SilverSight/python/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

374 lines
12 KiB
Python
Raw 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
"""
dna_codec.py — Hachimoji DNA Encoding/Decoding Layer
Translates between binary data and Hachimoji 8-base DNA sequences.
Uses the SilverSight Hachimoji alphabet (A, B, C, G, P, S, T, Z)
at 3 bits per base — 50% denser than standard 2-bit DNA encoding.
Encoding table (ASCII-ordered, satisfies monotonicity axiom):
000 → A (Φ) 001 → B (Λ) 010 → C (Ρ) 011 → G (Κ)
100 → P (Ω) 101 → S (Σ) 110 → T (Π) 111 → Z (Ζ)
ASCII order: A=0x41 < B=0x42 < C=0x43 < G=0x47 < P=0x50 < S=0x53 < T=0x54 < Z=0x5A
This guarantees: int_to_dna(v₁) < int_to_dna(v₂) iff v₁ < v₂ (lexicographic = rank).
Melting temperature contributions (nearest-neighbor simplified):
G/C pairs: 3 bonds → high Tm contribution
A/T pairs: 2 bonds → low Tm contribution
B/S, P/Z: synthetic pairs, assigned Tm values for encoding purposes
"""
from __future__ import annotations
from typing import List, Tuple
# ============================================================
# §1 HACHIMOJI ALPHABET
# ============================================================
# 3-bit → base (ASCII-ordered: A=0x41 < B=0x42 < C=0x43 < G=0x47 < P=0x50 < S=0x53 < T=0x54 < Z=0x5A)
# Monotonicity axiom: int rank = lexicographic rank. Matches HACHIMOJI_DNA_SYNTAX.md and HachimojiLUT.lean.
BITS_TO_BASE = {
0b000: "A",
0b001: "B",
0b010: "C",
0b011: "G",
0b100: "P",
0b101: "S",
0b110: "T",
0b111: "Z",
}
# base → 3-bit
BASE_TO_BITS = {v: k for k, v in BITS_TO_BASE.items()}
# All 8 bases in ASCII order
HACHIMOJI_BASES = list("ABCGPSTZ")
# Greek ↔ Latin (matches HachimojiLUT.lean phase assignments: A=0°=Φ, B=45°=Λ, …)
LATIN_TO_GREEK = {
"A": "Φ", "B": "Λ", "C": "Ρ", "G": "Κ",
"P": "Ω", "S": "Σ", "T": "Π", "Z": "Ζ",
}
GREEK_TO_LATIN = {v: k for k, v in LATIN_TO_GREEK.items()}
# ============================================================
# §2 MELTING TEMPERATURE MODEL
# ============================================================
# Per-base Tm contribution (°C, simplified nearest-neighbor model)
# Standard bases: G/C = 3 bonds, A/T = 2 bonds
# Synthetic bases: assigned for sorting purposes
TM_CONTRIBUTION = {
"A": 2.0, # 2 hydrogen bonds
"T": 2.0, # 2 hydrogen bonds
"G": 3.0, # 3 hydrogen bonds (canonical Watson-Crick)
"C": 3.0, # 3 hydrogen bonds (canonical Watson-Crick)
"B": 2.5, # synthetic, intermediate
"S": 2.5, # synthetic, intermediate
"P": 3.5, # synthetic, higher affinity (hachimoji pair)
"Z": 3.5, # synthetic, higher affinity (hachimoji pair)
}
# GC-equivalent weight for sorting (normalized)
# Maps each base to a "GC-equivalent" score for sorting purposes
GC_EQUIVALENT = {
"A": 0.0,
"T": 0.0,
"G": 1.0,
"C": 1.0,
"B": 0.5,
"S": 0.5,
"P": 1.5,
"Z": 1.5,
}
def melting_temperature(sequence: str) -> float:
"""Compute simplified melting temperature for a Hachimoji DNA sequence.
Uses a per-base contribution model (simplified from nearest-neighbor).
For sorting purposes — relative order matters more than absolute °C.
Args:
sequence: Hachimoji DNA string (characters from ATGCBSPZ)
Returns:
Melting temperature in °C (simplified)
"""
if not sequence:
return 0.0
base_tm = sum(TM_CONTRIBUTION.get(b, 0.0) for b in sequence)
# Wallace rule approximation: Tm ≈ sum of per-base contributions
return base_tm
def gc_content(sequence: str) -> float:
"""Compute GC-equivalent content for a Hachimoji sequence.
Standard GC content counts G+C / total. For Hachimoji, we use
the GC-equivalent weight: G/C = 1.0, B/S = 0.5, P/Z = 1.5, A/T = 0.0.
Args:
sequence: Hachimoji DNA string
Returns:
GC-equivalent content in [0, 1] (normalized)
"""
if not sequence:
return 0.0
total = sum(GC_EQUIVALENT.get(b, 0.0) for b in sequence)
# Normalize: max possible per base is 1.5 (P/Z), so divide by 1.5 * len
max_possible = 1.5 * len(sequence)
return total / max_possible if max_possible > 0 else 0.0
def raw_gc_content(sequence: str) -> float:
"""Standard GC content (G+C only, ignoring synthetic bases)."""
if not sequence:
return 0.0
gc = sum(1 for b in sequence if b in ("G", "C"))
return gc / len(sequence)
# ============================================================
# §3 ENCODING: bytes → Hachimoji DNA
# ============================================================
def bytes_to_bits(data: bytes) -> List[int]:
"""Convert bytes to a list of bits (MSB first per byte)."""
bits = []
for byte in data:
for i in range(7, -1, -1):
bits.append((byte >> i) & 1)
return bits
def bits_to_bytes(bits: List[int]) -> bytes:
"""Convert a list of bits back to bytes (MSB first per byte)."""
# Pad to multiple of 8
padded = bits + [0] * ((8 - len(bits) % 8) % 8)
result = bytearray()
for i in range(0, len(padded), 8):
byte = 0
for j in range(8):
byte = (byte << 1) | padded[i + j]
result.append(byte)
return bytes(result)
def encode_bytes_to_dna(data: bytes) -> str:
"""Encode arbitrary bytes to a Hachimoji DNA sequence.
Chunks bits into groups of 3, maps each to a base.
Pads with zeros if needed (last group may be short).
Args:
data: arbitrary bytes
Returns:
Hachimoji DNA string
"""
bits = bytes_to_bits(data)
# Pad to multiple of 3
while len(bits) % 3 != 0:
bits.append(0)
sequence = []
for i in range(0, len(bits), 3):
chunk = (bits[i] << 2) | (bits[i + 1] << 1) | bits[i + 2]
sequence.append(BITS_TO_BASE[chunk])
return "".join(sequence)
def decode_dna_to_bytes(sequence: str) -> bytes:
"""Decode a Hachimoji DNA sequence back to bytes.
Args:
sequence: Hachimoji DNA string
Returns:
Decoded bytes (may include padding zeros at the end)
"""
bits = []
for base in sequence:
if base not in BASE_TO_BITS:
raise ValueError(f"Invalid Hachimoji base: {base!r}")
chunk = BASE_TO_BITS[base]
bits.append((chunk >> 2) & 1)
bits.append((chunk >> 1) & 1)
bits.append(chunk & 1)
return bits_to_bytes(bits)
# ============================================================
# §4 BINARY VECTOR ENCODING: x ∈ {0,1}^n → DNA
# ============================================================
def encode_binary_vector(x: List[int], bases_per_var: int = 3) -> str:
"""Encode a binary vector as a Hachimoji DNA sequence.
Each variable gets `bases_per_var` copies of A (for 0) or P (for 1).
Redundant encoding: A has low Tm (2.0), P has high Tm (3.5), so Tm
is monotonically proportional to the number of 1s in x.
Args:
x: binary vector (list of 0s and 1s)
bases_per_var: number of bases per variable (default 3, for majority-vote decoding)
Returns:
Hachimoji DNA string of length len(x) * bases_per_var
"""
bits_per_var = bases_per_var # internal alias for callers using old parameter name
sequence = []
for val in x:
# Encode each variable as a base (0 → A, 1 → P for max Tm separation)
# Using A (low Tm) for 0 and P (high Tm) for 1
# This makes the Tm directly proportional to the number of 1s
for _ in range(bits_per_var):
sequence.append("A" if val == 0 else "P")
return "".join(sequence)
def decode_binary_vector(sequence: str, bits_per_var: int = 3) -> List[int]:
"""Decode a Hachimoji DNA sequence back to a binary vector.
Args:
sequence: Hachimoji DNA string
bits_per_var: bases per variable (must match encoding)
Returns:
Binary vector
"""
if len(sequence) % bits_per_var != 0:
raise ValueError(
f"Sequence length {len(sequence)} not divisible by bits_per_var={bits_per_var}"
)
result = []
for i in range(0, len(sequence), bits_per_var):
chunk = sequence[i : i + bits_per_var]
# Majority vote: if most bases are P → 1, else → 0
p_count = chunk.count("P")
result.append(1 if p_count > bits_per_var / 2 else 0)
return result
# ============================================================
# §5 QUBO ENCODING: energy → DNA sequence structure
# ============================================================
def qubo_energy(x: List[int], Q: List[List[float]]) -> float:
"""Compute QUBO energy E(x) = x^T Q x.
Args:
x: binary vector
Q: QUBO matrix (n×n, upper triangular or symmetric)
Returns:
Energy value
"""
n = len(x)
energy = 0.0
for i in range(n):
for j in range(n):
energy += Q[i][j] * x[i] * x[j]
return energy
def encode_qubo_solution(
x: List[int],
Q: List[List[float]],
bits_per_var: int = 3,
) -> Tuple[str, float]:
"""Encode a QUBO solution as a DNA sequence with energy metadata.
The sequence encodes the binary vector. The energy is stored as
a header (first 8 bases encode Q16_16 energy value).
Args:
x: binary solution vector
Q: QUBO matrix
bits_per_var: bases per variable
Returns:
(dna_sequence, energy)
"""
from q16_canonical import float_to_q16, q16_to_bytes, q16_from_bytes
energy = qubo_energy(x, Q)
# Encode energy as Q16_16 header (8 bases = 24 bits = 3 bytes)
energy_q16 = float_to_q16(energy)
energy_bytes = q16_to_bytes(energy_q16) # 4 bytes
energy_dna = encode_bytes_to_dna(energy_bytes) # ~11 bases
# Encode solution vector
solution_dna = encode_binary_vector(x, bits_per_var)
return energy_dna + "|" + solution_dna, energy
def decode_qubo_solution(
sequence: str,
n_vars: int,
bits_per_var: int = 3,
) -> Tuple[List[int], float]:
"""Decode a QUBO solution from DNA sequence.
Args:
sequence: DNA sequence with energy header
n_vars: number of variables in the QUBO
bits_per_var: bases per variable
Returns:
(binary_vector, energy)
"""
from q16_canonical import q16_from_bytes, q16_to_float
# Split header and body
parts = sequence.split("|")
if len(parts) != 2:
raise ValueError("Invalid QUBO DNA format: expected energy|solution")
energy_dna, solution_dna = parts
# Decode energy header
energy_bytes = decode_dna_to_bytes(energy_dna)
energy_q16 = q16_from_bytes(energy_bytes[:4]) # first 4 bytes
energy = q16_to_float(energy_q16)
# Decode solution vector
x = decode_binary_vector(solution_dna, bits_per_var)
return x[:n_vars], energy
# ============================================================
# §6 CONVENIENCE
# ============================================================
def dna_to_greek(sequence: str) -> str:
"""Convert Latin Hachimoji DNA to Greek notation."""
return "".join(LATIN_TO_GREEK.get(b, b) for b in sequence)
def greek_to_dna(sequence: str) -> str:
"""Convert Greek Hachimoji notation to Latin DNA."""
return "".join(GREEK_TO_LATIN.get(g, g) for g in sequence)
def sequence_stats(sequence: str) -> dict:
"""Compute useful statistics for a Hachimoji DNA sequence."""
return {
"length": len(sequence),
"gc_content": round(gc_content(sequence), 4),
"raw_gc_content": round(raw_gc_content(sequence), 4),
"melting_temperature": round(melting_temperature(sequence), 2),
"base_counts": {b: sequence.count(b) for b in HACHIMOJI_BASES if b in sequence},
"greek": dna_to_greek(sequence),
}