onehot_dna 0.1.0

Fixed-length one-hot DNA encoding for barcode matching and correction
Documentation
  • Coverage
  • 61.54%
    16 out of 26 items documented0 out of 17 items with examples
  • Size
  • Source code size: 19.07 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 584.11 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 5s Average build duration of successful builds.
  • all releases: 5s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • stela2502/onehot_dna
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • stela2502

onehot_dna

Small fixed-length one-hot DNA encoding for fast barcode and primer matching in Rust.

This crate is meant for short sequences where all candidates have the same length, for example single-cell barcode blocks, sample tags, primer fragments, or other small DNA tokens that need fast exact or fuzzy matching.

The central type is:

OneHot<const N: usize>

It stores N DNA bases in a single u128, using four bits per base. Therefore N <= 32.

Encoding

Each DNA base is encoded as a four-bit one-hot mask:

Base Bits
A 0001
C 0010
G 0100
T 1000
anything else, including N 0000

Unknown bases are encoded as zero. They match nothing and therefore count as mismatches. This is intentional for barcode correction: an ambiguous base should not accidentally rescue a hit.

Install

Add the crate directly from GitHub:

[dependencies]

onehot_dna = { git = "https://github.com/stela2502/onehot_dna" }

Or, for a fixed revision:

[dependencies]

onehot_dna = { git = "https://github.com/stela2502/onehot_dna", rev = "<commit>" }

Then import the types you need:

use onehot_dna::{OneHot, OneHot9, OneHotSet};

Basic usage

Encode a fixed-length DNA sequence:

use onehot_dna::OneHot;

let barcode = OneHot::<9>::from_bytes(b"ACGTACGTN")?;

assert_eq!(barcode.to_dna_string(), "ACGTACGTN");
println!("{barcode}");
# Ok::<(), Box<dyn std::error::Error>>(())

The length is part of the type. A OneHot<9> and a OneHot<16> are different types, which helps avoid mixing incompatible barcode classes by accident.

use onehot_dna::OneHot;

let cell_block = OneHot::<9>::from_bytes(b"ACGTACGTA")?;
let umi_like   = OneHot::<8>::from_bytes(b"ACGTACGT")?;

// cell_block and umi_like cannot be compared directly because their lengths differ.
# Ok::<(), Box<dyn std::error::Error>>(())

Extracting a window from a read

Use from_window when the barcode/primer segment is embedded in a larger read:

use onehot_dna::OneHot;

let read = b"XXACGTACGTNYY";
let block = OneHot::<9>::from_window(read, 2)?;

assert_eq!(block.to_dna_string(), "ACGTACGTN");
# Ok::<(), Box<dyn std::error::Error>>(())

Exact and fuzzy matching

mismatches returns the barcode-style Hamming distance between two encoded sequences of the same length:

use onehot_dna::OneHot;

let a = OneHot::<9>::from_bytes(b"AAAAAAAAA")?;
let b = OneHot::<9>::from_bytes(b"AAAAAAAAC")?;
let n = OneHot::<9>::from_bytes(b"AAAAAAAAN")?;

assert_eq!(a.mismatches(b), 1);
assert_eq!(a.mismatches(n), 1); // N/unknown matches nothing

assert!(a.within(b, 1));
assert!(!a.within(b, 0));
# Ok::<(), Box<dyn std::error::Error>>(())

Exact matching is simple bit equality:

use onehot_dna::OneHot;

let a = OneHot::<9>::from_bytes(b"ACGTACGTA")?;
let b = OneHot::<9>::from_bytes(b"ACGTACGTA")?;

assert!(a.exact_match(b));
# Ok::<(), Box<dyn std::error::Error>>(())

Candidate tables with OneHotSet

OneHotSet<N> is a small candidate table for fixed-length barcodes or primers.

use onehot_dna::{OneHot9, OneHotSet};

let candidates = OneHotSet::<9>::from_sequences(&[
    b"AAAAAAAAA".as_slice(),
    b"CCCCCCCCC".as_slice(),
    b"GGGGGGGGG".as_slice(),
])?;

let query = OneHot9::from_bytes(b"AAAAAAAAC")?;
let hit = candidates.best_match(&query, 1);

assert_eq!(hit, Some((0, 1)));
# Ok::<(), Box<dyn std::error::Error>>(())

best_match returns:

Option<(usize, u32)>

where the tuple is (candidate_index, mismatch_distance).

It returns None when:

  • no candidate is within max_mismatches
  • the best hit is tied between multiple candidates

This is useful for barcode correction where a non-unique correction should be rejected rather than guessed.

Example tie rejection:

use onehot_dna::{OneHot9, OneHotSet};

let candidates = OneHotSet::<9>::from_sequences(&[
    b"AAAAAAAAA".as_slice(),
    b"AAAAAAAAC".as_slice(),
])?;

let query = OneHot9::from_bytes(b"AAAAAAAAN")?;

// The query is one mismatch away from both candidates, so the match is ambiguous.
assert_eq!(candidates.best_match(&query, 1), None);
# Ok::<(), Box<dyn std::error::Error>>(())

Estimating a safe correction radius

OneHotSet can report the minimum pairwise distance between all candidates:

use onehot_dna::OneHotSet;

let candidates = OneHotSet::<9>::from_sequences(&[
    b"AAAAAAAAA".as_slice(),
    b"AAAAAAAAC".as_slice(),
    b"CCCCCCCCC".as_slice(),
])?;

let min_distance = candidates.min_pairwise_mismatches();
let radius = candidates.correction_radius();

assert_eq!(min_distance, Some(1));
assert_eq!(radius, Some(0));
# Ok::<(), Box<dyn std::error::Error>>(())

The correction radius is computed as:

(min_pairwise_distance - 1) / 2

That is the conservative Hamming-style radius where two valid candidates should not claim the same observed sequence.

For real barcode whitelists you may still want to set your own max_mismatches, because empirical sequencing error and whitelist design often matter more than the theoretical global radius.

BD Rhapsody 9 bp blocks

The crate includes a convenience alias:

pub type OneHot9 = OneHot<9>;

This is useful for BD Rhapsody-style C1/C2/C3 barcode blocks:

use onehot_dna::{OneHot9, OneHotSet};

let c1_table = OneHotSet::<9>::from_sequences(&[
    b"ACGTACGTA".as_slice(),
    b"TGCATGCAT".as_slice(),
])?;

let observed_c1 = OneHot9::from_bytes(b"ACGTACGTT")?;

if let Some((idx, dist)) = c1_table.best_match(&observed_c1, 1) {
    println!("corrected C1 block to candidate {idx} with {dist} mismatch(es)");
}
# Ok::<(), Box<dyn std::error::Error>>(())

Raw bits

If you need compact storage or precomputed constants, use bits and from_bits:

use onehot_dna::OneHot;

let x = OneHot::<9>::from_bytes(b"ACGTACGTA")?;
let packed: u128 = x.bits();

let restored = OneHot::<9>::from_bits(packed);
assert_eq!(x, restored);
# Ok::<(), Box<dyn std::error::Error>>(())

from_bits does not validate that every four-bit nibble contains exactly one legal base bit. It is intended for trusted packed values.

Error handling

Construction can fail when the sequence length does not match N, or when N > 32:

use onehot_dna::{OneHot, OneHotError};

let err = OneHot::<9>::from_bytes(b"ACGT").unwrap_err();

assert_eq!(
    err,
    OneHotError::WrongLength {
        expected: 9,
        observed: 4,
    }
);

Design notes

This crate is intentionally small:

  • no allocation for individual OneHot<N> values
  • compact u128 storage
  • const-generic fixed length
  • simple mismatch counting
  • unknown bases treated as non-matching
  • deterministic unique-best-hit correction via OneHotSet::best_match

It is best suited for short fixed-length DNA tokens, not for general sequence alignment.

Run tests

cargo test

License

Add your license information here.