Files
obitools4/pkg/obialign/lcsband.go
T
Eric Coissac 49225030ca Implement core clustering logic and scoring features
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.
2026-11-03 09:32:40 +11:00

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)
}