mirror of
https://github.com/metabarcoding/obitools4.git
synced 2026-11-09 09:24:41 +00:00
This update introduces the core clustering algorithm, including parallel execution, dynamic block sizing, and SIMD-accelerated scoring methods. It also adds robust sequence preparation, k-mer analysis, and comprehensive test data fixtures for validation.
186 lines
6.3 KiB
Go
186 lines
6.3 KiB
Go
package obialign
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obiseq"
|
|
)
|
|
|
|
// MaxBandedLCSLength is the longest sequence BandedLCS accepts. The score and
|
|
// the alignment length are packed on 16 bits each, and the alignment length
|
|
// can reach the sum of both sequence lengths.
|
|
const MaxBandedLCSLength = 1<<15 - 1
|
|
|
|
// ErrSequenceTooLong is returned by BandedLCS when a sequence is longer than
|
|
// MaxBandedLCSLength.
|
|
var ErrSequenceTooLong = errors.New("obialign: sequence too long for BandedLCS")
|
|
|
|
// A DP cell is packed on 32 bits as
|
|
//
|
|
// score<<16 | (lcsLenBase - alignment length)
|
|
//
|
|
// so that the maximum of two cells ranks them by score first and, for equal
|
|
// scores, by shortest alignment. The three moves then reduce to
|
|
//
|
|
// cell(i,j) = max(diag + match<<16, up, left) - 1
|
|
//
|
|
// since every move lengthens the alignment by one and only a diagonal match
|
|
// adds one to the score. The value 0 stands for an unavailable cell (outside
|
|
// the band): it loses against any real cell. Every cell of the band has at
|
|
// least one real neighbour (the band is connected and contains (0,0)), so
|
|
// the subtraction never wraps for a cell that is used; the scalar reference
|
|
// still clamps the maximum to 1 as a safety net.
|
|
const (
|
|
lcsScoreUnit = uint32(1) << 16
|
|
lcsLenBase = uint32(0xFFFF)
|
|
)
|
|
|
|
// bandedLCSWorkspace holds the scratch memory of one alignment.
|
|
type bandedLCSWorkspace struct {
|
|
aRev, bCode []uint32 // encoded bases (aRev is reversed), SIMD kernel only
|
|
rows [3][]uint32 // antidiagonal (SIMD) or row (scalar) buffers
|
|
aRev16 []uint16 // 16-bit encoded bases of the score-only kernel
|
|
bCode16 []uint16
|
|
rows16 [3][]uint16
|
|
widened int // number of alignments that needed a second pass
|
|
}
|
|
|
|
var bandedLCSPool = sync.Pool{New: func() any { return new(bandedLCSWorkspace) }}
|
|
|
|
func growUint32(s []uint32, n int) []uint32 {
|
|
if cap(s) < n {
|
|
return make([]uint32, n)
|
|
}
|
|
return s[:n]
|
|
}
|
|
|
|
// iupacMask gives for each byte its 4-bit nucleotide set (A=1, C=2, G=4,
|
|
// T=8), or 0 when the byte is not an IUPAC letter. For two bytes of non-zero
|
|
// mask, SameIUPACNuc(a, b) is exactly (mask[a] & mask[b]) != 0.
|
|
var iupacMask = buildIUPACMask()
|
|
|
|
func buildIUPACMask() (m [256]uint8) {
|
|
probes := [4]byte{'a', 'c', 'g', 't'}
|
|
for c := 0; c < 256; c++ {
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
|
|
continue
|
|
}
|
|
for bit, p := range probes {
|
|
if obiseq.SameIUPACNuc(byte(c), p) {
|
|
m[c] |= 1 << uint(bit)
|
|
}
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// allRegularIUPAC reports whether every base has a non-zero IUPAC mask, in
|
|
// which case base compatibility is a plain AND of masks.
|
|
func allRegularIUPAC(s []byte) bool {
|
|
for _, c := range s {
|
|
if iupacMask[c] == 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// bandOffsets returns the diagonal offsets k = j - i covered by a band of
|
|
// half-width g around the diagonals 0..delta, with sequence A (columns j) not
|
|
// shorter than sequence B (rows i), clipped to the matrix.
|
|
func bandOffsets(lA, lB, g int) (kmin, kmax int) {
|
|
return max(-g, -lB), min(lA-lB+g, lA)
|
|
}
|
|
|
|
// BandedLCS computes the score of the longest common subsequence of two
|
|
// nucleotide sequences, and the length of the shortest alignment achieving
|
|
// it (match: +1, mismatch and gap: 0; bases are compared with the IUPAC
|
|
// rules of obiseq.SameIUPACNuc). The distance is alilen - lcs.
|
|
//
|
|
// If maxError >= 0, the result is the optimal (lcs, alilen) when its distance
|
|
// is at most maxError, and (-1, -1) otherwise. This is exact, not a
|
|
// heuristic: the DP is restricted to a band, and a bound on what a path
|
|
// leaving the band can score proves when the band was wide enough. maxError < 0
|
|
// means no limit.
|
|
//
|
|
// An error is returned when a sequence is longer than MaxBandedLCSLength.
|
|
func BandedLCS(seqA, seqB []byte, maxError int) (lcs, alilen int, err error) {
|
|
if len(seqA) > MaxBandedLCSLength || len(seqB) > MaxBandedLCSLength {
|
|
return -1, -1, ErrSequenceTooLong
|
|
}
|
|
if len(seqA) < len(seqB) {
|
|
seqA, seqB = seqB, seqA
|
|
}
|
|
ws := bandedLCSPool.Get().(*bandedLCSWorkspace)
|
|
lcs, alilen = bandedLCS(seqA, seqB, maxError, ws, haveSIMD)
|
|
bandedLCSPool.Put(ws)
|
|
return lcs, alilen, nil
|
|
}
|
|
|
|
// bandedLCS expects len(a) >= len(b), both within MaxBandedLCSLength.
|
|
//
|
|
// With delta = lA - lB and a band of half-width g (offsets -g .. delta+g):
|
|
// - an alignment with at most E errors needs at least delta+2h gaps to reach
|
|
// the offset delta+h or -h, so it lies in the band of g0 = (E-delta)/2;
|
|
// - a path leaving a band of half-width g makes at least g+1 gaps on the B
|
|
// side, hence scores at most lB-g-1 matches, and has more than E errors
|
|
// as soon as g >= g0.
|
|
//
|
|
// Let Q be the best path of the band g0. If its distance exceeds E, the
|
|
// global optimum cannot be within E either (it would be in the band and be
|
|
// Q): the answer is -1. Otherwise, if score(Q) >= lB-g0-1 no outside path can
|
|
// beat Q (a tie in score loses on length, the outside path having more errors
|
|
// than Q): Q is the global optimum. Else the band is widened once to
|
|
// g1 = min(E-delta, lB-score(Q)-1); since score(Q) >= lA-E for any alignment
|
|
// within E errors, g1 always certifies the result.
|
|
func bandedLCS(a, b []byte, maxError int, ws *bandedLCSWorkspace, useSIMD bool) (int, int) {
|
|
lA, lB := len(a), len(b)
|
|
delta := lA - lB
|
|
if maxError >= 0 && maxError < delta {
|
|
return -1, -1
|
|
}
|
|
if lB == 0 {
|
|
return 0, lA
|
|
}
|
|
simdOK := useSIMD && allRegularIUPAC(a) && allRegularIUPAC(b)
|
|
if simdOK {
|
|
encodeBases(ws, a, b)
|
|
}
|
|
run := func(g int) (int, int) {
|
|
kmin, kmax := bandOffsets(lA, lB, g)
|
|
return ws.align(a, b, kmin, kmax, simdOK)
|
|
}
|
|
|
|
if maxError < 0 {
|
|
return run(lA)
|
|
}
|
|
g0 := (maxError - delta) / 2
|
|
lcs, alilen := run(g0)
|
|
if alilen-lcs > maxError {
|
|
return -1, -1
|
|
}
|
|
if lcs >= lB-g0-1 {
|
|
return lcs, alilen
|
|
}
|
|
ws.widened++
|
|
lcs, alilen = run(min(maxError-delta, lB-lcs-1))
|
|
if alilen-lcs > maxError {
|
|
return -1, -1
|
|
}
|
|
return lcs, alilen
|
|
}
|
|
|
|
// align runs the banded DP on the offsets [kmin, kmax] (kmin <= 0 <= kmax) and
|
|
// returns the best (lcs, alilen) of the band. The bases must have been encoded
|
|
// with encodeBases when simdOK is set.
|
|
func (ws *bandedLCSWorkspace) align(a, b []byte, kmin, kmax int, simdOK bool) (int, int) {
|
|
var key uint32
|
|
if simdOK {
|
|
key = bandedLCSSIMD(len(a), len(b), kmin, kmax, ws)
|
|
} else {
|
|
key = bandedLCSScalar(a, b, kmin, kmax, ws)
|
|
}
|
|
return int(key >> 16), int(lcsLenBase - key&0xFFFF)
|
|
}
|