Push zunrplorkwkt #70

Merged
coissac merged 93 commits from push-zunrplorkwkt into main 2026-08-28 23:15:38 +00:00
19 changed files with 535 additions and 405 deletions
Showing only changes of commit 616cb76af3 - Show all commits
@@ -5,7 +5,11 @@ exists, `Mat` is gone. (1b) done — `Layer::Empty`, the first non-ready
state, added (panics on every read method). (2a) done — the state, added (panics on every read method). (2a) done — the
`obikpartition` crate and `KmerPartition` itself exist (`open`/`n_layers`/ `obikpartition` crate and `KmerPartition` itself exist (`open`/`n_layers`/
`layer`/`layers`/`find`). (2b) — migrating `PartitionCache`/`QueryLayer` `layer`/`layers`/`find`). (2b) — migrating `PartitionCache`/`QueryLayer`
onto it — **not started**, deliberately deferred. Earlier mix-up, for onto it — **not started**, deliberately deferred. (3) done — the
`obikindex ↔ obikpartitionner` dependency inverted: `PartitionRouter` now
takes `&mut KmerIndex` and produces `Layer::Empty` shells directly, closing
the gap `Layer::Empty` was built for in (1b) — see "(3) done" below.
Earlier mix-up, for
context: an earlier context: an earlier
version of this doc used the name `KmerPartition` (singular) for what was version of this doc used the name `KmerPartition` (singular) for what was
actually the *collection* type (later renamed `KmerPartitions`, later actually the *collection* type (later renamed `KmerPartitions`, later
@@ -315,6 +319,134 @@ tuple moved in wholesale); `scan_layer_families`'s still-independent
`PartitionMeta::load` (see "Remaining instance…" below) — all explicitly `PartitionMeta::load` (see "Remaining instance…" below) — all explicitly
deferred to whenever wiring is tackled next. deferred to whenever wiring is tackled next.
## (3) done (2026-08-20): `obikindex ↔ obikpartitionner` dependency inverted, `PartitionRouter` now fills `Layer::Empty` shells
Resolved a question left implicit since "Major restructuring": that pass
set the direction `obikindex → obikpartitionner` (so `KmerIndex` could
delegate `partition_dir` to it) without questioning whether that was the
right direction at all. Challenged directly: `obikpartitionner` is an
*algorithm* (superkmer routing/dereplication/counting) operating on an
*index* (`KmerIndex`, the data structure) — algorithms depend on the data
types they need, not the other way around. [[feedback_no_precedent_defense]]
applied here: "that's the direction we already picked" was not treated as
a justification for keeping it.
**New direction**: `obikpartitionner → obikindex` (+ `obilayeredmap`,
`obipipeline`, `obiread` directly, for what `run`'s pipeline itself needs).
`obikindex → obikpartitionner` is gone entirely — `KmerIndex` no longer
imports `PartitionRouter`/`KmerSpectrum` in any form. Two path-naming
primitives that used to make this edge necessary moved down a tier instead
of staying put:
- `partition_dir`/`PARTITIONS_SUBDIR` moved from `obikpartitionner` into
`obikpartition` (the Partition-tier crate `KmerPartition` already lives
in), alongside a new `index_dir(root, i)` — both free functions,
mirroring `obilayeredmap::layer_dir` one tier down. `KmerIndex::
partition_dir`/`index_dir` now delegate here instead of to
`obikpartitionner`/an inline `.join("index")`.
- `KmerIndex::create`/`create_skeleton` no longer call
`PartitionRouter::create` to lay out an empty `partitions/` skeleton
upfront — turned out to be dead weight once traced: `select_layer.rs`/
`rebuild_layer.rs` already `create_dir_all` their own partition/layer
directories on demand, and `Layer::create`'s directory-creation covers
the scatter path the same way. Partitions and their layer-0 shells now
come into existence lazily, on first write, with nothing to pre-create.
`KmerIndex::create`'s now-unused `force: bool` parameter was dropped
(4 call sites updated) rather than left as a dead parameter.
**`PartitionRouter` reshaped** (`obikpartitionner/src/partition/router.rs`)
around the "création, paramétrage, run()" shape agreed on: `new(index:
&mut KmerIndex) -> Self` (no disk access), chainable setters
(`level_max`/`theta`/`workers`/`max_open`, defaults matching the CLI's old
hardcoded values), then `run(path_source, on_progress)`. `write`/
`write_batch`/`flush`/`close`/`dereplicate`/`count_kmer` stay public,
unconsumed (`&self`/`&mut self`, not `self`) — callers needing fine-grained
control (tests, `obikphylo`'s test harness) still get it, `run` is a
convenience layered on top, not the only way in.
`run` absorbs the entire body of what used to be the free function
`obikmer::steps::scatter` (now deleted, along with the `steps` module
entirely) — the `obipipeline::make_pipe!` two-stage pipeline
(file→pages→superkmers), throttling, per-file logging. What changed:
- Every `ensure_writer(partition)` call now does `Layer::create(&layer0_dir)`
(`layer0_dir = obilayeredmap::layer_dir(&index.index_dir(i), 0)`) before
opening `raw.{ext}` inside it — raw/dereplicated superkmer files and the
provisional `mphf1.bin`/`counts1.bin`/`kmer_spectrum_raw.json` now live
under `<partition>/index/layer_0/`, not flat under `<partition>/` as
before. This is `Layer::Empty` actually being used as the "builder code
holding an `Empty` layer" its own (1b) docs anticipated, not just a shell
with no consumer.
- **Caught by an end-to-end smoke test, not by `cargo test`**: this path
move broke `obikindex::index_layer::build_index_layer` and
`remove_build_artifacts`, both of which still read/deleted
`dereplicated.skmer.zst`/`mphf1.bin`/`counts1.bin` from
`self.partition_dir(i)` (the old flat location) — no test in the
workspace suite exercises the real CLI's file-reading `scatter` path
end-to-end (`obikphylo`'s test harness and `obikpartitionner`'s own
tests both call `write_batch` directly, bypassing `run`/file discovery
entirely), so the whole suite stayed green while `obikmer index` on
real FASTA silently indexed 0 kmers. Found by running the actual CLI
against a small FASTA and noticing `count.json`'s `f0` (870, correct)
didn't match "0 total kmers indexed" at the final stage. Fixed by
retargeting both functions to `self.layer_dir(i, 0)`. **Lesson,
consistent with the retracted-claim lesson above**: a green test suite
is not proof a refactor is correct when no test in it exercises the
specific path that changed — for anything touching the CLI's own
file-driven entry point, running the CLI for real is not optional
verification.
- The internal `obisys::spinner("scatter")` + hand-rolled EMA-rate display
is gone from the library entirely, replaced by an `Option<impl
FnMut(obisys::Progress)>` parameter — a new, deliberately generic
progress-reporting type (`obisys::Progress { position: u64, total:
Option<u64> }`, alongside the existing `TracedBar`/`spinner`/
`progress_bar`) added specifically so every future algo crate's `run()`
reports progress the same shape, once, rather than each inventing its
own. `total: None` here (bases processed isn't knowable without
pre-scanning every input file) — deliberately simpler than the old
in-library rate/file-count/thread-count message; the caller can
recompute a Mbp/s rate from consecutive `position` values +
wall-clock time itself, which is exactly what `cmd/index/mod.rs` now
does to reproduce the old spinner message. This is a real, intentional
restriction of the library's job: it reports raw ticks, the CLI decides
what a human sees — same "generic vs. domain-specific" split applied
again, this time to progress reporting rather than to Layer content.
Explicitly **not** the same mechanism as `Stage`/`Reporter` (per
[[feedback_stage_reporter_in_cmd_layer]]): `Stage`/`Reporter` measures a
whole call's wall time from outside it; a progress callback has to fire
*from inside* a loop mid-call, which wrapping from outside cannot
express — two different needs, not the same rule reapplied under a new
name. `Stage::start("scatter")`/`rep.push(...)` stayed in
`cmd/index/mod.rs`, wrapping the whole `run()` call, unchanged in kind.
- `dereplicate`/`count_kmer` keep their existing internal
`obisys::progress_bar(...)` calls as-is (unconverted to the callback) —
explicitly out of scope for this pass, by agreement.
**Forced, not optional, consequence of the dependency inversion**:
`KmerIndex::dereplicate_and_count`/`partition_router`/`write_spectrum(&
KmerSpectrum)` could not stay on `KmerIndex` at all once `obikindex` can no
longer name `obikpartitionner::{PartitionRouter, KmerSpectrum}` in any
position — not a design choice, a mechanical requirement of severing the
edge. Replaced by: `KmerIndex::write_spectrum(f0: u64, f1: u64, counts:
&BTreeMap<u32, u64>)` (plain values, no `KmerSpectrum` dependency) and a
new `KmerIndex::mark_counted()` (symmetric to the already-existing
`mark_scattered`), with the orchestration itself (`router.dereplicate()`
`router.count_kmer()``write_spectrum``mark_counted()`) now living in
`cmd/index/mod.rs`, not `obikindex`.
Every `PartitionRouter::new(&mut index)` call in this codebase runs into
the same NLL trap once: `PartitionRouter` has a `Drop` impl (auto-`close`
on scope exit), which extends its `&mut KmerIndex` borrow to the end of
the enclosing scope even after its last real use — `idx.mark_scattered()`
right after `router.run(...)` (or `idx.write_spectrum(...)` right after
`router.count_kmer(...)`) fails to borrow-check unless the router is
`drop()`-ed explicitly first. Hit and fixed identically at all three call
sites that needed it (`cmd/index/mod.rs` ×2, `obikphylo`'s test harness,
`obikpartitionner`'s own tests).
Full workspace suite green (`cargo check --workspace --all-targets` +
`cargo test --workspace`, exit code 0) both before and after the
`index_layer.rs` fix above — the smoke test is what actually caught the
regression the suite missed.
## The problem ## The problem
Reading a layer's data (MPHF + matrix) is not free: `MphfLayer::open` mmaps Reading a layer's data (MPHF + matrix) is not free: `MphfLayer::open` mmaps
+6 -95
View File
@@ -39,15 +39,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]] [[package]]
name = "aligned-vec" name = "aligned-vec"
version = "0.6.4" version = "0.6.4"
@@ -137,15 +128,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -340,12 +322,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.1" version = "0.10.1"
@@ -684,15 +660,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "cvt"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "debugid" name = "debugid"
version = "0.8.0" version = "0.8.0"
@@ -913,20 +880,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fs_at"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036"
dependencies = [
"aligned",
"cfg-if",
"cvt",
"libc",
"nix 0.29.0",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -1422,7 +1375,7 @@ dependencies = [
"combine", "combine",
"libc", "libc",
"mach2", "mach2",
"nix 0.26.4", "nix",
"sysctl", "sysctl",
"thiserror 1.0.69", "thiserror 1.0.69",
"widestring", "widestring",
@@ -1484,27 +1437,6 @@ dependencies = [
"pin-utils", "pin-utils",
] ]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "normpath"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "ntapi" name = "ntapi"
version = "0.4.3" version = "0.4.3"
@@ -1606,7 +1538,7 @@ dependencies = [
"obicompactvec", "obicompactvec",
"obidebruinj", "obidebruinj",
"obikentropy", "obikentropy",
"obikpartitionner", "obikpartition",
"obikseq", "obikseq",
"obilayeredmap", "obilayeredmap",
"obipipeline", "obipipeline",
@@ -1675,15 +1607,17 @@ dependencies = [
"memmap2", "memmap2",
"niffler", "niffler",
"obicompactvec", "obicompactvec",
"obikindex",
"obikrope", "obikrope",
"obikseq", "obikseq",
"obilayeredmap",
"obipipeline",
"obiread", "obiread",
"obiskbuilder", "obiskbuilder",
"obiskio", "obiskio",
"obisys", "obisys",
"ptr_hash", "ptr_hash",
"rayon", "rayon",
"remove_dir_all",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
@@ -2016,7 +1950,7 @@ dependencies = [
"findshlibs", "findshlibs",
"libc", "libc",
"log", "log",
"nix 0.26.4", "nix",
"once_cell", "once_cell",
"prost", "prost",
"prost-build", "prost-build",
@@ -2371,20 +2305,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "remove_dir_all"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "808cc0b475acf76adf36f08ca49429b12aad9f678cb56143d5b3cb49b9a1dd08"
dependencies = [
"cfg-if",
"cvt",
"fs_at",
"libc",
"normpath",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3312,15 +3232,6 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
obikseq = { path = "../obikseq" } obikseq = { path = "../obikseq" }
obikpartitionner = { path = "../obikpartitionner" } obikpartition = { path = "../obikpartition" }
obitaxonomy = { path = "../obitaxonomy" } obitaxonomy = { path = "../obitaxonomy" }
obiskio = { path = "../obiskio" } obiskio = { path = "../obiskio" }
obisys = { path = "../obisys" } obisys = { path = "../obisys" }
+19 -41
View File
@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use obikpartitionner::{KmerSpectrum, PartitionRouter};
use obilayeredmap::meta::PartitionMeta; use obilayeredmap::meta::PartitionMeta;
use obisys::{Reporter, Stage, progress_bar}; use obisys::{Reporter, Stage, progress_bar};
use rayon::prelude::*; use rayon::prelude::*;
@@ -29,10 +28,8 @@ impl KmerIndex {
path: P, path: P,
config: IndexConfig, config: IndexConfig,
genome_info: Option<GenomeInfo>, genome_info: Option<GenomeInfo>,
force: bool,
) -> OKIResult<Self> { ) -> OKIResult<Self> {
let root_path = path.as_ref().to_owned(); let root_path = path.as_ref().to_owned();
PartitionRouter::create(&root_path, config.n_bits, force)?;
set_k(config.kmer_size); set_k(config.kmer_size);
set_m(config.minimizer_size); set_m(config.minimizer_size);
let mut meta = IndexMeta::new(config); let mut meta = IndexMeta::new(config);
@@ -86,7 +83,6 @@ impl KmerIndex {
let output = output.as_ref(); let output = output.as_ref();
fs::create_dir_all(output).map_err(OKIError::Io)?; fs::create_dir_all(output).map_err(OKIError::Io)?;
meta.write(output).map_err(OKIError::Io)?; meta.write(output).map_err(OKIError::Io)?;
PartitionRouter::create(output, meta.config.n_bits, false)?;
Ok(KmerIndex { root_path: output.to_owned(), meta: meta.clone() }) Ok(KmerIndex { root_path: output.to_owned(), meta: meta.clone() })
} }
@@ -160,16 +156,19 @@ impl KmerIndex {
} }
/// Path of partition `i`'s raw directory (`partitions/part_{i:05}`) — /// Path of partition `i`'s raw directory (`partitions/part_{i:05}`) —
/// the on-disk naming convention `obikpartitionner::PartitionRouter` /// delegates to `obikpartition`, the Partition tier's own naming
/// also writes to (raw/dereplicated superkmer files, `mphf1.bin`, /// primitive (mirrors `layer_dir` delegating to `obilayeredmap`).
/// `counts1.bin`), the single point of agreement between the two. /// `obikpartitionner::PartitionRouter` reaches this same directory
/// only indirectly, through this method (it depends on `KmerIndex`,
/// not the other way around — see
/// `DevDocMD/implementation/partition_layer_cache.md`).
pub fn partition_dir(&self, i: usize) -> PathBuf { pub fn partition_dir(&self, i: usize) -> PathBuf {
obikpartitionner::partition_dir(&self.root_path, i) obikpartition::partition_dir(&self.root_path, i)
} }
/// Path of partition `i`'s layered-index directory (`<partition>/index`). /// Path of partition `i`'s layered-index directory (`<partition>/index`).
pub fn index_dir(&self, i: usize) -> PathBuf { pub fn index_dir(&self, i: usize) -> PathBuf {
self.partition_dir(i).join("index") obikpartition::index_dir(&self.root_path, i)
} }
/// Path of layer `l` within partition `i`'s layered index. /// Path of layer `l` within partition `i`'s layered index.
@@ -223,39 +222,19 @@ impl KmerIndex {
Ok(()) Ok(())
} }
/// Open a fresh [`PartitionRouter`] onto this index's partition layout /// Mark dereplicate+count as complete and write `count.done`.
/// — the write-side handle for `scatter`, or for `dereplicate_and_count` pub fn mark_counted(&self) -> OKIResult<()> {
/// below. Transient: no state is kept in `KmerIndex` itself between
/// calls, only on disk.
pub fn partition_router(&self) -> OKIResult<PartitionRouter> {
Ok(PartitionRouter::open(&self.root_path, self.meta.config.n_bits)?)
}
/// Dereplicate all partitions then compute kmer counts.
///
/// Writes `spectrums/{label}.json` and touches `count.done` upon completion.
/// Per-partition spectrum files are removed unless `keep_intermediate` is true.
pub fn dereplicate_and_count(
&self,
keep_intermediate: bool,
rep: &mut Reporter,
) -> OKIResult<()> {
let router = self.partition_router()?;
let t = Stage::start("dereplicate");
router.dereplicate()?;
rep.push(t.stop());
let t = Stage::start("count_kmer");
let spectrum = router.count_kmer(keep_intermediate)?;
rep.push(t.stop());
self.write_spectrum(&spectrum)?;
touch(&self.root_path.join(SENTINEL_COUNTED))?; touch(&self.root_path.join(SENTINEL_COUNTED))?;
Ok(()) Ok(())
} }
fn write_spectrum(&self, sp: &KmerSpectrum) -> OKIResult<()> { /// Write `spectrums/{label}.json` from an already-computed kmer
/// spectrum (`f0`/`f1`/abundance histogram). Takes plain values rather
/// than `obikpartitionner::KmerSpectrum` — `KmerIndex` cannot depend on
/// `obikpartitionner` (that dependency runs the other way, see
/// `DevDocMD/implementation/partition_layer_cache.md`), and doesn't
/// need to: this is the only field of that type it actually uses.
pub fn write_spectrum(&self, f0: u64, f1: u64, counts: &BTreeMap<u32, u64>) -> OKIResult<()> {
let label = self let label = self
.meta .meta
.genomes .genomes
@@ -265,15 +244,14 @@ impl KmerIndex {
let spectrums_dir = self.root_path.join("spectrums"); let spectrums_dir = self.root_path.join("spectrums");
fs::create_dir_all(&spectrums_dir)?; fs::create_dir_all(&spectrums_dir)?;
let path = spectrums_dir.join(format!("{label}.json")); let path = spectrums_dir.join(format!("{label}.json"));
let spectrum_map: BTreeMap<String, u64> = sp let spectrum_map: BTreeMap<String, u64> = counts
.counts
.iter() .iter()
.map(|(&c, &f)| (format!("{c:010}"), f)) .map(|(&c, &f)| (format!("{c:010}"), f))
.collect(); .collect();
let f = fs::File::create(&path)?; let f = fs::File::create(&path)?;
serde_json::to_writer_pretty( serde_json::to_writer_pretty(
f, f,
&serde_json::json!({ "f0": sp.f0, "f1": sp.f1, "spectrum": spectrum_map }), &serde_json::json!({ "f0": f0, "f1": f1, "spectrum": spectrum_map }),
) )
.map_err(OKIError::Json)?; .map_err(OKIError::Json)?;
Ok(()) Ok(())
+13 -14
View File
@@ -42,14 +42,13 @@ impl KmerIndex {
mode: &IndexMode, mode: &IndexMode,
block_bits: u8, block_bits: u8,
) -> Result<usize, SKError> { ) -> Result<usize, SKError> {
let partition_dir = self.partition_dir(i); let layer0_dir = self.layer_dir(i, 0);
let dedup_path = partition_dir.join("dereplicated.skmer.zst"); let dedup_path = layer0_dir.join("dereplicated.skmer.zst");
if !dedup_path.exists() { if !dedup_path.exists() {
return Ok(0); return Ok(0);
} }
let layer_dir = self.layer_dir(i, 0); if layer0_dir.join("mphf.bin").exists() {
if layer_dir.join("mphf.bin").exists() {
return Ok(0); return Ok(0);
} }
@@ -57,14 +56,14 @@ impl KmerIndex {
let need_counts = filter_active || with_counts; let need_counts = filter_active || with_counts;
let mphf1_opt: Option<Mphf> = if need_counts { let mphf1_opt: Option<Mphf> = if need_counts {
let p = partition_dir.join("mphf1.bin"); let p = layer0_dir.join("mphf1.bin");
p.exists().then(|| Mphf::load_full(&p).ok()).flatten() p.exists().then(|| Mphf::load_full(&p).ok()).flatten()
} else { } else {
None None
}; };
let counts1_opt: Option<PersistentCompactIntVec> = if need_counts { let counts1_opt: Option<PersistentCompactIntVec> = if need_counts {
let p = partition_dir.join("counts1.bin"); let p = layer0_dir.join("counts1.bin");
p.exists() p.exists()
.then(|| PersistentCompactIntVec::open(&p).ok()) .then(|| PersistentCompactIntVec::open(&p).ok())
.flatten() .flatten()
@@ -95,8 +94,8 @@ impl KmerIndex {
let n_kmers = let n_kmers =
if with_counts { if with_counts {
let n = write_graph_as_unitigs(g, &layer_dir)?; let n = write_graph_as_unitigs(g, &layer0_dir)?;
TypedLayer::<PersistentCompactIntMatrix>::build(&layer_dir, block_bits, mode, |kmer| { TypedLayer::<PersistentCompactIntMatrix>::build(&layer0_dir, block_bits, mode, |kmer| {
match (&mphf1_opt, &counts1_opt) { match (&mphf1_opt, &counts1_opt) {
(Some(mphf), Some(counts)) => counts.get(mphf.index(&kmer.raw())), (Some(mphf), Some(counts)) => counts.get(mphf.index(&kmer.raw())),
_ => 1, _ => 1,
@@ -105,10 +104,10 @@ impl KmerIndex {
.map_err(|e| olm_to_sk(e, "layer build"))?; .map_err(|e| olm_to_sk(e, "layer build"))?;
n n
} else { } else {
materialize_layer(g, &layer_dir, block_bits, mode)? materialize_layer(g, &layer0_dir, block_bits, mode)?
}; };
let index_dir = layer_dir.parent().expect("layer_dir has a parent"); let index_dir = layer0_dir.parent().expect("layer_dir has a parent");
PartitionMeta { PartitionMeta {
n_layers: 1, n_layers: 1,
mode: mode.clone(), mode: mode.clone(),
@@ -123,11 +122,11 @@ impl KmerIndex {
/// ///
/// Deletes `dereplicated.skmer.zst` (+ sidecar), `mphf1.bin`, `counts1.bin`. /// Deletes `dereplicated.skmer.zst` (+ sidecar), `mphf1.bin`, `counts1.bin`.
pub fn remove_build_artifacts(&self, i: usize) { pub fn remove_build_artifacts(&self, i: usize) {
let partition_dir = self.partition_dir(i); let layer0_dir = self.layer_dir(i, 0);
let dedup = partition_dir.join("dereplicated.skmer.zst"); let dedup = layer0_dir.join("dereplicated.skmer.zst");
remove_if_exists(&SKFileMeta::sidecar_path(&dedup)); remove_if_exists(&SKFileMeta::sidecar_path(&dedup));
remove_if_exists(&dedup); remove_if_exists(&dedup);
remove_if_exists(&partition_dir.join("mphf1.bin")); remove_if_exists(&layer0_dir.join("mphf1.bin"));
remove_if_exists(&partition_dir.join("counts1.bin")); remove_if_exists(&layer0_dir.join("counts1.bin"));
} }
} }
+2 -2
View File
@@ -53,7 +53,7 @@ fn query_partition_with_missing_index_dir_returns_default_stats() {
evidence: obilayeredmap::IndexMode::Exact, evidence: obilayeredmap::IndexMode::Exact,
block_bits: 0, block_bits: 0,
}; };
let index = KmerIndex::create(tmp.path().join("idx"), config, None, false).expect("create index"); let index = KmerIndex::create(tmp.path().join("idx"), config, None).expect("create index");
let mut kmers: HashMap<CanonicalKmer, Vec<KmerDesc>> = HashMap::new(); let mut kmers: HashMap<CanonicalKmer, Vec<KmerDesc>> = HashMap::new();
// Any well-formed canonical k-mer works here — the call must return // Any well-formed canonical k-mer works here — the call must return
@@ -81,7 +81,7 @@ fn query_partition_with_empty_kmers_is_a_noop() {
evidence: obilayeredmap::IndexMode::Exact, evidence: obilayeredmap::IndexMode::Exact,
block_bits: 0, block_bits: 0,
}; };
let index = KmerIndex::create(tmp.path().join("idx"), config, None, false).expect("create index"); let index = KmerIndex::create(tmp.path().join("idx"), config, None).expect("create index");
let kmers: HashMap<CanonicalKmer, Vec<KmerDesc>> = HashMap::new(); let kmers: HashMap<CanonicalKmer, Vec<KmerDesc>> = HashMap::new();
let stats = index let stats = index
+68 -13
View File
@@ -1,18 +1,19 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Instant;
use clap::Args; use clap::Args;
use obikindex::{validate_label, GenomeInfo, IndexConfig, IndexState, KmerIndex}; use obikindex::{validate_label, GenomeInfo, IndexConfig, IndexState, KmerIndex};
use obikpartitionner::PartitionRouter;
use obilayeredmap::IndexMode; use obilayeredmap::IndexMode;
fn parse_key_value(s: &str) -> Result<(String, String), String> { fn parse_key_value(s: &str) -> Result<(String, String), String> {
let pos = s.find('=').ok_or_else(|| format!("invalid key=value: no '=' in '{s}'"))?; let pos = s.find('=').ok_or_else(|| format!("invalid key=value: no '=' in '{s}'"))?;
Ok((s[..pos].to_string(), s[pos + 1..].to_string())) Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
} }
use obisys::Reporter; use obisys::{spinner, Progress, Reporter, Stage};
use tracing::info; use tracing::info;
use crate::cli::{CommonArgs, block_size_to_bits, partitions_to_bits}; use crate::cli::{CommonArgs, block_size_to_bits, partitions_to_bits};
use crate::steps::scatter;
#[derive(Args)] #[derive(Args)]
pub struct IndexArgs { pub struct IndexArgs {
@@ -225,7 +226,7 @@ pub fn run(args: IndexArgs) {
} }
info info
}); });
KmerIndex::create(&output, config, genome_info, args.force).unwrap_or_else(|e| { KmerIndex::create(&output, config, genome_info).unwrap_or_else(|e| {
eprintln!("error creating index: {e}"); eprintln!("error creating index: {e}");
std::process::exit(1); std::process::exit(1);
}) })
@@ -234,17 +235,50 @@ pub fn run(args: IndexArgs) {
// ── Stage 1: scatter ───────────────────────────────────────────────────── // ── Stage 1: scatter ─────────────────────────────────────────────────────
if idx.state() < IndexState::Scattered { if idx.state() < IndexState::Scattered {
let k = idx.kmer_size();
let level_max = args.common.level_max;
let theta = args.common.theta;
let n_workers = args.common.threads.max(1); let n_workers = args.common.threads.max(1);
let max_open = args.common.effective_max_open(); let max_open = args.common.effective_max_open();
let mut router = idx.partition_router().unwrap_or_else(|e| {
eprintln!("error opening partition router: {e}"); let t = Stage::start("scatter");
std::process::exit(1); let pb = spinner("scatter");
}); let mut ema_rate: f64 = 0.0;
scatter(&mut router, args.common.seqfile_paths(), k, level_max, theta, n_workers, max_open, &mut rep); let mut last_t = Instant::now();
let mut last_bases: u64 = 0;
const ALPHA: f64 = 0.15;
let mut router = PartitionRouter::new(&mut idx)
.level_max(args.common.level_max)
.theta(args.common.theta)
.workers(n_workers)
.max_open(max_open);
router
.run(
args.common.seqfile_paths(),
Some(|p: Progress| {
let now = Instant::now();
let dt = now.duration_since(last_t).as_secs_f64();
if dt > 0.0 {
let instant = (p.position - last_bases) as f64 / dt;
ema_rate = ALPHA * instant + (1.0 - ALPHA) * ema_rate;
}
last_t = now;
last_bases = p.position;
let bp = p.position as f64;
let (count_str, rate_str) = if bp >= 1e9 {
(format!("{:.2} Gbp", bp / 1e9), format!("{:.0} Mbp/s", ema_rate / 1e6))
} else {
(format!("{:.0} Mbp", bp / 1e6), format!("{:.0} Mbp/s", ema_rate / 1e6))
};
pb.set_message(format!("{count_str} {rate_str}"));
}),
)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
pb.finish_and_clear();
rep.push(t.stop());
drop(router); // ends the borrow of `idx` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
idx.mark_scattered().unwrap_or_else(|e| { idx.mark_scattered().unwrap_or_else(|e| {
eprintln!("error marking scatter done: {e}"); eprintln!("error marking scatter done: {e}");
@@ -256,10 +290,31 @@ pub fn run(args: IndexArgs) {
// ── Stage 2: dereplicate + count ───────────────────────────────────────── // ── Stage 2: dereplicate + count ─────────────────────────────────────────
if idx.state() < IndexState::Counted { if idx.state() < IndexState::Counted {
idx.dereplicate_and_count(args.keep_intermediate, &mut rep).unwrap_or_else(|e| { let router = PartitionRouter::new(&mut idx);
let t = Stage::start("dereplicate");
router.dereplicate().unwrap_or_else(|e| {
eprintln!("error: {e}"); eprintln!("error: {e}");
std::process::exit(1); std::process::exit(1);
}); });
rep.push(t.stop());
let t = Stage::start("count_kmer");
let spectrum = router.count_kmer(args.keep_intermediate).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
rep.push(t.stop());
drop(router); // ends the borrow of `idx` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
idx.write_spectrum(spectrum.f0, spectrum.f1, &spectrum.counts).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
idx.mark_counted().unwrap_or_else(|e| {
eprintln!("error marking count done: {e}");
std::process::exit(1);
});
} else { } else {
info!("dereplicate+count already done, skipping"); info!("dereplicate+count already done, skipping");
} }
-1
View File
@@ -1,6 +1,5 @@
mod cli; mod cli;
mod cmd; mod cmd;
mod steps;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use tracing_subscriber::{EnvFilter, fmt}; use tracing_subscriber::{EnvFilter, fmt};
-3
View File
@@ -1,3 +0,0 @@
mod scatter;
pub use scatter::scatter;
-136
View File
@@ -1,136 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
use obikpartitionner::PartitionRouter;
use obipipeline::{ThrottleGuard, Throttled, throttle};
use obiread::NucPage;
use obisys::spinner;
use obisys::{Reporter, Stage};
use tracing::info;
use crate::cli::PipelineData;
// ── Iterator that keeps the slot guard alive until the file is exhausted ──────
struct GuardedIter {
inner: Box<dyn Iterator<Item = NucPage> + Send>,
_guard: ThrottleGuard,
flat_active: Arc<AtomicU32>,
}
impl Iterator for GuardedIter {
type Item = NucPage;
fn next(&mut self) -> Option<NucPage> {
self.inner.next()
}
}
impl Drop for GuardedIter {
fn drop(&mut self) {
self.flat_active.fetch_sub(1, Ordering::Relaxed);
}
}
// ── scatter ───────────────────────────────────────────────────────────────────
/// Run scatter: normalise → build superkmers → route to partition → close.
/// Reports the "scatter" stage to `rep`.
pub fn scatter(
kp: &mut PartitionRouter,
path_source: impl Iterator<Item = PathBuf> + Send + 'static,
k: usize,
level_max: usize,
theta: f64,
n_workers: usize,
max_open: usize,
rep: &mut Reporter,
) {
use obikseq::RoutableSuperKmer;
// Throttle in the source thread — never in a worker — to prevent deadlock.
let throttled = throttle(path_source, max_open);
let file_count = Arc::new(AtomicU64::new(0));
let flat_active = Arc::new(AtomicU32::new(0));
let transform_active = Arc::new(AtomicU32::new(0));
let t = Stage::start("scatter");
let pipe = obipipeline::make_pipe! {
PipelineData : Throttled<PathBuf> => Vec<RoutableSuperKmer>,
||? {
let file_count = Arc::clone(&file_count);
let flat_active = Arc::clone(&flat_active);
let k = k;
move |pw: Throttled<PathBuf>| {
let path = pw.item;
let guard = pw.guard;
let n = file_count.fetch_add(1, Ordering::Relaxed) + 1;
info!("indexing [{}]: {}", n, path.display());
let path_str = path.to_str().unwrap_or("").to_owned();
flat_active.fetch_add(1, Ordering::Relaxed);
obiread::open_nuc_stream(&path_str, k)
.map(|iter| GuardedIter { inner: iter, _guard: guard, flat_active: Arc::clone(&flat_active) })
}
} : Path => NucPage,
| {
let transform_active = Arc::clone(&transform_active);
move |page| {
transform_active.fetch_add(1, Ordering::Relaxed);
let result = obiskbuilder::build_superkmers_page(page, k, level_max, theta);
transform_active.fetch_sub(1, Ordering::Relaxed);
result
}
} : NucPage => Batch,
};
let pb = spinner("scatter");
let mut total_bases: u64 = 0;
let mut ema_rate: f64 = 0.0;
let mut last_t = Instant::now();
let mut last_bases: u64 = 0;
let kmer_overlap = (k - 1) as u64;
const ALPHA: f64 = 0.15;
for batch in pipe.apply(throttled, n_workers, 1) {
total_bases += batch
.iter()
.map(|sk| (sk.seql() as u64).saturating_sub(kmer_overlap))
.sum::<u64>();
let now = Instant::now();
let dt = now.duration_since(last_t).as_secs_f64();
if dt > 0.1 {
let instant = (total_bases - last_bases) as f64 / dt;
ema_rate = ALPHA * instant + (1.0 - ALPHA) * ema_rate;
last_t = now;
last_bases = total_bases;
let bp = total_bases as f64;
let (count_str, rate_str) = if bp >= 1e9 {
(
format!("{:.2} Gbp", bp / 1e9),
format!("{:.0} Mbp/s", ema_rate / 1e6),
)
} else {
(
format!("{:.0} Mbp", bp / 1e6),
format!("{:.0} Mbp/s", ema_rate / 1e6),
)
};
let n_files = file_count.load(Ordering::Relaxed);
let r = flat_active.load(Ordering::Relaxed);
let c = transform_active.load(Ordering::Relaxed);
pb.set_message(format!(
"{count_str} {rate_str} {n_files} files [R:{r} C:{c}]"
));
}
kp.write_batch(batch).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
}
pb.finish_and_clear();
kp.close().expect("close error");
rep.push(t.stop());
}
+22 -1
View File
@@ -20,11 +20,32 @@
//! entirely) both still reinvent a fragment of this. Migrating them is a //! entirely) both still reinvent a fragment of this. Migrating them is a
//! separate, deferred step. //! separate, deferred step.
use std::path::Path; use std::path::{Path, PathBuf};
use obikseq::CanonicalKmer; use obikseq::CanonicalKmer;
use obilayeredmap::{layer_dir, IndexMode, Layer, OLMResult}; use obilayeredmap::{layer_dir, IndexMode, Layer, OLMResult};
/// Partition subdirectory name, under an index's root — the single source
/// of truth for the on-disk `partitions/part_NNNNN` naming convention.
/// Moved here from `obikpartitionner` (2026-08-20): naming primitives for
/// the Partition tier belong with the other Partition-tier code, not with
/// the superkmer-routing algorithm that happens to be their first
/// consumer — see `DevDocMD/implementation/partition_layer_cache.md`.
pub const PARTITIONS_SUBDIR: &str = "partitions";
/// Path of partition `i`'s directory under `root` — `<root>/partitions/part_NNNNN`,
/// zero-padded to 5 digits.
pub fn partition_dir(root: &Path, i: usize) -> PathBuf {
root.join(PARTITIONS_SUBDIR).join(format!("part_{i:05}"))
}
/// Path of partition `i`'s layered-index directory — `<partition_dir>/index`,
/// the root every [`obilayeredmap::layer_dir`] call for this partition is
/// relative to.
pub fn index_dir(root: &Path, i: usize) -> PathBuf {
partition_dir(root, i).join("index")
}
/// One partition's open layers, in layer order (layer 0 first). /// One partition's open layers, in layer order (layer 0 first).
pub struct KmerPartition { pub struct KmerPartition {
layers: Vec<Layer>, layers: Vec<Layer>,
+7 -5
View File
@@ -6,15 +6,17 @@ edition = "2024"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
obikseq = { path = "../obikseq", features = ["test-utils"] } obikseq = { path = "../obikseq", features = ["test-utils"] }
obiread = { path = "../obiread" }
obikrope = { path = "../obikrope" } obikrope = { path = "../obikrope" }
[dependencies] [dependencies]
niffler = "3.0.0" niffler = "3.0.0"
remove_dir_all = "1.0" obikseq = { path = "../obikseq" }
obikseq = { path = "../obikseq" } obikindex = { path = "../obikindex" }
obiskbuilder = { path = "../obiskbuilder" } obilayeredmap = { path = "../obilayeredmap" }
obiskio = { path = "../obiskio" } obipipeline = { path = "../obipipeline" }
obiread = { path = "../obiread" }
obiskbuilder = { path = "../obiskbuilder" }
obiskio = { path = "../obiskio" }
rayon = "1" rayon = "1"
sysinfo = "0.39" sysinfo = "0.39"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+1 -1
View File
@@ -1,4 +1,4 @@
mod kmer_sort; mod kmer_sort;
mod partition; mod partition;
pub use partition::{partition_dir, KmerSpectrum, PartitionRouter, PARTITIONS_SUBDIR}; pub use partition::{KmerSpectrum, PartitionRouter};
+8 -7
View File
@@ -1,10 +1,12 @@
//! K-mer partitioning: routing super-kmers into per-partition files, //! K-mer partitioning: routing super-kmers into per-partition, layer-0
//! deduplicating them, and counting unique canonical k-mers. //! files, deduplicating them, and counting unique canonical k-mers.
//! //!
//! Submodules: [`router`] (`PartitionRouter`, `KmerSpectrum`, the //! Submodules: [`router`] (`PartitionRouter`, `KmerSpectrum`, the
//! `partition_dir` naming convention, and the routing/lifecycle API), //! routing/lifecycle API — partition/layer path naming itself lives on
//! [`dereplicate`] (two-phase split+merge deduplication), [`count`] //! `obikindex::KmerIndex`, not here, see
//! (unique-kmer enumeration, MPHF, abundance counting). //! `DevDocMD/implementation/partition_layer_cache.md`), [`dereplicate`]
//! (two-phase split+merge deduplication), [`count`] (unique-kmer
//! enumeration, MPHF, abundance counting).
mod count; mod count;
mod dereplicate; mod dereplicate;
@@ -13,7 +15,6 @@ mod router;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use router::{partition_dir, KmerSpectrum, PartitionRouter}; pub use router::{KmerSpectrum, PartitionRouter};
const SK_EXT: &str = "skmer.zst"; const SK_EXT: &str = "skmer.zst";
pub const PARTITIONS_SUBDIR: &str = "partitions";
+205 -77
View File
@@ -1,37 +1,32 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use obisys::progress_bar; use obikindex::KmerIndex;
use obikseq::RoutableSuperKmer; use obikseq::RoutableSuperKmer;
use obilayeredmap::Layer;
use obiskio::SKResult; use obiskio::SKResult;
use obisys::{progress_bar, Progress};
use rayon::prelude::*; use rayon::prelude::*;
use remove_dir_all::remove_dir_all;
use sysinfo::System; use sysinfo::System;
use tracing::info;
use niffler::Level; use niffler::Level;
use niffler::send::compression::Format; use niffler::send::compression::Format;
use obiskio::SKFileWriter; use obiskio::SKFileWriter;
use obipipeline::{throttle, ThrottleGuard, Throttled};
use obiread::NucPage;
use crate::kmer_sort::chunk_size_from_ram; use crate::kmer_sort::chunk_size_from_ram;
use super::count::count_partition; use super::count::count_partition;
use super::dereplicate::{dereplicate_partition, optimal_buckets}; use super::dereplicate::{dereplicate_partition, optimal_buckets};
use super::{PARTITIONS_SUBDIR, SK_EXT}; use super::SK_EXT;
/// Path of partition `i`'s directory under `root` — the single source of
/// truth for the `part_{i:05}` on-disk naming convention. Shared by
/// `PartitionRouter` (which writes here) and, one crate up, `KmerIndex`
/// (which builds `index_dir`/`layer_dir` on top of this same root, once
/// `PartitionRouter` has finished writing) — the only piece of
/// partition-directory knowledge that genuinely needs to cross the
/// crate boundary, since both sides must agree on where a partition lives.
pub fn partition_dir(root: &Path, i: usize) -> PathBuf {
root.join(PARTITIONS_SUBDIR).join(format!("part_{i:05}"))
}
pub struct KmerSpectrum { pub struct KmerSpectrum {
pub f0: u64, pub f0: u64,
@@ -39,75 +34,124 @@ pub struct KmerSpectrum {
pub counts: BTreeMap<u32, u64>, pub counts: BTreeMap<u32, u64>,
} }
/// Routes raw super-kmers into per-partition files, then dereplicates and // ── Pipeline plumbing, private to `run` ─────────────────────────────────────
/// counts them — this crate's entire job now that layer/query/merge/select/
/// rebuild/dump/distance concerns have moved to `obikindex` (they operate on /// Carrier enum for `obipipeline::make_pipe!`'s two-stage transform — local
/// built layers, which don't exist yet at this stage — see /// to this crate, not `obikmer`'s own `PipelineData` (which stays scoped to
/// `DevDocMD/implementation/partition_layer_cache.md`). Transient: it makes /// its other CLI commands): a library crate can't depend on the binary
/// sense only while raw partition files are being written or processed, a /// that consumes it, so this is a self-contained duplicate of the same
/// phase that always precedes any `Layer`. /// shape, not a shared type.
pub struct PartitionRouter { enum PipelineData {
root_path: PathBuf, Path(Throttled<PathBuf>),
NucPage(NucPage),
Batch(Vec<RoutableSuperKmer>),
}
unsafe impl Send for PipelineData {}
unsafe impl Sync for PipelineData {}
/// Keeps a file's throttle-slot guard alive until the file's page iterator
/// is exhausted, so the next queued file can only start once this one has
/// actually finished producing pages, not merely been dequeued.
struct GuardedIter {
inner: Box<dyn Iterator<Item = NucPage> + Send>,
_guard: ThrottleGuard,
flat_active: Arc<AtomicU32>,
}
impl Iterator for GuardedIter {
type Item = NucPage;
fn next(&mut self) -> Option<NucPage> {
self.inner.next()
}
}
impl Drop for GuardedIter {
fn drop(&mut self) {
self.flat_active.fetch_sub(1, Ordering::Relaxed);
}
}
// ── PartitionRouter ──────────────────────────────────────────────────────────
/// Routes raw super-kmers into per-partition, layer-0 files, then
/// dereplicates and counts them — this crate's entire job now that
/// layer/query/merge/select/rebuild/dump/distance concerns have moved to
/// `obikindex` (they operate on built layers, which don't exist yet at this
/// stage — see `DevDocMD/implementation/partition_layer_cache.md`).
///
/// Holds `&mut KmerIndex` — this is an algorithm operating on an index, not
/// a data structure of its own; it owns no path-naming knowledge (every
/// path comes from `index.index_dir`/`obilayeredmap::layer_dir`/
/// `Layer::create`), only the transient routing/dereplication/counting
/// state a run needs.
///
/// Two-phase construction: `new` + optional setters (`level_max`/`theta`/
/// `workers`/`max_open`) configure the run, `run` executes it. `run` takes
/// an `Option` progress callback — the router reports raw `(position,
/// total)` ticks via [`obisys::Progress`] and stays unaware of *how* (or
/// whether) the caller displays them; rendering a spinner/progress bar is
/// the caller's decision, not this crate's.
pub struct PartitionRouter<'a> {
index: &'a mut KmerIndex,
n_partitions: usize, n_partitions: usize,
partitions_mask: u64, partitions_mask: u64,
writers: Vec<Option<SKFileWriter>>, writers: Vec<Option<SKFileWriter>>,
level: Level, level: Level,
closed: bool, closed: bool,
level_max: usize,
theta: f64,
workers: usize,
max_open: usize,
} }
impl PartitionRouter { impl<'a> PartitionRouter<'a> {
/// Create a fresh partition layout at `root_path` for `n_partitions = /// Configure a router for `index`'s partition layout. Doesn't touch
/// 2^n_bits` partitions. /// disk by itself — partitions and their layer-0 shells are created
pub fn create(root_path: &Path, n_bits: usize, force: bool) -> SKResult<Self> { /// lazily, on the first super-kmer routed to each of them.
// `root_path` itself may already exist as a bare directory: callers pub fn new(index: &'a mut KmerIndex) -> Self {
// typically hold an index-level lock file there before creating the let n_bits = index.n_bits();
// partition layout. What actually signals a pre-existing partition
// set is the `PARTITIONS_SUBDIR` subdirectory, not the root itself.
if root_path.join(PARTITIONS_SUBDIR).exists() {
if force {
remove_dir_all(root_path)?;
} else {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{}: partition directory already exists", root_path.display()),
)
.into());
}
}
fs::create_dir_all(root_path.join(PARTITIONS_SUBDIR))?;
Self::new(root_path, n_bits)
}
/// Reopen the partition layout at `root_path` for further routing (or
/// for `dereplicate`/`count_kmer`, which don't need `writers` but reuse
/// this same handle for consistency). Every caller of `open` wants to
/// write or process, never just read paths (those live on `KmerIndex`
/// directly), so this always starts open too.
pub fn open(root_path: &Path, n_bits: usize) -> SKResult<Self> {
if !root_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{}: partition directory not found", root_path.display()),
)
.into());
}
Self::new(root_path, n_bits)
}
fn new(root_path: &Path, n_bits: usize) -> SKResult<Self> {
let n_partitions = 1usize << n_bits; let n_partitions = 1usize << n_bits;
let writers = (0..n_partitions).map(|_| None).collect(); let workers = obisys::effective_parallelism();
Ok(Self { Self {
root_path: root_path.to_owned(), index,
n_partitions, n_partitions,
partitions_mask: (1u64 << n_bits) - 1, partitions_mask: (1u64 << n_bits) - 1,
writers, writers: (0..n_partitions).map(|_| None).collect(),
level: Level::One, level: Level::One,
closed: false, closed: false,
}) level_max: 6,
theta: 0.7,
workers,
max_open: (workers / 4).max(1),
}
} }
/// Route and write one super-kmer to its partition file. /// Maximum sub-word size for entropy computation (superkmer building).
pub fn level_max(mut self, v: usize) -> Self {
self.level_max = v;
self
}
/// Entropy threshold (k-mers with score ≤ theta are rejected).
pub fn theta(mut self, v: f64) -> Self {
self.theta = v;
self
}
/// Number of worker threads for `run`'s file-reading/superkmer-building pipeline.
pub fn workers(mut self, v: usize) -> Self {
self.workers = v;
self
}
/// Maximum number of input files `run` opens simultaneously.
pub fn max_open(mut self, v: usize) -> Self {
self.max_open = v;
self
}
/// Route and write one super-kmer to its partition's raw super-kmer file.
pub fn write(&mut self, rsk: RoutableSuperKmer) -> SKResult<()> { pub fn write(&mut self, rsk: RoutableSuperKmer) -> SKResult<()> {
self.check_not_closed()?; self.check_not_closed()?;
let partition = (rsk.minimizer().seq_hash() & self.partitions_mask) as usize; let partition = (rsk.minimizer().seq_hash() & self.partitions_mask) as usize;
@@ -149,6 +193,82 @@ impl PartitionRouter {
!self.closed !self.closed
} }
/// Run the full scatter pipeline: normalise every file in
/// `path_source` -> build super-kmers -> route -> write, then close.
/// `on_progress`, when set, is called periodically (rate-limited, not
/// once per batch) with cumulative bases processed so far —
/// `total: None`, since the total isn't known without pre-scanning
/// every input file.
pub fn run(
&mut self,
path_source: impl Iterator<Item = PathBuf> + Send + 'static,
mut on_progress: Option<impl FnMut(Progress)>,
) -> SKResult<()> {
let k = self.index.kmer_size();
let level_max = self.level_max;
let theta = self.theta;
let n_workers = self.workers;
let max_open = self.max_open;
// Throttle in the source thread — never in a worker — to prevent deadlock.
let throttled = throttle(path_source, max_open);
let file_count = Arc::new(AtomicU64::new(0));
let flat_active = Arc::new(AtomicU32::new(0));
let transform_active = Arc::new(AtomicU32::new(0));
let pipe = obipipeline::make_pipe! {
PipelineData : Throttled<PathBuf> => Vec<RoutableSuperKmer>,
||? {
let file_count = Arc::clone(&file_count);
let flat_active = Arc::clone(&flat_active);
move |pw: Throttled<PathBuf>| {
let path = pw.item;
let guard = pw.guard;
let n = file_count.fetch_add(1, Ordering::Relaxed) + 1;
info!("indexing [{}]: {}", n, path.display());
let path_str = path.to_str().unwrap_or("").to_owned();
flat_active.fetch_add(1, Ordering::Relaxed);
obiread::open_nuc_stream(&path_str, k)
.map(|iter| GuardedIter { inner: iter, _guard: guard, flat_active: Arc::clone(&flat_active) })
}
} : Path => NucPage,
| {
let transform_active = Arc::clone(&transform_active);
move |page| {
transform_active.fetch_add(1, Ordering::Relaxed);
let result = obiskbuilder::build_superkmers_page(page, k, level_max, theta);
transform_active.fetch_sub(1, Ordering::Relaxed);
result
}
} : NucPage => Batch,
};
let mut total_bases: u64 = 0;
let mut last_report = Instant::now();
let kmer_overlap = (k - 1) as u64;
const REPORT_INTERVAL: f64 = 0.1;
for batch in pipe.apply(throttled, n_workers, 1) {
total_bases += batch
.iter()
.map(|sk| (sk.seql() as u64).saturating_sub(kmer_overlap))
.sum::<u64>();
if let Some(cb) = on_progress.as_mut() {
let now = Instant::now();
if now.duration_since(last_report).as_secs_f64() > REPORT_INTERVAL {
last_report = now;
cb(Progress { position: total_bases, total: None });
}
}
self.write_batch(batch)?;
}
if let Some(cb) = on_progress.as_mut() {
cb(Progress { position: total_bases, total: None });
}
self.close()
}
/// Deduplicate all `raw.{ext}` files in parallel, replacing each with a /// Deduplicate all `raw.{ext}` files in parallel, replacing each with a
/// `dereplicated.{ext}` file where identical canonical sequences are merged /// `dereplicated.{ext}` file where identical canonical sequences are merged
/// and their counts summed. /// and their counts summed.
@@ -181,7 +301,7 @@ impl PartitionRouter {
let results: Vec<SKResult<()>> = (0..self.n_partitions) let results: Vec<SKResult<()>> = (0..self.n_partitions)
.into_par_iter() .into_par_iter()
.map(|i| { .map(|i| {
let dir = partition_dir(&self.root_path, i); let dir = self.layer0_dir(i);
if !dir.exists() { if !dir.exists() {
pb.inc(1); pb.inc(1);
return Ok(()); return Ok(());
@@ -226,7 +346,7 @@ impl PartitionRouter {
let results: Vec<SKResult<()>> = (0..self.n_partitions) let results: Vec<SKResult<()>> = (0..self.n_partitions)
.into_par_iter() .into_par_iter()
.map(|i| { .map(|i| {
let dir = partition_dir(&self.root_path, i); let dir = self.layer0_dir(i);
let dedup_path = dir.join(format!("dereplicated.{SK_EXT}")); let dedup_path = dir.join(format!("dereplicated.{SK_EXT}"));
if !dedup_path.exists() { if !dedup_path.exists() {
pb.inc(1); pb.inc(1);
@@ -251,7 +371,7 @@ impl PartitionRouter {
let mut f1: u64 = 0; let mut f1: u64 = 0;
for i in 0..self.n_partitions { for i in 0..self.n_partitions {
let path = partition_dir(&self.root_path, i).join("kmer_spectrum_raw.json"); let path = self.layer0_dir(i).join("kmer_spectrum_raw.json");
if !path.exists() { if !path.exists() {
continue; continue;
} }
@@ -276,6 +396,14 @@ impl PartitionRouter {
// ── private ─────────────────────────────────────────────────────────────── // ── private ───────────────────────────────────────────────────────────────
/// Directory of partition `i`'s layer 0 — every raw/dereplicated
/// superkmer file and provisional `mphf1.bin`/`counts1.bin` this router
/// produces lives here, alongside where `build_index_layer`
/// (`obikindex`) will later turn it into the real layer 0.
fn layer0_dir(&self, i: usize) -> PathBuf {
obilayeredmap::layer_dir(&self.index.index_dir(i), 0)
}
fn check_not_closed(&self) -> SKResult<()> { fn check_not_closed(&self) -> SKResult<()> {
if self.closed { if self.closed {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "write to closed PartitionRouter").into()) Err(io::Error::new(io::ErrorKind::BrokenPipe, "write to closed PartitionRouter").into())
@@ -286,8 +414,8 @@ impl PartitionRouter {
fn ensure_writer(&mut self, partition: usize) -> SKResult<&mut SKFileWriter> { fn ensure_writer(&mut self, partition: usize) -> SKResult<&mut SKFileWriter> {
if self.writers[partition].is_none() { if self.writers[partition].is_none() {
let dir = partition_dir(&self.root_path, partition); let dir = self.layer0_dir(partition);
fs::create_dir_all(&dir)?; Layer::create(&dir).map_err(|e| io::Error::other(e.to_string()))?;
let file_path = dir.join(format!("raw.{SK_EXT}")); let file_path = dir.join(format!("raw.{SK_EXT}"));
let writer = SKFileWriter::create_with(file_path, Format::Zstd, self.level)?; let writer = SKFileWriter::create_with(file_path, Format::Zstd, self.level)?;
self.writers[partition] = Some(writer); self.writers[partition] = Some(writer);
@@ -296,7 +424,7 @@ impl PartitionRouter {
} }
} }
impl Drop for PartitionRouter { impl Drop for PartitionRouter<'_> {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.close(); let _ = self.close();
} }
+19 -3
View File
@@ -1,16 +1,30 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use obikindex::{IndexConfig, KmerIndex};
use obikrope::Rope; use obikrope::Rope;
use obikseq::SuperKmer; use obikseq::SuperKmer;
use obilayeredmap::IndexMode;
use obiskbuilder::build_superkmers; use obiskbuilder::build_superkmers;
use super::count::count_partition; use super::count::count_partition;
use super::{PartitionRouter, PARTITIONS_SUBDIR}; use super::PartitionRouter;
const K: usize = 11; const K: usize = 11;
const M: usize = 5; const M: usize = 5;
fn test_index(dir: &std::path::Path) -> KmerIndex {
let config = IndexConfig {
kmer_size: K,
minimizer_size: M,
n_bits: 0, // 1 partition — matches this suite's ground-truth setup
with_counts: false,
evidence: IndexMode::Exact,
block_bits: 0,
};
KmerIndex::create(dir, config, None).unwrap()
}
fn setup() { fn setup() {
obikseq::params::set_k(K); obikseq::params::set_k(K);
obikseq::params::set_m(M); obikseq::params::set_m(M);
@@ -46,12 +60,14 @@ fn pipeline_counts(seqs: &[&[u8]]) -> (u64, u64) {
let superkmers: Vec<_> = build_superkmers(rope, K, 1, 0.0); let superkmers: Vec<_> = build_superkmers(rope, K, 1, 0.0);
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut kp = PartitionRouter::create(dir.path(), 0, true).unwrap(); let mut index = test_index(&dir.path().join("idx"));
let mut kp = PartitionRouter::new(&mut index);
kp.write_batch(superkmers).unwrap(); kp.write_batch(superkmers).unwrap();
kp.close().unwrap(); kp.close().unwrap();
kp.dereplicate().unwrap(); kp.dereplicate().unwrap();
drop(kp); // ends the borrow of `index` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
let part_dir = dir.path().join(PARTITIONS_SUBDIR).join("part_00000"); let part_dir = index.layer_dir(0, 0);
let dedup_path = part_dir.join("dereplicated.skmer.zst"); let dedup_path = part_dir.join("dereplicated.skmer.zst");
if !dedup_path.exists() { if !dedup_path.exists() {
return (0, 0); return (0, 0);
+9 -3
View File
@@ -7,6 +7,7 @@ use obisys::Reporter;
use tempfile::tempdir; use tempfile::tempdir;
use obikindex::{GenomeInfo, IndexConfig, KmerIndex, MergeMode}; use obikindex::{GenomeInfo, IndexConfig, KmerIndex, MergeMode};
use obikpartitionner::PartitionRouter;
use super::alignment::SnpAlignmentExt; use super::alignment::SnpAlignmentExt;
use super::build::SiblingAnnexBuildExt; use super::build::SiblingAnnexBuildExt;
@@ -64,19 +65,24 @@ fn build_single_genome_index(dir: &Path, label: &str, seq: &[u8]) -> KmerIndex {
evidence: obilayeredmap::IndexMode::Exact, evidence: obilayeredmap::IndexMode::Exact,
block_bits: 0, block_bits: 0,
}; };
let mut idx = KmerIndex::create(&index_path, config, Some(GenomeInfo::new(label)), false) let mut idx = KmerIndex::create(&index_path, config, Some(GenomeInfo::new(label)))
.expect("create"); .expect("create");
let mut rep = Reporter::new(); let mut rep = Reporter::new();
let stream = obiread::open_nuc_stream(fasta_path.to_str().unwrap(), K).expect("open fasta"); let stream = obiread::open_nuc_stream(fasta_path.to_str().unwrap(), K).expect("open fasta");
let mut router = idx.partition_router().expect("partition_router"); let mut router = PartitionRouter::new(&mut idx);
for page in stream { for page in stream {
let batch = obiskbuilder::build_superkmers_page(page, K, /* level_max */ 1, /* theta */ 0.0); let batch = obiskbuilder::build_superkmers_page(page, K, /* level_max */ 1, /* theta */ 0.0);
router.write_batch(batch).expect("write_batch"); router.write_batch(batch).expect("write_batch");
} }
router.close().expect("close partition writers"); router.close().expect("close partition writers");
router.dereplicate().expect("dereplicate");
let spectrum = router.count_kmer(false).expect("count_kmer");
drop(router); // ends the borrow of `idx` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
idx.mark_scattered().expect("mark_scattered"); idx.mark_scattered().expect("mark_scattered");
idx.dereplicate_and_count(false, &mut rep).expect("dereplicate_and_count"); idx.write_spectrum(spectrum.f0, spectrum.f1, &spectrum.counts).expect("write_spectrum");
idx.mark_counted().expect("mark_counted");
idx.build_layers(1, None, false, &mut rep).expect("build_layers"); idx.build_layers(1, None, false, &mut rep).expect("build_layers");
idx idx
} }
+1 -1
View File
@@ -9,6 +9,6 @@ mod stage;
pub use budget::MemoryBudget; pub use budget::MemoryBudget;
pub use lock::DirLock; pub use lock::DirLock;
pub use progress::{TracedBar, progress_bar, spinner}; pub use progress::{Progress, TracedBar, progress_bar, spinner};
pub use resources::{CpuSample, IoSample, available_memory_bytes, effective_parallelism, peak_rss_bytes}; pub use resources::{CpuSample, IoSample, available_memory_bytes, effective_parallelism, peak_rss_bytes};
pub use stage::{Reporter, Stage, StageStats}; pub use stage::{Reporter, Stage, StageStats};
+21
View File
@@ -5,6 +5,27 @@ use std::time::{Duration, Instant};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use tracing::{debug, info}; use tracing::{debug, info};
// ── Generic progress reporting ──────────────────────────────────────────────
/// One tick of progress from a long-running algorithm, passed to a
/// caller-supplied callback — the standard shape every algo crate's `run()`
/// should use, so a CLI command only has to learn to read this once
/// regardless of which algorithm is reporting.
///
/// `total: None` means the algorithm doesn't know its total work up front
/// (e.g. bases scattered from input files of unknown size until read) — the
/// caller should render a spinner. `total: Some(n)` means it does — the
/// caller can render an actual progress bar. The algorithm itself never
/// decides which; it only reports what it knows, `position` and `total`,
/// and stays unaware of how — or whether — the caller displays it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
pub position: u64,
pub total: Option<u64>,
}
// ── Terminal progress bar/spinner ────────────────────────────────────────────
const BRAILLE: &[&str] = &["", "", "", "", "", "", "", "", "", ""]; const BRAILLE: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
const ETA_REFRESH_MS: u64 = 500; const ETA_REFRESH_MS: u64 = 500;
const ETA_MIN_ELAPSED_MS: u64 = 1000; const ETA_MIN_ELAPSED_MS: u64 = 1000;