#!/usr/bin/env python3 """ dna_encode_file.py — Encode arbitrary files as Hachimoji DNA sequences Takes any file, chunks it into symbols, builds a monotone LUT, and outputs the DNA encoding. The LUT makes the encoding self-describing: anyone with the LUT can decode the DNA back to the original file. Usage: python dna_encode_file.py input.bin [--output output.dna] [--chunk-size 1] python dna_encode_file.py --decode input.dna --lut input.lut --output restored.bin Chunk sizes: 1 byte → 256 symbols → 2 bases each (8^2 = 64 ≥ 256? no, need 3 bases) 1 byte → 256 symbols → ceil(log8(256)) = 3 bases per symbol 2 bytes → 65536 symbols → ceil(log8(65536)) = 6 bases per symbol The monotone LUT assigns shortest DNA sequences to most frequent symbols, so the DNA output is naturally compact for data with skewed distributions. """ from __future__ import annotations import hashlib import json import os import struct import sys import time from collections import Counter from typing import Dict, List, Optional, Tuple from dataclasses import dataclass # ============================================================ # §1 HACHIMOJI ALPHABET (ASCII-ordered for monotone encoding) # ============================================================ BASES = list("ABCGPSTZ") N_BASES = len(BASES) BASE_TO_INDEX = {b: i for i, b in enumerate(BASES)} INDEX_TO_BASE = {i: b for i, b in enumerate(BASES)} def int_to_dna(value: int, length: int) -> str: """Convert integer to DNA sequence (base-8, fixed length).""" seq = [] for _ in range(length): seq.append(INDEX_TO_BASE[value % N_BASES]) value //= N_BASES return "".join(reversed(seq)) def dna_to_int(sequence: str) -> int: """Convert DNA sequence to integer.""" value = 0 for b in sequence: value = value * N_BASES + BASE_TO_INDEX[b] return value def bases_needed(n_symbols: int) -> int: """Minimum bases per symbol to represent n_symbols unique keys.""" if n_symbols <= 0: return 0 length = 1 while N_BASES ** length < n_symbols: length += 1 return length # ============================================================ # §2 MONOTONE LUT BUILDER # ============================================================ def build_frequency_lut( data: bytes, chunk_size: int = 1, ) -> Tuple[Dict[bytes, str], Dict[str, bytes], int]: """Build a monotone LUT from data frequency. Most frequent chunks get shortest / lowest-index DNA sequences. This is the key to making the encoding work as a compression format: frequent symbols → short sequences, rare symbols → long sequences. Args: data: raw bytes chunk_size: bytes per symbol (1, 2, or 4) Returns: (encode_lut, decode_lut, bases_per_symbol) """ # Count chunk frequencies counts: Counter[bytes] = Counter() for i in range(0, len(data) - chunk_size + 1, chunk_size): chunk = data[i : i + chunk_size] if len(chunk) == chunk_size: counts[chunk] += 1 # Handle trailing bytes shorter than chunk_size remainder = len(data) % chunk_size if remainder > 0: counts[data[-remainder:]] += 1 # Sort by frequency (descending) — most frequent gets lowest index sorted_chunks = sorted(counts.keys(), key=lambda c: -counts[c]) # Build LUT n_symbols = len(sorted_chunks) bps = bases_needed(n_symbols) encode_lut: Dict[bytes, str] = {} decode_lut: Dict[str, bytes] = {} for rank, chunk in enumerate(sorted_chunks): seq = int_to_dna(rank, bps) encode_lut[chunk] = seq decode_lut[seq] = chunk return encode_lut, decode_lut, bps def build_uniform_lut(chunk_size: int = 1) -> Tuple[Dict[bytes, str], Dict[str, bytes], int]: """Build a uniform LUT (all chunks, frequency-independent). For chunk_size=1: 256 entries, 3 bases per symbol. This is the simple case — no frequency optimization. """ n_symbols = 256 ** chunk_size bps = bases_needed(n_symbols) encode_lut: Dict[bytes, str] = {} decode_lut: Dict[str, bytes] = {} for i in range(n_symbols): chunk = i.to_bytes(chunk_size, "big") seq = int_to_dna(i, bps) encode_lut[chunk] = seq decode_lut[seq] = chunk return encode_lut, decode_lut, bps # ============================================================ # §3 ENCODING # ============================================================ @dataclass class EncodedFile: """Result of encoding a file as DNA.""" dna_sequence: str lut: Dict[str, bytes] chunk_size: int bases_per_symbol: int original_size: int encoded_length: int sha256: str encode_time: float def to_metadata(self) -> dict: return { "chunk_size": self.chunk_size, "bases_per_symbol": self.bases_per_symbol, "original_size": self.original_size, "encoded_length": self.encoded_length, "sha256": self.sha256, "encode_time": round(self.encode_time, 4), "lut_entries": len(self.lut), "bases": "".join(BASES), } def encode_file( data: bytes, chunk_size: int = 1, use_frequency: bool = True, ) -> EncodedFile: """Encode arbitrary bytes as a Hachimoji DNA sequence. Args: data: raw bytes chunk_size: bytes per symbol (1, 2, or 4) use_frequency: if True, build frequency-optimized LUT Returns: EncodedFile with DNA sequence and metadata """ t0 = time.time() # Build LUT if use_frequency: enc_lut, dec_lut, bps = build_frequency_lut(data, chunk_size) else: enc_lut, dec_lut, bps = build_uniform_lut(chunk_size) # Encode dna_parts = [] for i in range(0, len(data), chunk_size): chunk = data[i : i + chunk_size] if len(chunk) < chunk_size: # Pad remainder with zeros chunk = chunk + b"\x00" * (chunk_size - len(chunk)) dna_parts.append(enc_lut[chunk]) dna = "".join(dna_parts) elapsed = time.time() - t0 return EncodedFile( dna_sequence=dna, lut=dec_lut, chunk_size=chunk_size, bases_per_symbol=bps, original_size=len(data), encoded_length=len(dna), sha256=hashlib.sha256(data).hexdigest(), encode_time=elapsed, ) def decode_file(dna: str, lut: Dict[str, bytes], chunk_size: int, bases_per_symbol: int) -> bytes: """Decode a DNA sequence back to bytes using the LUT. Args: dna: DNA sequence lut: decode LUT (sequence → bytes) chunk_size: bytes per symbol bases_per_symbol: bases per symbol Returns: Decoded bytes """ parts = [] for i in range(0, len(dna), bases_per_symbol): seq = dna[i : i + bases_per_symbol] if seq in lut: parts.append(lut[seq]) else: # Unknown sequence — fill with zeros parts.append(b"\x00" * chunk_size) return b"".join(parts) # ============================================================ # §4 LUT FILE FORMAT # ============================================================ def save_lut(lut: Dict[str, bytes], path: str, chunk_size: int, bases_per_symbol: int): """Save LUT to a JSON file.""" data = { "format": "hachimoji_lut_v1", "bases": "".join(BASES), "chunk_size": chunk_size, "bases_per_symbol": bases_per_symbol, "entries": {seq: chunk.hex() for seq, chunk in lut.items()}, } with open(path, "w") as f: json.dump(data, f, indent=2) def load_lut(path: str) -> Tuple[Dict[str, bytes], int, int]: """Load LUT from a JSON file. Returns: (decode_lut, chunk_size, bases_per_symbol) """ with open(path) as f: data = json.load(f) chunk_size = data["chunk_size"] bps = data["bases_per_symbol"] lut = {seq: bytes.fromhex(hex_str) for seq, hex_str in data["entries"].items()} return lut, chunk_size, bps def save_dna(dna: str, path: str): """Save DNA sequence to a text file.""" with open(path, "w") as f: f.write(dna) def load_dna(path: str) -> str: """Load DNA sequence from a text file.""" with open(path) as f: return f.read().strip() # ============================================================ # §5 CLI # ============================================================ def main(): import argparse parser = argparse.ArgumentParser(description="Encode/decode files as Hachimoji DNA") parser.add_argument("input", help="Input file") parser.add_argument("--output", "-o", help="Output file (default: input.dna)") parser.add_argument("--decode", "-d", action="store_true", help="Decode mode") parser.add_argument("--lut", help="LUT file (required for decode)") parser.add_argument("--chunk-size", type=int, default=1, choices=[1, 2, 4], help="Bytes per symbol (default: 1)") parser.add_argument("--no-frequency", action="store_true", help="Use uniform LUT instead of frequency-optimized") parser.add_argument("--save-lut", help="Save LUT to file") args = parser.parse_args() if args.decode: # Decode mode if not args.lut: parser.error("--lut required for decode mode") dna = load_dna(args.input) lut, chunk_size, bps = load_lut(args.lut) data = decode_file(dna, lut, chunk_size, bps) out_path = args.output or args.input.replace(".dna", ".bin") with open(out_path, "wb") as f: f.write(data) print(f"Decoded {len(dna)} bases → {len(data)} bytes → {out_path}") else: # Encode mode with open(args.input, "rb") as f: data = f.read() result = encode_file(data, args.chunk_size, not args.no_frequency) out_path = args.output or args.input + ".dna" save_dna(result.dna_sequence, out_path) # Save LUT lut_path = args.save_lut or args.input + ".lut" save_lut(result.lut, lut_path, result.chunk_size, result.bases_per_symbol) # Print stats m = result.to_metadata() print(f"Encoded: {m['original_size']} bytes → {m['encoded_length']} bases") print(f" Chunk size: {m['chunk_size']} byte(s)") print(f" Bases/symbol: {m['bases_per_symbol']}") print(f" LUT entries: {m['lut_entries']}") print(f" SHA-256: {m['sha256']}") print(f" Time: {m['encode_time']}s") print(f" DNA output: {out_path}") print(f" LUT output: {lut_path}") # Verify roundtrip decoded = decode_file(result.dna_sequence, result.lut, result.chunk_size, result.bases_per_symbol) decoded_trimmed = decoded[:len(data)] if decoded_trimmed == data: print(f" Roundtrip: ✓ VERIFIED") else: print(f" Roundtrip: ✗ FAILED") if __name__ == "__main__": main()