mirror of
https://github.com/metabarcoding/obitools4.git
synced 2026-03-25 13:30:52 +00:00
Refactor kmer index package to use disk-based partitioning with minimizer - Replace roaring64 bitmaps with disk-based kmer index - Implement partitioned kmer sets with delta-varint encoding - Add support for frequency filtering during construction - Introduce new builder pattern for index construction - Add streaming operations for set operations (union, intersect, etc.) - Add support for super-kmer encoding during construction - Update command line tool to use new index format - Remove dependency on roaring bitmap library This change introduces a new architecture for kmer indexing that is more memory efficient and scalable for large datasets.
509 lines
16 KiB
Markdown
509 lines
16 KiB
Markdown
# Plan de refonte du package obikmer : index disk-based par partitions minimizer
|
||
|
||
## Constat
|
||
|
||
Les roaring64 bitmaps ne sont pas adaptés au stockage de 10^10 k-mers
|
||
(k=31) dispersés sur un espace de 2^62. L'overhead structurel (containers
|
||
roaring par high key 32 bits) dépasse la taille des données elles-mêmes,
|
||
et les opérations `Or()` entre bitmaps fragmentés ne terminent pas en
|
||
temps raisonnable.
|
||
|
||
## Principe de la nouvelle architecture
|
||
|
||
Un `KmerSet` est un ensemble trié de k-mers canoniques (uint64) stocké
|
||
sur disque, partitionné par minimizer. Chaque partition est un fichier
|
||
binaire contenant des uint64 triés, compressés par delta-varint.
|
||
|
||
Un `KmerSetGroup` est un répertoire contenant N ensembles partitionnés
|
||
de la même façon (même k, même m, même P).
|
||
|
||
Un `KmerSet` est un `KmerSetGroup` de taille 1 (singleton).
|
||
|
||
Les opérations ensemblistes se font partition par partition, en merge
|
||
streaming, sans charger l'index complet en mémoire.
|
||
|
||
## Cycle de vie d'un index
|
||
|
||
L'index a deux phases distinctes :
|
||
|
||
1. **Phase de construction (mutable)** : on ouvre un index, on y ajoute
|
||
des séquences. Pour chaque séquence, les super-kmers sont extraits
|
||
et écrits de manière compacte (2 bits/base) dans le fichier
|
||
temporaire de partition correspondant (`minimizer % P`). Les
|
||
super-kmers sont une représentation compressée naturelle des k-mers
|
||
chevauchants : un super-kmer de longueur L encode L-k+1 k-mers en
|
||
ne stockant que ~L/4 bytes au lieu de (L-k+1) × 8 bytes.
|
||
|
||
2. **Phase de clôture (optimisation)** : on ferme l'index, ce qui
|
||
déclenche le traitement **partition par partition** (indépendant,
|
||
parallélisable) :
|
||
- Charger les super-kmers de la partition
|
||
- En extraire tous les k-mers canoniques
|
||
- Trier le tableau de k-mers
|
||
- Dédupliquer (et compter si FrequencyFilter)
|
||
- Delta-encoder et écrire le fichier .kdi final
|
||
Après clôture, l'index est statique et immuable.
|
||
|
||
3. **Phase de lecture (immutable)** : opérations ensemblistes,
|
||
Jaccard, Quorum, Contains, itération. Toutes en streaming.
|
||
|
||
---
|
||
|
||
## Format sur disque
|
||
|
||
### Index finalisé
|
||
|
||
```
|
||
index_dir/
|
||
metadata.toml
|
||
set_0/
|
||
part_0000.kdi
|
||
part_0001.kdi
|
||
...
|
||
part_{P-1}.kdi
|
||
set_1/
|
||
part_0000.kdi
|
||
...
|
||
...
|
||
set_{N-1}/
|
||
...
|
||
```
|
||
|
||
### Fichiers temporaires pendant la construction
|
||
|
||
```
|
||
index_dir/
|
||
.build/
|
||
set_0/
|
||
part_0000.skm # super-kmers encodés 2 bits/base
|
||
part_0001.skm
|
||
...
|
||
set_1/
|
||
...
|
||
```
|
||
|
||
Le répertoire `.build/` est supprimé après Close().
|
||
|
||
### metadata.toml
|
||
|
||
```toml
|
||
id = "mon_index"
|
||
k = 31
|
||
m = 13
|
||
partitions = 1024
|
||
type = "KmerSetGroup" # ou "KmerSet" (N=1)
|
||
size = 3 # nombre de sets (N)
|
||
sets_ids = ["genome_A", "genome_B", "genome_C"]
|
||
|
||
[user_metadata]
|
||
organism = "Triticum aestivum"
|
||
|
||
[sets_metadata]
|
||
# métadonnées individuelles par set si nécessaire
|
||
```
|
||
|
||
### Fichier .kdi (Kmer Delta Index)
|
||
|
||
Format binaire :
|
||
|
||
```
|
||
[magic: 4 bytes "KDI\x01"]
|
||
[count: uint64 little-endian] # nombre de k-mers dans cette partition
|
||
[first: uint64 little-endian] # premier k-mer (valeur absolue)
|
||
[delta_1: varint] # arr[1] - arr[0]
|
||
[delta_2: varint] # arr[2] - arr[1]
|
||
...
|
||
[delta_{count-1}: varint] # arr[count-1] - arr[count-2]
|
||
```
|
||
|
||
Varint : encoding unsigned, 7 bits utiles par byte, bit de poids fort
|
||
= continuation (identique au varint protobuf).
|
||
|
||
Fichier vide (partition sans k-mer) : magic + count=0.
|
||
|
||
### Fichier .skm (Super-Kmer temporaire)
|
||
|
||
Format binaire, séquence de super-kmers encodés :
|
||
|
||
```
|
||
[len: uint16 little-endian] # longueur du super-kmer en bases
|
||
[sequence: ceil(len/4) bytes] # séquence encodée 2 bits/base, packed
|
||
...
|
||
```
|
||
|
||
**Compression par rapport au stockage de k-mers bruts** :
|
||
|
||
Un super-kmer de longueur L contient L-k+1 k-mers.
|
||
- Stockage super-kmer : 2 + ceil(L/4) bytes
|
||
- Stockage k-mers bruts : (L-k+1) × 8 bytes
|
||
|
||
Exemple avec k=31, super-kmer typique L=50 :
|
||
- Super-kmer : 2 + 13 = 15 bytes → encode 20 k-mers
|
||
- K-mers bruts : 20 × 8 = 160 bytes
|
||
- **Facteur de compression : ~10×**
|
||
|
||
Pour un génome de 10 Gbases (~10^10 k-mers bruts) :
|
||
- K-mers bruts : ~80 Go par set temporaire
|
||
- Super-kmers : **~8 Go** par set temporaire
|
||
|
||
Avec FrequencyFilter et couverture 30× :
|
||
- K-mers bruts : ~2.4 To
|
||
- Super-kmers : **~240 Go**
|
||
|
||
---
|
||
|
||
## FrequencyFilter
|
||
|
||
Le FrequencyFilter n'est plus un type de données séparé. C'est un
|
||
**mode de construction** du builder. Le résultat est un KmerSetGroup
|
||
standard.
|
||
|
||
### Principe
|
||
|
||
Pendant la construction, tous les super-kmers sont écrits dans les
|
||
fichiers temporaires .skm, y compris les doublons (chaque occurrence
|
||
de chaque séquence est écrite).
|
||
|
||
Pendant Close(), pour chaque partition :
|
||
1. Charger tous les super-kmers de la partition
|
||
2. Extraire tous les k-mers canoniques dans un tableau []uint64
|
||
3. Trier le tableau
|
||
4. Parcourir linéairement : les k-mers identiques sont consécutifs
|
||
5. Compter les occurrences de chaque k-mer
|
||
6. Si count >= minFreq → écrire dans le .kdi final (une seule fois)
|
||
7. Sinon → ignorer
|
||
|
||
### Dimensionnement
|
||
|
||
Pour un génome de 10 Gbases avec couverture 30× :
|
||
- N_brut ≈ 3×10^11 k-mers bruts
|
||
- Espace temporaire .skm ≈ 240 Go (compressé super-kmer)
|
||
- RAM par partition pendant Close() :
|
||
Avec P=1024 : ~3×10^8 k-mers/partition × 8 = **~2.4 Go**
|
||
Avec P=4096 : ~7.3×10^7 k-mers/partition × 8 = **~600 Mo**
|
||
|
||
Le choix de P détermine le compromis nombre de fichiers vs RAM par
|
||
partition.
|
||
|
||
### Sans FrequencyFilter (déduplication simple)
|
||
|
||
Pour de la déduplication simple (chaque k-mer écrit une fois), le
|
||
builder peut dédupliquer au niveau des buffers en RAM avant flush.
|
||
Cela réduit significativement l'espace temporaire car les doublons
|
||
au sein d'un même buffer (provenant de séquences proches) sont
|
||
éliminés immédiatement.
|
||
|
||
---
|
||
|
||
## API publique visée
|
||
|
||
### Structures
|
||
|
||
```go
|
||
// KmerSetGroup est l'entité de base.
|
||
// Un KmerSet est un KmerSetGroup avec Size() == 1.
|
||
type KmerSetGroup struct {
|
||
// champs internes : path, k, m, P, N, metadata, état
|
||
}
|
||
|
||
// KmerSetGroupBuilder construit un KmerSetGroup mutable.
|
||
type KmerSetGroupBuilder struct {
|
||
// champs internes : buffers I/O par partition et par set,
|
||
// fichiers temporaires .skm, paramètres (minFreq, etc.)
|
||
}
|
||
```
|
||
|
||
### Construction
|
||
|
||
```go
|
||
// NewKmerSetGroupBuilder crée un builder pour un nouveau KmerSetGroup.
|
||
// directory : répertoire de destination
|
||
// k : taille des k-mers (1-31)
|
||
// m : taille des minimizers (-1 pour auto = ceil(k/2.5))
|
||
// n : nombre de sets dans le groupe
|
||
// P : nombre de partitions (-1 pour auto)
|
||
// options : options de construction (FrequencyFilter, etc.)
|
||
func NewKmerSetGroupBuilder(directory string, k, m, n, P int,
|
||
options ...BuilderOption) (*KmerSetGroupBuilder, error)
|
||
|
||
// WithMinFrequency active le mode FrequencyFilter.
|
||
// Seuls les k-mers vus >= minFreq fois sont conservés dans l'index
|
||
// final. Les super-kmers sont écrits avec leurs doublons pendant
|
||
// la construction ; le comptage exact se fait au Close().
|
||
func WithMinFrequency(minFreq int) BuilderOption
|
||
|
||
// AddSequence extrait les super-kmers d'une séquence et les écrit
|
||
// dans les fichiers temporaires de partition du set i.
|
||
func (b *KmerSetGroupBuilder) AddSequence(setIndex int, seq *obiseq.BioSequence)
|
||
|
||
// AddSuperKmer écrit un super-kmer dans le fichier temporaire de
|
||
// sa partition pour le set i.
|
||
func (b *KmerSetGroupBuilder) AddSuperKmer(setIndex int, sk SuperKmer)
|
||
|
||
// Close finalise la construction :
|
||
// - flush des buffers d'écriture
|
||
// - pour chaque partition de chaque set (parallélisable) :
|
||
// - charger les super-kmers depuis le .skm
|
||
// - extraire les k-mers canoniques
|
||
// - trier, dédupliquer (compter si freq filter)
|
||
// - delta-encoder et écrire le .kdi
|
||
// - écrire metadata.toml
|
||
// - supprimer le répertoire .build/
|
||
// Retourne le KmerSetGroup en lecture seule.
|
||
func (b *KmerSetGroupBuilder) Close() (*KmerSetGroup, error)
|
||
```
|
||
|
||
### Lecture et opérations
|
||
|
||
```go
|
||
// OpenKmerSetGroup ouvre un index finalisé en lecture seule.
|
||
func OpenKmerSetGroup(directory string) (*KmerSetGroup, error)
|
||
|
||
// --- Métadonnées (API inchangée) ---
|
||
func (ksg *KmerSetGroup) K() int
|
||
func (ksg *KmerSetGroup) M() int // nouveau : taille du minimizer
|
||
func (ksg *KmerSetGroup) Partitions() int // nouveau : nombre de partitions
|
||
func (ksg *KmerSetGroup) Size() int
|
||
func (ksg *KmerSetGroup) Id() string
|
||
func (ksg *KmerSetGroup) SetId(id string)
|
||
func (ksg *KmerSetGroup) HasAttribute(key string) bool
|
||
func (ksg *KmerSetGroup) GetAttribute(key string) (interface{}, bool)
|
||
func (ksg *KmerSetGroup) SetAttribute(key string, value interface{})
|
||
// ... etc (toute l'API attributs actuelle est conservée)
|
||
|
||
// --- Opérations ensemblistes ---
|
||
// Toutes produisent un nouveau KmerSetGroup singleton sur disque.
|
||
// Opèrent partition par partition en streaming.
|
||
|
||
func (ksg *KmerSetGroup) Union(outputDir string) (*KmerSetGroup, error)
|
||
func (ksg *KmerSetGroup) Intersect(outputDir string) (*KmerSetGroup, error)
|
||
func (ksg *KmerSetGroup) Difference(outputDir string) (*KmerSetGroup, error)
|
||
func (ksg *KmerSetGroup) QuorumAtLeast(q int, outputDir string) (*KmerSetGroup, error)
|
||
func (ksg *KmerSetGroup) QuorumExactly(q int, outputDir string) (*KmerSetGroup, error)
|
||
func (ksg *KmerSetGroup) QuorumAtMost(q int, outputDir string) (*KmerSetGroup, error)
|
||
|
||
// --- Opérations entre deux KmerSetGroups ---
|
||
// Les deux groupes doivent avoir les mêmes k, m, P.
|
||
|
||
func (ksg *KmerSetGroup) UnionWith(other *KmerSetGroup, outputDir string) (*KmerSetGroup, error)
|
||
func (ksg *KmerSetGroup) IntersectWith(other *KmerSetGroup, outputDir string) (*KmerSetGroup, error)
|
||
|
||
// --- Métriques (résultat en mémoire, pas de sortie disque) ---
|
||
|
||
func (ksg *KmerSetGroup) JaccardDistanceMatrix() *obidist.DistMatrix
|
||
func (ksg *KmerSetGroup) JaccardSimilarityMatrix() *obidist.DistMatrix
|
||
|
||
// --- Accès individuel ---
|
||
|
||
func (ksg *KmerSetGroup) Len(setIndex ...int) uint64
|
||
func (ksg *KmerSetGroup) Contains(setIndex int, kmer uint64) bool
|
||
func (ksg *KmerSetGroup) Iterator(setIndex int) iter.Seq[uint64]
|
||
```
|
||
|
||
---
|
||
|
||
## Implémentation interne
|
||
|
||
### Primitives bas niveau
|
||
|
||
**`varint.go`** : encode/decode varint uint64
|
||
|
||
```go
|
||
func EncodeVarint(w io.Writer, v uint64) (int, error)
|
||
func DecodeVarint(r io.Reader) (uint64, error)
|
||
```
|
||
|
||
### Format .kdi
|
||
|
||
**`kdi_writer.go`** : écriture d'un fichier .kdi à partir d'un flux
|
||
trié de uint64 (delta-encode au vol).
|
||
|
||
```go
|
||
type KdiWriter struct { ... }
|
||
func NewKdiWriter(path string) (*KdiWriter, error)
|
||
func (w *KdiWriter) Write(kmer uint64) error
|
||
func (w *KdiWriter) Close() error
|
||
```
|
||
|
||
**`kdi_reader.go`** : lecture streaming d'un fichier .kdi (décode
|
||
les deltas au vol).
|
||
|
||
```go
|
||
type KdiReader struct { ... }
|
||
func NewKdiReader(path string) (*KdiReader, error)
|
||
func (r *KdiReader) Next() (uint64, bool)
|
||
func (r *KdiReader) Count() uint64
|
||
func (r *KdiReader) Close() error
|
||
```
|
||
|
||
### Format .skm
|
||
|
||
**`skm_writer.go`** : écriture de super-kmers encodés 2 bits/base.
|
||
|
||
```go
|
||
type SkmWriter struct { ... }
|
||
func NewSkmWriter(path string) (*SkmWriter, error)
|
||
func (w *SkmWriter) Write(sk SuperKmer) error
|
||
func (w *SkmWriter) Close() error
|
||
```
|
||
|
||
**`skm_reader.go`** : lecture de super-kmers depuis un fichier .skm.
|
||
|
||
```go
|
||
type SkmReader struct { ... }
|
||
func NewSkmReader(path string) (*SkmReader, error)
|
||
func (r *SkmReader) Next() (SuperKmer, bool)
|
||
func (r *SkmReader) Close() error
|
||
```
|
||
|
||
### Merge streaming
|
||
|
||
**`kdi_merge.go`** : k-way merge de plusieurs flux triés.
|
||
|
||
```go
|
||
type KWayMerge struct { ... }
|
||
func NewKWayMerge(readers []*KdiReader) *KWayMerge
|
||
func (m *KWayMerge) Next() (kmer uint64, count int, ok bool)
|
||
func (m *KWayMerge) Close() error
|
||
```
|
||
|
||
### Builder
|
||
|
||
**`kmer_set_builder.go`** : construction d'un KmerSetGroup.
|
||
|
||
Le builder gère :
|
||
- P × N écrivains .skm bufferisés (un par partition × set)
|
||
- À la clôture : traitement partition par partition
|
||
(parallélisable sur plusieurs cores)
|
||
|
||
Gestion mémoire des buffers d'écriture :
|
||
- Chaque SkmWriter a un buffer I/O de taille raisonnable (~64 Ko)
|
||
- Avec P=1024 et N=1 : 1024 × 64 Ko = 64 Mo de buffers
|
||
- Avec P=1024 et N=10 : 640 Mo de buffers
|
||
- Pas de buffer de k-mers en RAM : tout est écrit sur disque
|
||
immédiatement via les super-kmers
|
||
|
||
RAM pendant Close() (tri d'une partition) :
|
||
- Charger les super-kmers → extraire les k-mers → tableau []uint64
|
||
- Avec P=1024 et 10^10 k-mers/set : ~10^7 k-mers/partition × 8 = ~80 Mo
|
||
- Avec FrequencyFilter (doublons) et couverture 30× :
|
||
~3×10^8/partition × 8 = ~2.4 Go (ajustable via P)
|
||
|
||
### Structure disk-based
|
||
|
||
**`kmer_set_disk.go`** : KmerSetGroup en lecture seule.
|
||
|
||
**`kmer_set_disk_ops.go`** : opérations ensemblistes par merge
|
||
streaming partition par partition.
|
||
|
||
---
|
||
|
||
## Ce qui change par rapport à l'API actuelle
|
||
|
||
### Changements de sémantique
|
||
|
||
| Aspect | Ancien (roaring) | Nouveau (disk-based) |
|
||
|---|---|---|
|
||
| Stockage | En mémoire (roaring64.Bitmap) | Sur disque (.kdi delta-encoded) |
|
||
| Temporaire construction | En mémoire | Super-kmers sur disque (.skm 2 bits/base) |
|
||
| Mutabilité | Mutable à tout moment | Builder → Close() → immutable |
|
||
| Opérations ensemblistes | Résultat en mémoire | Résultat sur disque (nouveau répertoire) |
|
||
| Contains | O(1) roaring lookup | O(log n) recherche binaire sur .kdi |
|
||
| Itération | Roaring iterator | Streaming décodage delta-varint |
|
||
|
||
### API conservée (signatures identiques ou quasi-identiques)
|
||
|
||
- `KmerSetGroup` : `K()`, `Size()`, `Id()`, `SetId()`
|
||
- Toute l'API attributs
|
||
- `JaccardDistanceMatrix()`, `JaccardSimilarityMatrix()`
|
||
- `Len()`, `Contains()`
|
||
|
||
### API modifiée
|
||
|
||
- `Union()`, `Intersect()`, etc. : ajout du paramètre `outputDir`
|
||
- `QuorumAtLeast()`, etc. : idem
|
||
- Construction : `NewKmerSetGroupBuilder()` + `AddSequence()` + `Close()`
|
||
au lieu de manipulation directe
|
||
|
||
### API supprimée
|
||
|
||
- `KmerSet` comme type distinct (remplacé par KmerSetGroup singleton)
|
||
- `FrequencyFilter` comme type distinct (mode du Builder)
|
||
- Tout accès direct à `roaring64.Bitmap`
|
||
- `KmerSet.Copy()` (copie de répertoire à la place)
|
||
- `KmerSet.Union()`, `.Intersect()`, `.Difference()` (deviennent méthodes
|
||
de KmerSetGroup avec outputDir)
|
||
|
||
---
|
||
|
||
## Fichiers à créer / modifier dans pkg/obikmer
|
||
|
||
### Nouveaux fichiers
|
||
|
||
| Fichier | Contenu |
|
||
|---|---|
|
||
| `varint.go` | Encode/Decode varint uint64 |
|
||
| `kdi_writer.go` | Écrivain de fichiers .kdi (delta-encoded) |
|
||
| `kdi_reader.go` | Lecteur streaming de fichiers .kdi |
|
||
| `skm_writer.go` | Écrivain de super-kmers encodés 2 bits/base |
|
||
| `skm_reader.go` | Lecteur de super-kmers depuis .skm |
|
||
| `kdi_merge.go` | K-way merge streaming de flux triés |
|
||
| `kmer_set_builder.go` | KmerSetGroupBuilder (construction) |
|
||
| `kmer_set_disk.go` | KmerSetGroup disk-based (lecture, métadonnées) |
|
||
| `kmer_set_disk_ops.go` | Opérations ensemblistes streaming |
|
||
|
||
### Fichiers à supprimer
|
||
|
||
| Fichier | Raison |
|
||
|---|---|
|
||
| `kmer_set.go` | Remplacé par kmer_set_disk.go |
|
||
| `kmer_set_group.go` | Idem |
|
||
| `kmer_set_attributes.go` | Intégré dans kmer_set_disk.go |
|
||
| `kmer_set_persistence.go` | L'index est nativement sur disque |
|
||
| `kmer_set_group_quorum.go` | Intégré dans kmer_set_disk_ops.go |
|
||
| `frequency_filter.go` | Mode du Builder, plus de type séparé |
|
||
| `kmer_index_builder.go` | Remplacé par kmer_set_builder.go |
|
||
|
||
### Fichiers conservés tels quels
|
||
|
||
| Fichier | Contenu |
|
||
|---|---|
|
||
| `encodekmer.go` | Encodage/décodage k-mers |
|
||
| `superkmer.go` | Structure SuperKmer |
|
||
| `superkmer_iter.go` | IterSuperKmers, IterCanonicalKmers |
|
||
| `encodefourmer.go` | Encode4mer |
|
||
| `counting.go` | Count4Mer |
|
||
| `kmermap.go` | KmerMap (usage indépendant) |
|
||
| `debruijn.go` | Graphe de de Bruijn |
|
||
|
||
---
|
||
|
||
## Ordre d'implémentation
|
||
|
||
1. `varint.go` + tests
|
||
2. `skm_writer.go` + `skm_reader.go` + tests
|
||
3. `kdi_writer.go` + `kdi_reader.go` + tests
|
||
4. `kdi_merge.go` + tests
|
||
5. `kmer_set_builder.go` + tests (construction + Close)
|
||
6. `kmer_set_disk.go` (structure, métadonnées, Open)
|
||
7. `kmer_set_disk_ops.go` + tests (Union, Intersect, Quorum, Jaccard)
|
||
8. Adaptation de `pkg/obitools/obikindex/`
|
||
9. Suppression des anciens fichiers roaring
|
||
10. Adaptation des tests existants
|
||
|
||
Chaque étape est testable indépendamment.
|
||
|
||
---
|
||
|
||
## Dépendances externes
|
||
|
||
### Supprimées
|
||
|
||
- `github.com/RoaringBitmap/roaring` : plus nécessaire pour les
|
||
index k-mers (vérifier si d'autres packages l'utilisent encore)
|
||
|
||
### Ajoutées
|
||
|
||
- Aucune. Varint, delta-encoding, merge, encodage 2 bits/base :
|
||
tout est implémentable en Go standard.
|