Skip to content

Commit 5c77db2

Browse files
Mmdrame2017claude
andcommitted
feat(phase-1+): real L4 RS in Rust + CLI + Goldman ternary L1 profile
## Real Reed-Solomon in Rust (reference-rs/) - Switch dep from reed-solomon-erasure (erasure coding) to `reed-solomon` (byte-level RS, matches the Python reedsolo geometry). - error_correction.rs: real rs_encode_msg / rs_decode_msg + encode_with_ecc / decode_with_ecc + 3 unit tests (no-error, 10-error recovery, too-many). - semantic.rs: tag now correctly declares L4=rs255-223 (no more passthrough lie). - decoder.rs: length-based dispatch — RS decode if data % 255 == 0, else fall back to L4=none plaintext path. Same trick applied to the Python decoder so canonical L1+L5 vectors round-trip through both refs. ## CLI: aeonscript (reference/aeonscript/cli.py) - argparse-based, four subcommands: - encode INPUT [-o OUT.fasta] [-t MIME] [--id ID] - decode FASTA [-o OUTPUT] - inspect FASTA (shows stats + tag) - validate VECTORS.json (run against any conformance suite) - Registered as the `aeonscript` console script via pyproject.toml [project.scripts]. After `pip install -e .`, the user can run `aeonscript encode my_file.txt` directly. - Tests in tests/test_cli.py exercise encode/decode round-trip, inspect output, and the validate command against the canonical L1+L5 vectors. ## Goldman ternary L1 profile (reference/aeonscript/goldman.py) - First implementation of an alternative L1 profile demonstrating the v0.2 profile-system architecture works. - L1-3-goldman: each byte → 6 trits → 6 bases, each base differs from the previous one. Structurally prevents adjacent-base homopolymers. - Density: ~1.6 bits/base utiles (vs. 2 bits/base for L1-4) — the price of guaranteed-zero pairwise homopolymers. - Tests cover round-trip on random data, structural no-pair property, multiple initial-base seeds, density bound, and three negative cases. - Public API exported from `aeonscript`. ## Decoder permissiveness Both Python and Rust decoders now transparently accept blocks with L4=none (no Reed-Solomon parity bytes) by detecting via length: if the descrambled stream length is a positive multiple of 255 (full RS codeword size), the decoder runs RS; otherwise it passes through. This is what the canonical L1+L5 conformance vectors need — they were generated without RS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 71a2d30 commit 5c77db2

11 files changed

Lines changed: 650 additions & 49 deletions

File tree

reference-rs/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ categories = ["compression", "encoding", "science"]
1414
authors = ["The AeonScript Contributors"]
1515

1616
[dependencies]
17-
# Reed-Solomon: L4 codec via the well-maintained reed-solomon-erasure crate
18-
reed-solomon-erasure = "6.0"
17+
# Reed-Solomon: byte-level RS(n, k) over GF(256), matching the Python ref
18+
reed-solomon = "0.2"
1919
# Ergonomic error types
2020
thiserror = "1"
2121
# JSON for biosafety hazard DB loading

reference-rs/src/decoder.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,20 @@ pub fn decode_oligos(oligos: &[String]) -> Result<Vec<u8>, DecodeError> {
4242
// Descrambler
4343
let plain = descramble(&rs_encoded);
4444

45-
// L4: Reed-Solomon (v0.1 skeleton — passthrough)
46-
let (after_l4, _n_errors) = decode_with_ecc(&plain, DEFAULT_NSYM, DEFAULT_BLOCK)?;
45+
// L4: Reed-Solomon if the byte stream looks RS-encoded (length is a
46+
// positive multiple of the full block size 255). Otherwise we assume
47+
// L4=none (plaintext) — which is what the canonical L1+L5 test vectors
48+
// use.
49+
let full_block = DEFAULT_BLOCK + DEFAULT_NSYM;
50+
let after_l4 = if plain.len() >= full_block && plain.len() % full_block == 0 {
51+
match decode_with_ecc(&plain, DEFAULT_NSYM, DEFAULT_BLOCK) {
52+
Ok((decoded, _)) => decoded,
53+
// RS decode failure on aligned input is genuine corruption.
54+
Err(e) => return Err(DecodeError::Ecc(e)),
55+
}
56+
} else {
57+
plain
58+
};
4759

4860
// L5: find the tag
4961
if after_l4.first() != Some(&b'|') {
Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,123 @@
1-
//! Layer 4 — Reed-Solomon error correction.
1+
//! Layer 4 — Reed-Solomon error correction over GF(256).
22
//!
3-
//! This module is a thin wrapper over the [`reed_solomon_erasure`] crate.
4-
//! The default geometry is RS(255, 223) over GF(256), matching the Python
5-
//! reference (which uses `reedsolo`).
6-
//!
7-
//! v0.1 reference implementation: skeleton only — full RS encoding will be
8-
//! wired in v0.1.1. For now, the `encode_with_ecc` function passes data
9-
//! through untouched, with the L4 tag value set to `none` by the encoder.
10-
//!
11-
//! This compromise lets us ship a Rust v0.1 skeleton that compiles and
12-
//! round-trips L1+L5, while keeping the RS integration as a clean follow-up
13-
//! PR. The Python reference is the canonical L4 implementation for v0.1.
3+
//! Thin wrapper over the [`reed_solomon`] crate. The default geometry is
4+
//! RS(255, 223) matching the Python reference (which uses `reedsolo`).
5+
//! Conformant implementations MUST produce byte-identical codewords given
6+
//! identical inputs.
7+
8+
use reed_solomon::{Decoder, Encoder};
149

1510
/// Errors produced by the L4 codec.
1611
#[derive(Debug, thiserror::Error)]
1712
#[allow(missing_docs)]
1813
pub enum EccError {
19-
#[error("Reed-Solomon decoding failed: {0}")]
14+
#[error("Reed-Solomon decoding failed (too many errors): {0}")]
2015
DecodeFailed(String),
21-
#[error("Invalid block size {0} (must be 255 with parity)")]
16+
#[error("Invalid block size {0} (must be <= 255 with parity)")]
2217
InvalidBlockSize(usize),
18+
#[error("Encoded data length {0} not a multiple of full block size {1}")]
19+
UnalignedData(usize, usize),
2320
}
2421

2522
/// Default number of parity bytes per RS block. Per SPEC §3.1.
2623
pub const DEFAULT_NSYM: usize = 32;
27-
/// Default data bytes per RS block (n=255 − nsym).
24+
/// Default data bytes per RS block (n = 255 − nsym).
2825
pub const DEFAULT_BLOCK: usize = 223;
2926

30-
/// Encode bytes with Reed-Solomon RS(255, 223).
31-
///
32-
/// **v0.1 skeleton**: passes data through unchanged. The full implementation
33-
/// will use [`reed_solomon_erasure`] in v0.1.1.
34-
pub fn encode_with_ecc(data: &[u8], _nsym: usize, _block_size: usize) -> Vec<u8> {
35-
// TODO(v0.1.1): real RS encoding via reed-solomon-erasure
36-
data.to_vec()
27+
/// Encode a single RS block: `data.len() + nsym` bytes (data followed by parity).
28+
pub fn rs_encode_msg(data: &[u8], nsym: usize) -> Result<Vec<u8>, EccError> {
29+
if data.len() + nsym > 255 {
30+
return Err(EccError::InvalidBlockSize(data.len() + nsym));
31+
}
32+
let encoder = Encoder::new(nsym);
33+
let encoded = encoder.encode(data);
34+
Ok(encoded.iter().copied().collect())
35+
}
36+
37+
/// Decode a single RS codeword. Returns (decoded_message_bytes, errors_corrected).
38+
pub fn rs_decode_msg(codeword: &[u8], nsym: usize) -> Result<(Vec<u8>, usize), EccError> {
39+
let decoder = Decoder::new(nsym);
40+
let result = decoder
41+
.correct(codeword, None)
42+
.map_err(|e| EccError::DecodeFailed(format!("{:?}", e)))?;
43+
let n_errs = result.errors_count();
44+
let msg: Vec<u8> = result.data().to_vec();
45+
Ok((msg, n_errs))
46+
}
47+
48+
/// Split `data` into RS blocks of size `block_size` (zero-padding the last)
49+
/// and encode each. Output length = N × (block_size + nsym).
50+
pub fn encode_with_ecc(data: &[u8], nsym: usize, block_size: usize) -> Result<Vec<u8>, EccError> {
51+
let mut out = Vec::new();
52+
for chunk in data.chunks(block_size) {
53+
let mut padded = chunk.to_vec();
54+
if padded.len() < block_size {
55+
padded.resize(block_size, 0);
56+
}
57+
out.extend(rs_encode_msg(&padded, nsym)?);
58+
}
59+
Ok(out)
3760
}
3861

39-
/// Decode RS-encoded bytes, returning (payload, total_errors_corrected).
40-
///
41-
/// **v0.1 skeleton**: passes data through unchanged.
62+
/// Decode RS-encoded data, returning (concatenated_payload_bytes, total_errors_corrected).
63+
/// Caller (decoder.rs) trims the payload based on the L5 LEN field.
4264
pub fn decode_with_ecc(
4365
data: &[u8],
44-
_nsym: usize,
45-
_block_size: usize,
66+
nsym: usize,
67+
block_size: usize,
4668
) -> Result<(Vec<u8>, usize), EccError> {
47-
Ok((data.to_vec(), 0))
69+
let full_block = block_size + nsym;
70+
if data.len() % full_block != 0 {
71+
return Err(EccError::UnalignedData(data.len(), full_block));
72+
}
73+
let mut out = Vec::with_capacity(data.len() - (data.len() / full_block) * nsym);
74+
let mut total_errors = 0usize;
75+
for chunk in data.chunks(full_block) {
76+
let (msg, n_errs) = rs_decode_msg(chunk, nsym)?;
77+
out.extend(msg);
78+
total_errors += n_errs;
79+
}
80+
Ok((out, total_errors))
4881
}
4982

5083
#[cfg(test)]
5184
mod tests {
5285
use super::*;
5386

5487
#[test]
55-
fn passthrough_round_trip_v01_skeleton() {
56-
let data = b"some payload bytes";
57-
let encoded = encode_with_ecc(data, DEFAULT_NSYM, DEFAULT_BLOCK);
58-
let (decoded, n_err) = decode_with_ecc(&encoded, DEFAULT_NSYM, DEFAULT_BLOCK).unwrap();
59-
assert_eq!(decoded.as_slice(), data);
60-
assert_eq!(n_err, 0);
88+
fn round_trip_no_errors() {
89+
let data: Vec<u8> = (0..223).collect();
90+
let encoded = encode_with_ecc(&data, DEFAULT_NSYM, DEFAULT_BLOCK).unwrap();
91+
assert_eq!(encoded.len(), 255);
92+
let (decoded, n_errs) = decode_with_ecc(&encoded, DEFAULT_NSYM, DEFAULT_BLOCK).unwrap();
93+
assert_eq!(decoded, data);
94+
assert_eq!(n_errs, 0);
95+
}
96+
97+
#[test]
98+
fn corrects_up_to_16_byte_errors() {
99+
let data: Vec<u8> = (0..223).collect();
100+
let mut encoded = encode_with_ecc(&data, DEFAULT_NSYM, DEFAULT_BLOCK).unwrap();
101+
102+
// Flip 10 bytes (well within RS(255,223) 16-error capacity)
103+
let positions = [3usize, 17, 42, 88, 100, 130, 175, 200, 220, 250];
104+
for &p in &positions {
105+
encoded[p] ^= 0xAA;
106+
}
107+
let (decoded, n_errs) = decode_with_ecc(&encoded, DEFAULT_NSYM, DEFAULT_BLOCK).unwrap();
108+
assert_eq!(decoded, data);
109+
assert_eq!(n_errs, 10);
110+
}
111+
112+
#[test]
113+
fn fails_on_too_many_errors() {
114+
let data: Vec<u8> = (0..223).collect();
115+
let mut encoded = encode_with_ecc(&data, DEFAULT_NSYM, DEFAULT_BLOCK).unwrap();
116+
// Flip 20 bytes (exceeds 16-error capacity)
117+
for p in 0..20 {
118+
encoded[p * 10] ^= 0xFF;
119+
}
120+
let result = decode_with_ecc(&encoded, DEFAULT_NSYM, DEFAULT_BLOCK);
121+
assert!(matches!(result, Err(EccError::DecodeFailed(_))));
61122
}
62123
}

reference-rs/src/semantic.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,11 @@ pub fn validate_tag_value(field: &str, value: &str) -> Result<(), TagError> {
7979

8080
/// Build a canonical AeonScript v0.1 tag string.
8181
///
82-
/// **Note on `L4`** : v0.1 of the Rust reference ships an L4 *skeleton*
83-
/// (passthrough). Until the full Reed-Solomon integration lands in v0.1.1,
84-
/// the tag declares `L4=none` to be honest about what the wire format
85-
/// actually carries. The Python reference always declares `L4=rs255-223`.
82+
/// Declares `L4=rs255-223` — matching the Python reference, since the Rust
83+
/// reference now also implements RS(255, 223) via the `reed-solomon` crate.
8684
pub fn make_tag(mime_type: &str, payload_len: usize, block_id: &str) -> String {
8785
format!(
88-
"|AEONSCRIPT=0.1;L1=L1-4;L4=none;TYPE={mime};LEN={len};ID={id}|",
86+
"|AEONSCRIPT=0.1;L1=L1-4;L4=rs255-223;TYPE={mime};LEN={len};ID={id}|",
8987
mime = mime_type,
9088
len = payload_len,
9189
id = block_id

reference/aeonscript/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from .decoder import decode_oligos
2020
from .encoder import encode_bytes, encode_file
21+
from .goldman import bytes_to_goldman_dna, goldman_dna_to_bytes
2122
from .physical import bits_to_dna, dna_to_bits
2223
from .semantic import make_tag, parse_tag
2324

@@ -36,6 +37,8 @@
3637
"screen_oligos",
3738
"BioSafetyViolation",
3839
"HazardMatch",
40+
"bytes_to_goldman_dna",
41+
"goldman_dna_to_bytes",
3942
"__version__",
4043
"__spec_version__",
4144
]

0 commit comments

Comments
 (0)