Fix super k-mer minimizer bijection and add validation test

This commit addresses a bug in the super k-mer implementation where the minimizer bijection property was not properly enforced. The fix ensures that:

1. All k-mers within a super k-mer share the same minimizer
2. Identical super k-mer sequences have the same minimizer

The changes include:

- Fixing the super k-mer iteration logic to properly validate the minimizer bijection property
- Adding a comprehensive test suite (TestSuperKmerMinimizerBijection) that validates the intrinsic property of super k-mers
- Updating the .gitignore file to properly track relevant files

This resolves issues where the same sequence could be associated with different minimizers, violating the super k-mer definition.
This commit is contained in:
Eric Coissac
2026-02-08 13:44:23 +01:00
parent 7a979ba77f
commit db98ddb241
4 changed files with 219 additions and 3 deletions

5
.gitignore vendored
View File

@@ -27,7 +27,8 @@ xx
!/obitests/**
!/sample/**
LLM/**
LLM/**
*_files
entropy.html
entropy.html
bug_id.txt

View File

@@ -0,0 +1,99 @@
# Définition du super k-mer
## Définition
Un **super k-mer** est une **sous-séquence MAXIMALE** d'une séquence dans laquelle **tous les k-mers consécutifs partagent le même minimiseur**.
### Termes
- **k-mer** : sous-séquence de longueur k
- **minimiseur** : le plus petit m-mer canonique parmi tous les m-mers d'un k-mer
- **k-mers consécutifs** : k-mers aux positions i et i+1 (chevauchement de k-1 nucléotides)
- **MAXIMALE** : ne peut être étendue ni à gauche ni à droite
## RÈGLES ABSOLUES
### RÈGLE 1 : Longueur minimum = k
Un super k-mer contient au minimum k nucléotides.
```
longueur(super-kmer) >= k
```
### RÈGLE 2 : Chevauchement obligatoire = k-1
Deux super-kmers consécutifs se chevauchent d'EXACTEMENT k-1 nucléotides.
```
SK1.End - SK2.Start = k - 1
```
### RÈGLE 3 : Bijection séquence ↔ minimiseur
Une séquence de super k-mer a UN et UN SEUL minimiseur.
```
Même séquence → Même minimiseur (TOUJOURS)
```
**Si vous observez la même séquence avec deux minimiseurs différents, c'est un BUG.**
### RÈGLE 4 : Tous les k-mers partagent le minimiseur
TOUS les k-mers contenus dans un super k-mer ont le même minimiseur.
```
∀ k-mer K dans SK : minimiseur(K) = SK.minimizer
```
### RÈGLE 5 : Maximalité
Un super k-mer ne peut pas être étendu.
- Si on ajoute un nucléotide à gauche : le nouveau k-mer a un minimiseur différent
- Si on ajoute un nucléotide à droite : le nouveau k-mer a un minimiseur différent
## VIOLATIONS INTERDITES
**Super k-mer de longueur < k**
**Chevauchement ≠ k-1 entre consécutifs**
**Même séquence avec minimiseurs différents**
**K-mer dans le super k-mer avec minimiseur différent**
**Super k-mer extensible (non-maximal)**
## CONSÉQUENCES PRATIQUES
### Pour l'extraction
L'algorithme doit :
1. Calculer le minimiseur de chaque k-mer
2. Découper quand le minimiseur change
3. Assigner au super k-mer le minimiseur commun à tous ses k-mers
4. Garantir que chaque super k-mer contient au moins k nucléotides
5. Garantir le chevauchement de k-1 entre consécutifs
### Pour la validation
Si après déduplication (obiuniq) on observe :
```
Séquence: ACGT...
Minimiseurs: {M1, M2} // plusieurs minimiseurs
```
C'est la PREUVE d'un bug : l'algorithme a produit cette séquence avec des minimiseurs différents, ce qui viole la RÈGLE 3.
## DIAGNOSTIC DU BUG
**Bug observé** : Même séquence avec minimiseurs différents après obiuniq
**Cause possible** : L'algorithme assigne le mauvais minimiseur OU découpe mal les super-kmers
**Ce que le bug NE PEUT PAS être** :
- Un problème d'obiuniq (révèle le bug, ne le crée pas)
- Un problème de chevauchement légitime (k-1 est correct)
**Ce que le bug DOIT être** :
- Minimiseur mal calculé ou mal assigné
- Découpage incorrect (mauvais endPos)
- Copie incorrecte des données

View File

@@ -95,7 +95,7 @@ func IterSuperKmers(seq []byte, k int, m int) iter.Seq[SuperKmer] {
}
}
if !firstKmer {
if !firstKmer && len(seq[superKmerStart:]) >= k {
superKmer := SuperKmer{
Minimizer: currentMinimizer,
Start: superKmerStart,

View File

@@ -77,6 +77,122 @@ func TestIterSuperKmersVsSlice(t *testing.T) {
}
}
// TestSuperKmerMinimizerBijection validates the intrinsic property that
// a super k-mer sequence has one and only one minimizer (bijection property).
// This test ensures that:
// 1. All k-mers in a super k-mer share the same minimizer
// 2. Two identical super k-mer sequences must have the same minimizer
func TestSuperKmerMinimizerBijection(t *testing.T) {
testCases := []struct {
name string
seq []byte
k int
m int
}{
{
name: "simple sequence",
seq: []byte("ACGTACGTACGTACGTACGTACGTACGTACGT"),
k: 21,
m: 11,
},
{
name: "homopolymer blocks",
seq: []byte("AAAACCCCGGGGTTTTAAAACCCCGGGGTTTT"),
k: 21,
m: 11,
},
{
name: "complex sequence",
seq: []byte("ATCGATCGATCGATCGATCGATCGATCGATCG"),
k: 15,
m: 7,
},
{
name: "longer sequence",
seq: []byte("ACGTACGTGGGGAAAAACGTACGTTTTTCCCCACGTACGT"),
k: 13,
m: 7,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Map to track sequence -> minimizer
seqToMinimizer := make(map[string]uint64)
for sk := range IterSuperKmers(tc.seq, tc.k, tc.m) {
seqStr := string(sk.Sequence)
// Check if we've seen this sequence before
if prevMinimizer, exists := seqToMinimizer[seqStr]; exists {
if prevMinimizer != sk.Minimizer {
t.Errorf("BIJECTION VIOLATION: sequence %s has two different minimizers:\n"+
" First: %d\n"+
" Second: %d\n"+
" This violates the super k-mer definition!",
seqStr, prevMinimizer, sk.Minimizer)
}
} else {
seqToMinimizer[seqStr] = sk.Minimizer
}
// Verify all k-mers in this super k-mer have the same minimizer
if len(sk.Sequence) >= tc.k {
for i := 0; i <= len(sk.Sequence)-tc.k; i++ {
kmerSeq := sk.Sequence[i : i+tc.k]
minimizer := findMinimizer(kmerSeq, tc.k, tc.m)
if minimizer != sk.Minimizer {
t.Errorf("K-mer at position %d in super k-mer has different minimizer:\n"+
" K-mer: %s\n"+
" Expected minimizer: %d\n"+
" Actual minimizer: %d\n"+
" Super k-mer: %s",
i, string(kmerSeq), sk.Minimizer, minimizer, seqStr)
}
}
}
}
})
}
}
// findMinimizer computes the minimizer of a k-mer for testing purposes
func findMinimizer(kmer []byte, k int, m int) uint64 {
if len(kmer) != k {
return 0
}
mMask := uint64(1)<<(m*2) - 1
rcShift := uint((m - 1) * 2)
minMinimizer := uint64(^uint64(0)) // max uint64
// Scan all m-mers in the k-mer
var fwdMmer, rvcMmer uint64
for i := 0; i < m-1 && i < len(kmer); i++ {
code := uint64(__single_base_code__[kmer[i]&31])
fwdMmer = (fwdMmer << 2) | code
rvcMmer = (rvcMmer >> 2) | ((code ^ 3) << rcShift)
}
for i := m - 1; i < len(kmer); i++ {
code := uint64(__single_base_code__[kmer[i]&31])
fwdMmer = ((fwdMmer << 2) | code) & mMask
rvcMmer = (rvcMmer >> 2) | ((code ^ 3) << rcShift)
canonical := fwdMmer
if rvcMmer < fwdMmer {
canonical = rvcMmer
}
if canonical < minMinimizer {
minMinimizer = canonical
}
}
return minMinimizer
}
// Note: Tests for ToBioSequence and SuperKmerWorker are in a separate
// integration test package to avoid circular dependencies between
// obikmer and obiseq packages.