feat: add convert command for in-place index evidence modification

Introduce a new CLI command that modifies existing indices to support exact, approximate, or hybrid evidence modes. This change enables the reindex module in obikrebuild, exposing an IndexReindex trait for evidence persistence and layer processing. It also refactors obikindexer to publicly expose an IndexBuilder trait for post-build evidence construction, while adding a set_evidence API in obikindex to safely update configuration fields without altering core parameters.
This commit is contained in:
Eric Coissac
2026-08-28 19:43:14 +02:00
parent bd7729b095
commit 4ea3cd32ba
10 changed files with 562 additions and 340 deletions
+22 -23
View File
@@ -218,29 +218,28 @@ impl IndexMeta {
self.write_full(&on_disk) self.write_full(&on_disk)
} }
// /// Overwrite `config` and the whole `genomes` list at once, preserving /// Update the evidence representation in place — `evidence`, and (for
// /// `state`. `config` otherwise never changes once an index exists — /// `Exact`) its block size. The only `IndexConfig` fields a reindex is
// /// this is the deliberate, rare exception for construction paths that /// ever allowed to touch: `kmer_size`/`minimizer_size`/`n_bits`/
// /// legitimately rewrite it in place (`select_in_place`, `reindex`). /// `with_counts` are baked into the already-built MPHF/unitigs/matrices
// /// The caller must refresh its own cached `Arc<IndexMeta>` afterward /// and can never change post-build, unlike the evidence bundle, which
// /// (e.g. `self.meta = Arc::new(IndexMeta::open(self)?)`) — this method /// is derived from `unitigs.bin` + `mphf.bin` alone and can be
// /// only updates the file, it has no way to reach back into whatever /// rebuilt in a different form at any time (see
// /// `KmerIndex` holds it. /// `obikindexer::IndexBuilder`).
// /// TODO: This methode is strange ///
// pub(crate) fn rewrite_config( /// The caller must refresh its own cached `Arc<IndexMeta>` afterward
// &self, /// (e.g. `self.meta = Arc::new(IndexMeta::open(self)?)`) — this method
// config: IndexConfig, /// only updates the file, it has no way to reach back into whatever
// genomes: Vec<GenomeInfo>, /// `KmerIndex` holds it.
// ) -> io::Result<()> { pub fn set_evidence(&self, evidence: IndexMode, block_bits: u8) -> io::Result<()> {
// let _guard = self.lock.write().unwrap(); let _guard = self.lock.write().unwrap();
// let state = Self::read_full(&self.root_path)?.state; let mut on_disk = Self::read_full(&self.root_path)?;
// self.write_full(&IndexMetadata { on_disk.config.evidence = evidence;
// version: self.version, if matches!(on_disk.config.evidence, IndexMode::Exact) {
// config, on_disk.config.block_bits = block_bits;
// genomes, }
// state, self.write_full(&on_disk)
// }) }
// }
pub fn set_state(&self, state: IndexState) -> io::Result<()> { pub fn set_state(&self, state: IndexState) -> io::Result<()> {
let _guard = self.lock.write().unwrap(); let _guard = self.lock.write().unwrap();
@@ -0,0 +1,68 @@
//! `IndexBuilder` — public, idempotent evidence-construction primitives
//! for an already-built `KmerLayer` (`unitigs.bin` + `mphf.bin` already on
//! disk; only the evidence bundle — `evidence.bin`/`unitigs.bin.idx` and/or
//! `fingerprint.bin` — is touched). Unlike `PrivateBuilder` (this module's
//! private sibling), these are meant for callers outside this crate that
//! need to add or convert a layer's evidence after the fact — today,
//! `obikrebuild::reindex`.
//!
//! `KmerLayer` itself (`obikindex`) stays a pure data-model type: it
//! exposes paths and reads content, it never builds anything. Construction
//! lives here instead, in the crate whose whole purpose is building index
//! content — same reasoning as `PrivateBuilder`, just public.
use obikindex::OKIResult;
use obikindex::layer::fingerprint::FingerprintVec;
use obikindex::layer::{KmerLayer, MphfLayer};
pub trait IndexBuilder {
/// Build this layer's exact evidence bundle (`evidence.bin` +
/// `unitigs.bin.idx`) if not already present.
///
/// No-op (`Ok(0)`) if exact evidence already exists — both `Exact` and
/// `Hybrid` layers already carry it.
fn build_exact_evidence(&self, block_bits: u8) -> OKIResult<usize>;
/// Build this layer's approximate (fingerprint) evidence with `b` bits
/// per slot and Findere parameter `z`, if not already present with a
/// matching `b`.
///
/// No-op (`Ok(0)`) if `Approx`/`Hybrid` evidence with the same `b`
/// already exists — rebuilds it (same file, same `b`, whatever `z`)
/// otherwise. `z` is never persisted (see `IndexMode::Approx`'s own
/// doc): it only governs how many consecutive stored k-mers a query
/// must match, so changing it alone is not something this method has
/// any file-level evidence to detect — the caller decides.
fn build_approx_evidence(&self, b: u8, z: u8) -> OKIResult<usize>;
/// Build whichever of the exact/approximate bundles this layer is
/// still missing, so it ends up carrying both (`Hybrid`).
///
/// Just the composition of `build_exact_evidence` and
/// `build_approx_evidence` — each is already independently idempotent,
/// so there is no extra "what's already there" decision to make here.
fn build_hybrid_evidence(&self, b: u8, z: u8, block_bits: u8) -> OKIResult<usize>;
}
impl IndexBuilder for KmerLayer {
fn build_exact_evidence(&self, block_bits: u8) -> OKIResult<usize> {
if self.evidence_path().exists() {
return Ok(0);
}
MphfLayer::build_exact_evidence(self.dir(), block_bits)
}
fn build_approx_evidence(&self, b: u8, z: u8) -> OKIResult<usize> {
let fingerprint_path = self.fingerprint_path();
if fingerprint_path.exists() && FingerprintVec::open(&fingerprint_path)?.b() == b {
return Ok(0);
}
MphfLayer::build_approx_evidence(self.dir(), b, z)
}
fn build_hybrid_evidence(&self, b: u8, z: u8, block_bits: u8) -> OKIResult<usize> {
let n_exact = self.build_exact_evidence(block_bits)?;
let n_approx = self.build_approx_evidence(b, z)?;
Ok(n_exact.max(n_approx))
}
}
+17 -242
View File
@@ -1,245 +1,20 @@
//! Construction-only `KmerIndex` operations — semantically meaningless //! Extension traits over `obikindex`'s foreign types (`KmerIndex`,
//! once an index is built and being used for reading (query, dump, phylo, //! `KmerLayer`) — Rust's orphan rule requires such traits to be defined in
//! or as a `merge`/`select`/`rebuild` *source*): sentinel marking, the //! the crate that implements them for a foreign type, not in `obikindex`
//! per-partition layer-build primitive, and cleanup of build-time scratch //! itself; `obikindex` has no way to make a trait "visible to only some
//! files. Private to this crate — no consumer outside the //! crates" from its own side.
//! indexing-pipeline algorithms in `obikindexer::algorithms` should ever
//! need them. See `DevDocMD/implementation/partition_layer_cache.md`,
//! "(8)".
//! //!
//! Defined here, not in `obikindex`, so it can be genuinely `pub(crate)` //! Two traits, two visibilities, one per submodule:
//! while extending a foreign type (`KmerIndex`) — Rust's orphan rule //! - [`private_builder::PrivateBuilder`] (`pub(crate)`) — construction-only
//! requires the trait itself to be local to the crate that implements it //! `KmerIndex` operations exclusive to this crate's own indexing
//! for a foreign type; `obikindex` has no way to make a trait "private //! pipeline (`algorithms::{partitionner,dereplicator,counter,layer_builder}`).
//! except to `obikindexer`" from its own side. //! - [`index_builder::IndexBuilder`] (`pub`, re-exported at the crate
//! //! root) — idempotent per-layer evidence-construction primitives, for
//! Scoped to the six methods exclusive to this crate's own pipeline //! any external caller that needs to add/convert a layer's evidence
//! (`partitionner`/`dereplicator`/`counter`/`layer_builder`). //! bundle after the fact (`obikrebuild::reindex`).
//! `clear_output_for_create`/`create_skeleton`/`finalize_indexed`/`state`
//! stay inherent on `KmerIndex`, deliberately not moved here: they're also
//! called from `obikindex`'s own `merge`/`select`/`rebuild`/`reindex`
//! modules, which — living inside `obikindex` itself — could never reach a
//! trait defined in `obikindexer` (the dependency only runs
//! `obikindexer → obikindex`, never the other way). Moving those four
//! would require extracting `merge`/`select`/`rebuild`/`reindex` into
//! algorithms first — separate, larger future work, not this one.
use std::collections::BTreeMap; mod index_builder;
use std::fs; mod private_builder;
use std::io;
use std::path::Path;
use cacheline_ef::{CachelineEf, CachelineEfVec}; pub use index_builder::IndexBuilder;
use epserde::prelude::*; pub(crate) use private_builder::PrivateBuilder;
use obicompactvec::{PersistentCompactIntMatrix, PersistentCompactIntVec};
use obidebruinj::GraphDeBruijn;
use obikindex::layer::IndexMode;
use obikindex::layer::{KmerLayer, TypedLayer};
use obikindex::{KmerIndex, OKIError, OKIResult};
use crate::graph_pipeline::{materialize_layer, write_graph_as_unitigs};
use obiskio::{SKError, SKFileMeta, SKFileReader};
use ptr_hash::{PtrHash, bucket_fn::CubicEps, hash::Xx64};
type Mphf = PtrHash<u64, CubicEps, CachelineEfVec<Vec<CachelineEf>>, Xx64, Vec<u8>>;
pub(crate) trait PrivateBuilder {
/// Mark scatter as complete (`IndexState::Scattered`).
///
/// If no genome label was set at creation time, one is derived from
/// the index root directory name (stripped of all extensions). `&self`
/// — not `&mut self` anymore: nothing is cached in memory to mutate,
/// see `DevDocMD/implementation/partition_layer_cache.md`, "(11)".
fn mark_scattered(&self) -> OKIResult<()>;
/// Mark dereplicate+count as complete (`IndexState::Counted`).
fn mark_counted(&self) -> OKIResult<()>;
/// Partition `i`'s layer 0 — the one and only layer every pipeline
/// algorithm in this crate (`partitionner`/`dereplicator`/`counter`/
/// `layer_builder`) reads from or writes to; later layers only exist
/// after a `merge`, which lives in `obikindex`, not here.
fn layer0(&self, i: usize) -> OKIResult<KmerLayer>;
/// Mark layer construction as complete (`IndexState::Indexed`).
fn mark_indexed(&self) -> OKIResult<()>;
/// Write `spectrums/{label}.json` from an already-computed kmer
/// spectrum (`f0`/`f1`/abundance histogram). Takes plain values rather
/// than `algorithms::counter::KmerSpectrum` — `KmerIndex` is the data
/// model, `Counter` the algorithm that depends on it, not the other
/// way around, and this is the only field of that type it actually
/// uses.
fn write_spectrum(&self, f0: u64, f1: u64, counts: &BTreeMap<u32, u64>) -> OKIResult<()>;
/// Build the layered MPHF index for partition `i`.
///
/// Returns the number of canonical k-mers indexed, or 0 if the
/// partition has no data or its layer was already built
/// (resume-safe).
///
/// Abundance filtering is applied when `min_ab > 1` or
/// `max_ab.is_some()`, using `mphf1.bin` + `counts1.bin` if they
/// exist. Count payload is stored iff `with_counts` is true.
fn build_index_layer(
&self,
i: usize,
min_ab: u32,
max_ab: Option<u32>,
with_counts: bool,
mode: &IndexMode,
block_bits: u8,
) -> Result<usize, SKError>;
/// Remove intermediate build artifacts for partition `i`: dereplicated
/// superkmers (+ sidecar), `mphf1.bin`, `counts1.bin`.
fn remove_build_artifacts(&self, i: usize);
}
impl PrivateBuilder for KmerIndex {
fn mark_scattered(&self) -> OKIResult<()> {
self.meta().mark_scattered().map_err(OKIError::Io)
}
fn mark_counted(&self) -> OKIResult<()> {
self.meta().mark_counted().map_err(OKIError::Io)
}
fn layer0(&self, i: usize) -> OKIResult<KmerLayer> {
self.partition(i)?.layer(0)
}
fn mark_indexed(&self) -> OKIResult<()> {
self.meta().mark_indexed().map_err(OKIError::Io)
}
fn write_spectrum(&self, f0: u64, f1: u64, counts: &BTreeMap<u32, u64>) -> OKIResult<()> {
let genomes = self.meta().genomes().map_err(OKIError::Io)?;
let label = genomes
.first()
.map(|g| g.label.as_str())
.unwrap_or("unknown")
.to_owned();
let spectrums_dir = self.dir().join("spectrums");
fs::create_dir_all(&spectrums_dir)?;
let path = spectrums_dir.join(format!("{label}.json"));
let spectrum_map: BTreeMap<String, u64> = counts
.iter()
.map(|(&c, &f)| (format!("{c:010}"), f))
.collect();
let f = fs::File::create(&path)?;
serde_json::to_writer_pretty(
f,
&serde_json::json!({ "f0": f0, "f1": f1, "spectrum": spectrum_map }),
)
.map_err(OKIError::Json)?;
Ok(())
}
fn build_index_layer(
&self,
i: usize,
min_ab: u32,
max_ab: Option<u32>,
with_counts: bool,
mode: &IndexMode,
block_bits: u8,
) -> Result<usize, SKError> {
let layer0 = self.layer0(i).map_err(|e| io::Error::other(e.to_string()))?;
let layer0_dir = layer0.dir();
let dedup_path = layer0.dereplicated_superkmers_path();
if !dedup_path.exists() {
return Ok(0);
}
if layer0_dir.join("mphf.bin").exists() {
return Ok(0);
}
let filter_active = min_ab > 1 || max_ab.is_some();
let need_counts = filter_active || with_counts;
let mphf1_opt: Option<Mphf> = if need_counts {
let p = layer0_dir.join("mphf1.bin");
p.exists().then(|| Mphf::load_full(&p).ok()).flatten()
} else {
None
};
let counts1_opt: Option<PersistentCompactIntVec> = if need_counts {
let p = layer0_dir.join("counts1.bin");
p.exists()
.then(|| PersistentCompactIntVec::open(&p).ok())
.flatten()
} else {
None
};
let mut g = GraphDeBruijn::new();
let mut reader = SKFileReader::open(&dedup_path)?;
for sk in reader.iter() {
for kmer in sk.iter_canonical_kmers() {
let accept = if filter_active {
match (&mphf1_opt, &counts1_opt) {
(Some(mphf), Some(counts)) => {
let ab = counts.get(mphf.index(&kmer.raw()));
ab >= min_ab && max_ab.map_or(true, |max| ab <= max)
}
_ => true,
}
} else {
true
};
if accept {
g.push(kmer);
}
}
}
let n_kmers = if with_counts {
let n = write_graph_as_unitigs(g, layer0_dir)
.map_err(|e| io::Error::other(e.to_string()))?;
TypedLayer::<PersistentCompactIntMatrix>::build(
layer0_dir,
block_bits,
mode,
|kmer| match (&mphf1_opt, &counts1_opt) {
(Some(mphf), Some(counts)) => counts.get(mphf.index(&kmer.raw())),
_ => 1,
},
)
.map_err(|e| io::Error::other(e.to_string()))?;
n
} else {
materialize_layer(g, layer0_dir, block_bits, mode)
.map_err(|e| io::Error::other(e.to_string()))?
};
Ok(n_kmers)
}
fn remove_build_artifacts(&self, i: usize) {
let layer0 = match self.layer0(i) {
Ok(layer) => layer,
Err(e) => {
eprintln!("warning: could not locate layer 0 of partition {i}: {e}");
return;
}
};
let layer0_dir = layer0.dir();
let dedup = layer0.dereplicated_superkmers_path();
remove_if_exists(&SKFileMeta::sidecar_path(&dedup));
remove_if_exists(&dedup);
remove_if_exists(&layer0_dir.join("mphf1.bin"));
remove_if_exists(&layer0_dir.join("counts1.bin"));
}
}
// ── private helpers ──────────────────────────────────────────────────────
fn remove_if_exists(path: &Path) {
if let Err(e) = fs::remove_file(path) {
if e.kind() != io::ErrorKind::NotFound {
eprintln!("warning: could not remove {}: {e}", path.display());
}
}
}
@@ -0,0 +1,240 @@
//! `PrivateBuilder` — construction-only `KmerIndex` operations:
//! sentinel marking, the per-partition layer-build primitive, and cleanup
//! of build-time scratch files. Semantically meaningless once an index is
//! built and being used for reading (query, dump, phylo, or as a
//! `merge`/`select`/`rebuild` *source*).
//!
//! Scoped to the six methods exclusive to this crate's own pipeline
//! (`partitionner`/`dereplicator`/`counter`/`layer_builder`) — no
//! consumer outside those algorithms should ever need them, hence
//! `pub(crate)` (see the module-level orphan-rule note in `extensions`'s
//! `mod.rs`). See `DevDocMD/implementation/partition_layer_cache.md`, "(8)".
//!
//! `clear_output_for_create`/`create_skeleton`/`finalize_indexed`/`state`
//! stay inherent on `KmerIndex`, deliberately not moved here: they're also
//! called from `obikindex`'s own `merge`/`select`/`rebuild`/`reindex`
//! modules, which — living inside `obikindex` itself — could never reach a
//! trait defined in `obikindexer` (the dependency only runs
//! `obikindexer → obikindex`, never the other way). Moving those four
//! would require extracting `merge`/`select`/`rebuild`/`reindex` into
//! algorithms first — separate, larger future work, not this one.
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;
use cacheline_ef::{CachelineEf, CachelineEfVec};
use epserde::prelude::*;
use obicompactvec::{PersistentCompactIntMatrix, PersistentCompactIntVec};
use obidebruinj::GraphDeBruijn;
use obikindex::layer::IndexMode;
use obikindex::layer::{KmerLayer, TypedLayer};
use obikindex::{KmerIndex, OKIError, OKIResult};
use crate::graph_pipeline::{materialize_layer, write_graph_as_unitigs};
use obiskio::{SKError, SKFileMeta, SKFileReader};
use ptr_hash::{PtrHash, bucket_fn::CubicEps, hash::Xx64};
type Mphf = PtrHash<u64, CubicEps, CachelineEfVec<Vec<CachelineEf>>, Xx64, Vec<u8>>;
pub(crate) trait PrivateBuilder {
/// Mark scatter as complete (`IndexState::Scattered`).
///
/// If no genome label was set at creation time, one is derived from
/// the index root directory name (stripped of all extensions). `&self`
/// — not `&mut self` anymore: nothing is cached in memory to mutate,
/// see `DevDocMD/implementation/partition_layer_cache.md`, "(11)".
fn mark_scattered(&self) -> OKIResult<()>;
/// Mark dereplicate+count as complete (`IndexState::Counted`).
fn mark_counted(&self) -> OKIResult<()>;
/// Partition `i`'s layer 0 — the one and only layer every pipeline
/// algorithm in this crate (`partitionner`/`dereplicator`/`counter`/
/// `layer_builder`) reads from or writes to; later layers only exist
/// after a `merge`, which lives in `obikindex`, not here.
fn layer0(&self, i: usize) -> OKIResult<KmerLayer>;
/// Mark layer construction as complete (`IndexState::Indexed`).
fn mark_indexed(&self) -> OKIResult<()>;
/// Write `spectrums/{label}.json` from an already-computed kmer
/// spectrum (`f0`/`f1`/abundance histogram). Takes plain values rather
/// than `algorithms::counter::KmerSpectrum` — `KmerIndex` is the data
/// model, `Counter` the algorithm that depends on it, not the other
/// way around, and this is the only field of that type it actually
/// uses.
fn write_spectrum(&self, f0: u64, f1: u64, counts: &BTreeMap<u32, u64>) -> OKIResult<()>;
/// Build the layered MPHF index for partition `i`.
///
/// Returns the number of canonical k-mers indexed, or 0 if the
/// partition has no data or its layer was already built
/// (resume-safe).
///
/// Abundance filtering is applied when `min_ab > 1` or
/// `max_ab.is_some()`, using `mphf1.bin` + `counts1.bin` if they
/// exist. Count payload is stored iff `with_counts` is true.
fn build_index_layer(
&self,
i: usize,
min_ab: u32,
max_ab: Option<u32>,
with_counts: bool,
mode: &IndexMode,
block_bits: u8,
) -> Result<usize, SKError>;
/// Remove intermediate build artifacts for partition `i`: dereplicated
/// superkmers (+ sidecar), `mphf1.bin`, `counts1.bin`.
fn remove_build_artifacts(&self, i: usize);
}
impl PrivateBuilder for KmerIndex {
fn mark_scattered(&self) -> OKIResult<()> {
self.meta().mark_scattered().map_err(OKIError::Io)
}
fn mark_counted(&self) -> OKIResult<()> {
self.meta().mark_counted().map_err(OKIError::Io)
}
fn layer0(&self, i: usize) -> OKIResult<KmerLayer> {
self.partition(i)?.layer(0)
}
fn mark_indexed(&self) -> OKIResult<()> {
self.meta().mark_indexed().map_err(OKIError::Io)
}
fn write_spectrum(&self, f0: u64, f1: u64, counts: &BTreeMap<u32, u64>) -> OKIResult<()> {
let genomes = self.meta().genomes().map_err(OKIError::Io)?;
let label = genomes
.first()
.map(|g| g.label.as_str())
.unwrap_or("unknown")
.to_owned();
let spectrums_dir = self.dir().join("spectrums");
fs::create_dir_all(&spectrums_dir)?;
let path = spectrums_dir.join(format!("{label}.json"));
let spectrum_map: BTreeMap<String, u64> = counts
.iter()
.map(|(&c, &f)| (format!("{c:010}"), f))
.collect();
let f = fs::File::create(&path)?;
serde_json::to_writer_pretty(
f,
&serde_json::json!({ "f0": f0, "f1": f1, "spectrum": spectrum_map }),
)
.map_err(OKIError::Json)?;
Ok(())
}
fn build_index_layer(
&self,
i: usize,
min_ab: u32,
max_ab: Option<u32>,
with_counts: bool,
mode: &IndexMode,
block_bits: u8,
) -> Result<usize, SKError> {
let layer0 = self.layer0(i).map_err(|e| io::Error::other(e.to_string()))?;
let layer0_dir = layer0.dir();
let dedup_path = layer0.dereplicated_superkmers_path();
if !dedup_path.exists() {
return Ok(0);
}
if layer0_dir.join("mphf.bin").exists() {
return Ok(0);
}
let filter_active = min_ab > 1 || max_ab.is_some();
let need_counts = filter_active || with_counts;
let mphf1_opt: Option<Mphf> = if need_counts {
let p = layer0_dir.join("mphf1.bin");
p.exists().then(|| Mphf::load_full(&p).ok()).flatten()
} else {
None
};
let counts1_opt: Option<PersistentCompactIntVec> = if need_counts {
let p = layer0_dir.join("counts1.bin");
p.exists()
.then(|| PersistentCompactIntVec::open(&p).ok())
.flatten()
} else {
None
};
let mut g = GraphDeBruijn::new();
let mut reader = SKFileReader::open(&dedup_path)?;
for sk in reader.iter() {
for kmer in sk.iter_canonical_kmers() {
let accept = if filter_active {
match (&mphf1_opt, &counts1_opt) {
(Some(mphf), Some(counts)) => {
let ab = counts.get(mphf.index(&kmer.raw()));
ab >= min_ab && max_ab.map_or(true, |max| ab <= max)
}
_ => true,
}
} else {
true
};
if accept {
g.push(kmer);
}
}
}
let n_kmers = if with_counts {
let n = write_graph_as_unitigs(g, layer0_dir)
.map_err(|e| io::Error::other(e.to_string()))?;
TypedLayer::<PersistentCompactIntMatrix>::build(
layer0_dir,
block_bits,
mode,
|kmer| match (&mphf1_opt, &counts1_opt) {
(Some(mphf), Some(counts)) => counts.get(mphf.index(&kmer.raw())),
_ => 1,
},
)
.map_err(|e| io::Error::other(e.to_string()))?;
n
} else {
materialize_layer(g, layer0_dir, block_bits, mode)
.map_err(|e| io::Error::other(e.to_string()))?
};
Ok(n_kmers)
}
fn remove_build_artifacts(&self, i: usize) {
let layer0 = match self.layer0(i) {
Ok(layer) => layer,
Err(e) => {
eprintln!("warning: could not locate layer 0 of partition {i}: {e}");
return;
}
};
let layer0_dir = layer0.dir();
let dedup = layer0.dereplicated_superkmers_path();
remove_if_exists(&SKFileMeta::sidecar_path(&dedup));
remove_if_exists(&dedup);
remove_if_exists(&layer0_dir.join("mphf1.bin"));
remove_if_exists(&layer0_dir.join("counts1.bin"));
}
}
// ── private helpers ──────────────────────────────────────────────────────
fn remove_if_exists(path: &Path) {
if let Err(e) = fs::remove_file(path) {
if e.kind() != io::ErrorKind::NotFound {
eprintln!("warning: could not remove {}: {e}", path.display());
}
}
}
+6 -3
View File
@@ -9,11 +9,14 @@
//! (routing raw super-kmers into partitions), `dereplicator` (deduplicating //! (routing raw super-kmers into partitions), `dereplicator` (deduplicating
//! a partition's raw super-kmers), `counter` (counting unique canonical //! a partition's raw super-kmers), `counter` (counting unique canonical
//! kmers), `layer_builder` (turning dereplicated super-kmers + counts into //! kmers), `layer_builder` (turning dereplicated super-kmers + counts into
//! the real layer 0). [`extensions`] (private — see its own module docs) //! the real layer 0). `extensions` (module itself private — see its own
//! holds construction-only `KmerIndex` operations these algorithms share. //! docs) holds `KmerIndex`/`KmerLayer` extension traits: some exclusive to
//! this crate's own pipeline, one ([`IndexBuilder`]) public and re-exported
//! here for external callers.
pub mod algorithms; pub mod algorithms;
pub(crate) mod extensions; mod extensions;
pub mod graph_pipeline; pub mod graph_pipeline;
pub use extensions::IndexBuilder;
pub use graph_pipeline::{build_graph, build_layer_from_kmers, materialize_layer, write_graph_as_unitigs}; pub use graph_pipeline::{build_graph, build_layer_from_kmers, materialize_layer, write_graph_as_unitigs};
+138
View File
@@ -0,0 +1,138 @@
use std::path::PathBuf;
use clap::Args;
use obikindex::KmerIndex;
use obikindex::layer::IndexMode;
use obikrebuild::IndexReindex;
use obisys::Reporter;
use tracing::info;
use crate::cli::block_size_to_bits;
use super::index::resolve_approx_params;
#[derive(Args)]
pub struct ConvertArgs {
/// Index directory to convert (modified in-place)
pub index: PathBuf,
#[command(flatten)]
mode: ModeArgs,
/// Fingerprint bits per slot (b) — required with --hybrid-evidence
/// when the source index is currently exact (there is no existing
/// fingerprint to inherit `b` from); rejected otherwise (the source
/// already fixes `b`, either from its own --approx-evidence or from
/// an already-approximate/hybrid index being converted to hybrid).
#[arg(long, value_name = "BITS")]
pub evidence_bits: Option<u8>,
/// Findere z parameter: number of consecutive *stored* k-mers (length
/// = the index's own k-mer size, fixed forever at `index` build time)
/// that must all match to confirm a hit.
///
/// WARNING: this does NOT shorten the indexed k-mer length the way
/// `-z` does at `index` build time — that length can never change
/// after the fact. Instead it *extends* the effective match window:
/// requiring z consecutive stored k-mers of length s to all match is
/// equivalent to requiring (s + z − 1) consecutive identical bases in
/// the query. E.g. on a k=31 index, z=2 requires 32 consecutive
/// matching bases, not 30 — going from z=1 to z=2 makes matching
/// *stricter*, it does not "index shorter k-mers".
#[arg(short = 'z', long)]
pub findere_z: Option<u8>,
/// Target false-positive rate per z-window (e.g. 0.01) — derives `b`
/// or `z` when one of them isn't given directly (see `index --fp` for
/// the same resolution rules).
#[arg(long)]
pub fp: Option<f64>,
/// Block size for exact evidence `.idx` (number of unitigs per block).
/// Ignored when converting to pure approximate evidence.
#[arg(long, default_value_t = 1)]
pub block_size: usize,
}
#[derive(Args)]
#[group(required = true, multiple = false)]
struct ModeArgs {
/// Convert to exact evidence (zero false positives).
#[arg(long)]
exact_evidence: bool,
/// Convert to approximate (fingerprint-only) evidence. Value = fingerprint bits per slot (b).
#[arg(long, value_name = "BITS")]
approx_evidence: Option<u8>,
/// Convert to hybrid evidence (both exact and approximate bundles kept).
#[arg(long)]
hybrid_evidence: bool,
}
pub fn run(args: ConvertArgs) {
// Modifies the index in place; acquired before opening so a concurrent
// writer can't slip in between the open and the convert below.
let _lock = obisys::DirLock::acquire(&args.index).unwrap_or_else(|e| {
eprintln!("error locking index directory {}: {e}", args.index.display());
std::process::exit(1);
});
let idx = KmerIndex::open(&args.index).unwrap_or_else(|e| {
eprintln!("error opening index: {e}");
std::process::exit(1);
});
let current = idx.meta().config().evidence.clone();
let target = if args.mode.exact_evidence {
warn_if_evidence_params_given(&args, "--exact-evidence");
info!("target: exact evidence");
IndexMode::Exact
} else if let Some(bits) = args.mode.approx_evidence {
let (z, b, fp) = resolve_approx_params(args.findere_z, Some(bits), args.fp);
info!("target: approximate evidence — b={b}, z={z}, fp={fp:.2e}");
IndexMode::Approx { b, z }
} else {
debug_assert!(args.mode.hybrid_evidence);
match current {
IndexMode::Exact => {
let bits = args.evidence_bits.unwrap_or_else(|| {
eprintln!(
"error: --hybrid-evidence requires --evidence-bits when starting from an exact index"
);
std::process::exit(1);
});
let (z, b, fp) = resolve_approx_params(args.findere_z, Some(bits), args.fp);
info!("target: hybrid evidence — b={b}, z={z}, fp={fp:.2e}");
IndexMode::Hybrid { b, z }
}
IndexMode::Approx { b, z } | IndexMode::Hybrid { b, z } => {
if args.evidence_bits.is_some() || args.findere_z.is_some() || args.fp.is_some() {
eprintln!(
"error: --evidence-bits/-z/--fp are not allowed with --hybrid-evidence \
when the source index is already approximate/hybrid — its existing \
b={b}, z={z} are reused as-is"
);
std::process::exit(1);
}
info!("target: hybrid evidence — reusing existing b={b}, z={z}");
IndexMode::Hybrid { b, z }
}
}
};
let block_bits = block_size_to_bits(args.block_size);
let mut rep = Reporter::new();
idx.reindex(target, block_bits, &mut rep).unwrap_or_else(|e| {
eprintln!("convert error: {e}");
std::process::exit(1);
});
rep.print();
}
fn warn_if_evidence_params_given(args: &ConvertArgs, mode: &str) {
if args.evidence_bits.is_some() || args.findere_z.is_some() || args.fp.is_some() {
eprintln!("warning: --evidence-bits/-z/--fp ignored with {mode}");
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod annotate; pub mod annotate;
pub mod convert;
pub mod dump; pub mod dump;
pub mod estimate; pub mod estimate;
pub mod filter; pub mod filter;
+3
View File
@@ -37,6 +37,8 @@ enum Commands {
Annotate(cmd::annotate::AnnotateArgs), Annotate(cmd::annotate::AnnotateArgs),
/// Maintenance/inspection operations on already-built indexes /// Maintenance/inspection operations on already-built indexes
Utils(cmd::utils::UtilsArgs), Utils(cmd::utils::UtilsArgs),
/// Convert an index's evidence representation (exact/approximate/hybrid), in place
Convert(cmd::convert::ConvertArgs),
} }
fn main() { fn main() {
@@ -61,5 +63,6 @@ fn main() {
Commands::Estimate(args) => cmd::estimate::run(args), Commands::Estimate(args) => cmd::estimate::run(args),
Commands::Annotate(args) => cmd::annotate::run(args), Commands::Annotate(args) => cmd::annotate::run(args),
Commands::Utils(args) => cmd::utils::run(args), Commands::Utils(args) => cmd::utils::run(args),
Commands::Convert(args) => cmd::convert::run(args),
} }
} }
+5 -7
View File
@@ -6,12 +6,9 @@
//! //!
//! [`compact_layer`] collapses a partition's accumulated layers (the //! [`compact_layer`] collapses a partition's accumulated layers (the
//! stopgap `obikmerge` uses to add genomes without a full rebuild) back //! stopgap `obikmerge` uses to add genomes without a full rebuild) back
//! into one, in place. `reindex` (reindexing an existing index's evidence //! into one, in place. [`reindex`] converts an existing index's evidence
//! representation in place `Exact`/`Approx`/`Hybrid`) is temporarily //! representation in place (`Exact`/`Approx`/`Hybrid`), delegating the
//! disabled: it predates this session's refactor and still reaches //! actual per-layer construction to `obikindexer::IndexBuilder`.
//! `obikindex` internals (`root_path`/`meta` fields, `index_dir`/
//! `layer_dir` methods) that are no longer accessible from outside that
//! crate. Fixing it is separate, future work.
//! //!
//! Removing k-mers (filtering) and repacking matrix files are *not* here: //! Removing k-mers (filtering) and repacking matrix files are *not* here:
//! the former lives in `obikfilter::Filter` (a fresh index at a new //! the former lives in `obikfilter::Filter` (a fresh index at a new
@@ -21,6 +18,7 @@
//! cannot depend back on this crate. //! cannot depend back on this crate.
mod compact_layer; mod compact_layer;
// mod reindex; // disabled — see module doc above. mod reindex;
pub use compact_layer::IndexCompact; pub use compact_layer::IndexCompact;
pub use reindex::IndexReindex;
+62 -65
View File
@@ -1,43 +1,52 @@
use obikindex::IndexBuilder; //! Convert an existing index's evidence representation in place —
use obikindex::IndexMeta; //! `Exact`/`Approx`/`Hybrid`. `unitigs.bin`/`mphf.bin` are never touched;
use obikindex::layer::{IndexMode, TypedLayer}; //! only the evidence bundle (`evidence.bin`/`unitigs.bin.idx` and/or
use obisys::{Reporter, Stage, progress_bar}; //! `fingerprint.bin`) is — the actual per-layer construction is
use std::fs; //! `obikindexer::IndexBuilder`'s job, this module only orchestrates it
use std::path::Path; //! across every partition/layer and updates `index.meta` on success.
use std::sync::Arc; //!
//! `KmerIndex` is foreign to this crate, so this is an extension trait,
//! same reasoning as `IndexCompact`/`IndexDump`/`IndexUnitigs`.
use obikindex::layer::{IndexMode, KmerLayer};
use obikindex::{IndexState, KmerIndex, OKIError, OKIResult};
use obikindexer::IndexBuilder;
use obisys::{PartitionRunner, Reporter, Stage, progress_bar};
use tracing::info; use tracing::info;
use obikindex::{OKIError, OKIResult}; /// `unitigs.bin.idx`'s filename isn't exposed by `obikindex`/`obiskio` as a
use obikindex::KmerIndex; /// path accessor (it's a private implementation detail of exact-evidence
use obikindex::IndexState; /// construction) — mirrored here as a literal, same as before this module
use obisys::PartitionRunner; /// was disabled.
const EVIDENCE_FILE: &str = "evidence.bin";
const FINGERPRINT_FILE: &str = "fingerprint.bin";
const UNITIG_IDX_FILE: &str = "unitigs.bin.idx"; const UNITIG_IDX_FILE: &str = "unitigs.bin.idx";
impl KmerIndex { pub trait IndexReindex {
/// Convert every layer's evidence bundle to `target` in-place. /// Convert every layer's evidence bundle to `target`, in place.
/// ///
/// - `Exact` → builds `evidence.bin` + `unitigs.bin.idx`, removes `fingerprint.bin` /// - `Exact` → builds `evidence.bin` + `unitigs.bin.idx` where missing, removes `fingerprint.bin`.
/// - `Approx` → builds `fingerprint.bin`, removes `evidence.bin` + `unitigs.bin.idx` /// - `Approx` → builds `fingerprint.bin` where missing or built with a different `b`, removes `evidence.bin` + `unitigs.bin.idx`.
/// - `Hybrid` → builds whichever bundle a layer is still missing, keeps both.
/// ///
/// The MPHF (`mphf.bin`) and unitigs (`unitigs.bin`) are never touched. /// Each layer's own build step is idempotent (see `IndexBuilder`), so
/// `index.meta` is updated with the new evidence kind on success. /// re-running this with the same `target` on an already-converted
pub fn reindex( /// index is a cheap no-op per layer, not a full rebuild.
&mut self, fn reindex(&self, target: IndexMode, block_bits: u8, rep: &mut Reporter) -> OKIResult<()>;
target: IndexMode, }
block_bits: u8,
rep: &mut Reporter, impl IndexReindex for KmerIndex {
) -> OKIResult<()> { fn reindex(&self, target: IndexMode, block_bits: u8, rep: &mut Reporter) -> OKIResult<()> {
if self.state()? != IndexState::Indexed { if self.meta().state().map_err(OKIError::Io)? != IndexState::Indexed {
return Err(OKIError::NotIndexed(self.root_path.clone())); return Err(OKIError::InvalidInput(format!(
"{}: index is not fully built",
self.dir().display()
)));
} }
let n = self.n_partitions(); let n = self.n_partitions();
info!( info!(
"reindex {} partition(s): {:?} → {:?}", "reindex {} partition(s): {:?} → {target:?}",
n, self.meta.config.evidence, target, n,
self.meta().config().evidence,
); );
let t = Stage::start("reindex"); let t = Stage::start("reindex");
@@ -58,68 +67,56 @@ impl KmerIndex {
pb.finish_and_clear(); pb.finish_and_clear();
let mut config = self.meta.config.clone(); self.meta().set_evidence(target, block_bits).map_err(OKIError::Io)?;
config.evidence = target;
if matches!(config.evidence, IndexMode::Exact) {
config.block_bits = block_bits;
}
let genomes = self.meta.genomes().map_err(OKIError::Io)?;
self.meta.rewrite_config(config, genomes).map_err(OKIError::Io)?;
self.meta = Arc::new(IndexMeta::open(self).map_err(OKIError::Io)?);
rep.push(t.stop()); rep.push(t.stop());
Ok(()) Ok(())
} }
} }
/// Process all layers of one partition's index directory. /// Process every layer of partition `i`.
fn reindex_partition( fn reindex_partition(index: &KmerIndex, i: usize, target: &IndexMode, block_bits: u8) -> OKIResult<()> {
index: &KmerIndex, let partition = index.partition(i)?;
i: usize, let n_layers = partition.n_layers();
target: &IndexMode, for l in 0..n_layers {
block_bits: u8, let layer = partition.layer(l)?.open()?;
) -> OKIResult<()> { reindex_layer(&layer, target, block_bits)?;
if !index.index_dir(i).exists() { remove_stale_evidence(&layer, target);
return Ok(());
}
let n_layers = index
.n_layers(i)
.map_err(|e| OKIError::InvalidInput(e.to_string()))?;
for layer_idx in 0..n_layers {
reindex_layer(&index.layer_dir(i, layer_idx), target, block_bits)?;
} }
Ok(()) Ok(())
} }
fn reindex_layer(layer_dir: &Path, target: &IndexMode, block_bits: u8) -> OKIResult<()> { fn reindex_layer(layer: &KmerLayer, target: &IndexMode, block_bits: u8) -> OKIResult<()> {
match target { match target {
IndexMode::Exact => { IndexMode::Exact => {
TypedLayer::<()>::build_exact_evidence(layer_dir, block_bits)?; layer.build_exact_evidence(block_bits)?;
} }
IndexMode::Approx { b, z } | IndexMode::Hybrid { b, z } => { IndexMode::Approx { b, z } => {
TypedLayer::<()>::build_approx_evidence(layer_dir, *b, *z)?; layer.build_approx_evidence(*b, *z)?;
}
IndexMode::Hybrid { b, z } => {
layer.build_hybrid_evidence(*b, *z, block_bits)?;
} }
} }
remove_stale_evidence(layer_dir, target) Ok(())
} }
fn remove_stale_evidence(layer_dir: &Path, target: &IndexMode) -> OKIResult<()> { fn remove_stale_evidence(layer: &KmerLayer, target: &IndexMode) {
match target { match target {
IndexMode::Exact => { IndexMode::Exact => {
remove_if_exists(&layer_dir.join(FINGERPRINT_FILE)); remove_if_exists(&layer.fingerprint_path());
} }
IndexMode::Approx { .. } => { IndexMode::Approx { .. } => {
remove_if_exists(&layer_dir.join(EVIDENCE_FILE)); remove_if_exists(&layer.evidence_path());
remove_if_exists(&layer_dir.join(UNITIG_IDX_FILE)); remove_if_exists(&layer.dir().join(UNITIG_IDX_FILE));
} }
IndexMode::Hybrid { .. } => { IndexMode::Hybrid { .. } => {
// both bundles kept — nothing to remove // both bundles kept — nothing to remove
} }
} }
Ok(())
} }
fn remove_if_exists(path: &Path) { fn remove_if_exists(path: &std::path::Path) {
if let Err(e) = fs::remove_file(path) { if let Err(e) = std::fs::remove_file(path) {
if e.kind() != std::io::ErrorKind::NotFound { if e.kind() != std::io::ErrorKind::NotFound {
eprintln!("warning: could not remove {}: {e}", path.display()); eprintln!("warning: could not remove {}: {e}", path.display());
} }