Files
obikmer/DevDocMD/implementation/partition_layer_cache.md
T
Eric Coissac 616cb76af3 Refactor indexing pipeline and partition path resolution
Invert dependencies by moving partition path primitives to a dedicated module and updating the index crate accordingly. Reshape the partition router to accept a mutable index reference, enabling chainable configuration and resolving lifetime issues with explicit drops. Shift orchestration logic from the index crate to the CLI, replacing monolithic scatter calls with discrete dereplication and counting steps. Introduce a generic progress callback API to decouple rate calculation from UI rendering, and correct file I/O paths to route layer-0 artifacts under the partition index directory.
2026-08-20 22:15:13 +02:00

41 KiB

Partition and layer caching (discussion)

Status (2026-08-20, latest pass): (1) done — obilayeredmap::Layer exists, Mat is gone. (1b) done — Layer::Empty, the first non-ready state, added (panics on every read method). (2a) done — the obikpartition crate and KmerPartition itself exist (open/n_layers/ layer/layers/find). (2b) — migrating PartitionCache/QueryLayer 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 version of this doc used the name KmerPartition (singular) for what was actually the collection type (later renamed KmerPartitions, later merged into KmerIndex — see "Major restructuring" below), and never retracted that usage before this section was rewritten. An agent working from that stale wording built the wrong thing. If you are about to implement (2), read "Definitions: obikpartition and KmerPartition" below — it is the current, authoritative naming — before touching any other section of this file, some of which still describe superseded states of the code and are kept only as dated history.

Definitions: obikpartition and KmerPartition (not yet created)

obikpartition — a new workspace crate, not created yet. Holds the Partition tier of the Index { Partition { Layer } } model, the same way obilayeredmap already holds the Layer tier as its own crate rather than living inside obikindex. Depends only on obilayeredmap (for Layer) and lower (obikseq, obiskio). Does not depend on obikindex, obikpartitionner, or obikphylo. Dependency direction: obikindex → obikpartition → obilayeredmap; obikphylo → obikindex (and/or obikpartition directly if it ends up needing it without going through KmerIndex).

KmerPartition (singular) — the one type this crate exists for. Represents one partition's already-open layers — a read cache, built once per partition and held for the run, not rebuilt per lookup. Shape:

pub struct KmerPartition {
    layers: Vec<obilayeredmap::Layer>,
}

Nothing else. In particular:

  • No path computation. KmerPartition::open takes an already-resolved index_dir: &Path (plus mode: &IndexMode, n_layers: usize, with_counts: bool — whatever it needs, as plain arguments), the same discipline obikpartitionner::PartitionRouter::open already follows. Computing index_dir/layer_dir from a partition number is KmerIndex's job (obikindex, which owns that already — see "Major restructuring" below); KmerPartition never reaches back into KmerIndex to get it (would require obikpartition → obikindex, the wrong direction).
  • No routing/write state. Writing raw superkmers, dereplicate, count_kmer stay in obikpartitionner::PartitionRouter — a completely different crate, a completely different phase (pre-layer, whereas KmerPartition only makes sense once layers exist).
  • No multi-partition collection baked in. KmerPartition is one partition. Whatever ends up caching several of them (replacing obikphylo::siblings::cache::PartitionCache's Vec<Vec<Layer>> and obikindex::query_layer's per-call reopen) holds Vec<KmerPartition> — that collection can live in obikpartition too, or in obikindex alongside KmerIndex; not yet decided, secondary to getting KmerPartition itself right first.

Do not confuse with KmerPartitions (plural — note the s): that type is gone. It used to be obikpartitionner's (nee obikpartition, briefly — see the crate-rename history below, itself a separate rename from this one) do-everything struct — routing, dereplication, and path lookups all in one. It was deleted on 2026-08-20; its read-side (paths, n_layers, partition_meta) was absorbed into KmerIndex, its write-side became PartitionRouter. KmerPartition (this section, singular, no final s) is a brand-new type with a different job, in a crate that doesn't exist yet — not a revival, not a renaming, of KmerPartitions.

Type-to-concept mapping: Index / Partition / Layer

The conceptual nesting Index { Partition { Layer { MPHF, Evidence, Matrix } } } }, current state:

  • Index = obikindex::KmerIndex{ root_path, meta: IndexMeta }. Also directly exposes the partition-path/metadata accessors (partition_dir(i), index_dir(i), layer_dir(i, l), partition_meta(i), n_layers(i), partition_mode(i), n_partitions()) since KmerPartitions merged into it (see "Major restructuring" below) — KmerIndex today is "index + collection of partitions' paths & metadata," just without a Vec of open layers.
  • Partition, the collection = no dedicated type today; the closest thing is KmerIndex itself (previous bullet). Once KmerPartition (singular, see Definitions above) exists, a Vec<KmerPartition> somewhere would be this — still open, see "Direction agreed" below.
  • Partition, one of them = obikpartition::KmerPartitionto be built, see Definitions above. Nothing plays this role today; obikphylo::siblings::cache::PartitionCache and obikindex::query_layer::QueryLayer each independently reinvent a fragment of it.
  • Layer = obilayeredmap::Layer (format-erased: Count/Presence, each wrapping a TypedLayer<D>) — see "(1) done" below for how this came to be; TypedLayer<D> ({ mphf: MphfLayer, data: D }, monomorphic) is the lower-level, D-fixed building block Layer is built on, not what other crates should reach for directly.
    • MPHF = MphfLayer.mphf: MemCase<MphfEps> — kmer → slot.
    • Evidence = MphfLayer.ev: LayerEvidence (Exact/Approx/ Hybridevidence.bin/fingerprint.bin; see EvidenceKind).
    • Matrix = TypedLayer<D>.data: DPersistentBitMatrix / PersistentCompactIntMatrix.

Target nesting once KmerPartition exists:

KmerIndex                                    (obikindex)
 └─ (opened on demand, per i)  KmerPartition  (obikpartition — not yet built)
     └─ layers: Vec<Layer>                    (obilayeredmap)
         └─ Layer::Count/Presence(TypedLayer<D>)
             └─ TypedLayer<D> { mphf: MphfLayer, data: D }
                 ├─ mphf.mphf   → MPHF
                 ├─ mphf.ev     → Evidence
                 └─ data        → Matrix

Major restructuring (2026-08-20): KmerPartitions merged into KmerIndex

Prompted by a direct question: why keep KmerIndex/KmerPartitions split when, one level down, KmerPartitions is going to directly hold Vec<KmerPartition> rather than being split again into "collection-holder" + "collection"? Investigating the actual justification ("KmerPartitions has an independent lifecycle, used before an index exists") turned out to be falseKmerPartitions::create was called in exactly one place, inside KmerIndex::create, and every open_with_config reopen outside KmerIndex's own constructors was a redundant re-derivation of a KmerPartitions already reachable via index.partition() (the exact kind of duplication this whole doc has been tracking). Once that was gone, so was the reason to keep them separate.

Second correction, from the same conversation: obikpartitionner had accumulated query/merge/select/rebuild/dump/distance logic that has nothing to do with partitioning super-kmers — it operates on layers, which don't exist yet at the phase obikpartitionner is actually responsible for (scatter → dereplicate → count, all pre-layer). That logic moved to obikindex, which already depends on obilayeredmap and never needed obikpartitionner for it. No crate-dependency inversion was needed — obikindex → obikpartitionner stays the same direction as before.

Result:

  • obikpartitionner (renamed back from obikpartition) now contains only PartitionRouter (superkmer routing: write/write_batch/flush/ close, dereplicate, count_kmer, KmerSpectrum) and the partition_dir(root, i) naming primitive both PartitionRouter and KmerIndex build on. KmerPartitions no longer exists as a type.
  • KmerIndex (obikindex) absorbed KmerPartitions's read-side entirely: partition_dir/index_dir/layer_dir/partition_meta/n_layers/ partition_mode/n_partitions (the last now derived from 2^config.n_bits, no longer a stored, independently-set duplicate field — kmer_size/minimizer_size used to be double-stored, in both KmerPartitions and IndexMeta.config, a latent-drift risk flagged earlier in this doc; now single-sourced from IndexMeta.config). Seven whole files moved from obikpartitionner into obikindex verbatim as impl KmerIndex blocks, kept as separate files (not merged into existing same-topic files): index_layer.rs, query_layer.rs, merge_layer/, select_layer.rs, rebuild_layer.rs, dump_layer.rs, plus distance.rs's count_store/presence_store (renamed matrix_store.rs to avoid colliding with obikindex's own pre-existing distance.rs), and their shared support (common.rs's load_meta/ olm_to_sk, filter.rs, graph_pipeline.rs).
  • obikphylo::siblings::cache::PartitionCache::build now takes &KmerIndex directly instead of a separately-opened &KmerPartitions — this deleted the redundant-reopen pattern at all 8 call sites (alignment/build/cardinality/distance/entropy×2/ sankoff_bundle/stats), the same bug flagged earlier in this conversation as a side effect of investigating the false "independent lifecycle" claim.
  • KmerIndex::partition()/partition_mut() are gone; scatter() (obikmer) and any write-side code get a transient PartitionRouter via KmerIndex::partition_router().
  • A real bug caught by the test suite during this move: PartitionRouter::open initially defaulted to closed: true (inherited from KmerPartitions::open_with_config's old read-only-reopen semantics), which broke every write through a router obtained via partition_router(). Fixed — PartitionRouter is exclusively a write/processing tool now, so open always starts open.

Full workspace test suite green (0 failed) after, including all 27 obikphylo::siblings tests.

(1) done (2026-08-20): Layer is now the heterogeneous handle, Mat is gone

Resolved the naming question left open above. Layer<D> (the old generic/monomorphic type) renamed to TypedLayer<D> throughout (obilayeredmap, obikindex, obikphylo — 12 files, mechanical) to free Layer for the type that's actually meant to be everyone's default handle. obilayeredmap::content_layer::Layer (re-exported at the crate root) is that type — Count(TypedLayer<PersistentCompactIntMatrix>)/ Presence(TypedLayer<PersistentBitMatrix>), Layer::open doing the same disk probe Mat::open used to, find_slot/index_batch/n_cols/ fill_sub_matrix_carries dispatching per variant exactly as Mat did.

obikphylo::siblings::cache::Mat deleted outright — PartitionCache now holds Vec<Vec<obilayeredmap::Layer>> directly. The one sibling-specific method Mat carried (iter_minorants_batch) is not on obilayeredmap:: Layer (phylo concepts don't belong in obilayeredmap) — it's an impl SiblingLayerExt for obilayeredmap::Layer in iter.rs, dispatching to each variant's existing impl<D: LayerData> SiblingLayerExt for TypedLayer<D>.

Full workspace suite green (0 failed) after, including all 27 obikphylo::siblings tests.

Still not built: (2) — KmerPartition (singular, one partition's open Vec<Layer>) and a multi-partition cache in obikpartitionner to replace obikphylo::siblings::cache::PartitionCache and obikindex::query_layer's still-separate QueryLayer (which still independently bundles MPHF+matrix, 2-way not using Layer at all). Both remaining consumers now sit one Layer::open call away from unifying onto (2) once it exists.

(1b) done (2026-08-20): Layer::Empty — the first non-ready-to-read state

First step toward Layer representing a layer's whole life, not just the open-for-reading end of it (see "Definitions" above: KmerPartition will hold Vec<Layer> regardless of each layer's state, states in between included). Added one variant:

pub enum Layer {
    Empty { dir: PathBuf },
    Count(TypedLayer<PersistentCompactIntMatrix>),
    Presence(TypedLayer<PersistentBitMatrix>),
}

Layer::create(dir) makes the directory and returns Empty { dir } — nothing else; no MPHF/unitigs/evidence construction yet (that's the deferred next step: build_mphf()/build_unitigs()/build_evidence() methods to progress Empty → eventually Count/Presence). Empty carries path accessors so builder code has one place to get mphf_path()/unitigs_path()/evidence_path()/fingerprint_path()/ counts_dir()/presence_dir() from, instead of redeclaring the mphf.bin/unitigs.bin/… filenames at each write site — reusing the constants layer.rs/mphf_layer.rs already own (COUNTS_DIR/ PRESENCE_DIR widened from private to pub(crate), file-name constants already were).

Every read method (content/evidence_kind/n/find_slot/ index_batch/n_cols/fill_sub_matrix_carries) panics on Empty with a one-line message naming the method — confirmed as the right behaviour: calling any of them on an Empty layer means the caller assumed a layer was ready when it wasn't, an implementation error to surface loudly, not a case to design around (Option/Result would let it silently propagate instead of failing at the actual mistake). Same panic added to obikphylo::siblings::iter.rs's impl SiblingLayerExt for obilayeredmap::Layer (4 methods), the one other place that exhaustively matched Layer's variants.

Full workspace suite green (cargo check --workspace --all-targets then cargo test --workspace, exit code 0) after.

Still deferred, per explicit instruction: build_mphf()/ build_unitigs()/build_evidence() to progress Empty further, and (2) — KmerPartition itself — unchanged from above (see "(2a) done" below, added next).

(2a) done (2026-08-20): obikpartition crate + KmerPartition

Built exactly the shape "Definitions" (top of file) specifies, nothing more — deliberately scoped down from the full "Direction agreed" plan below: only steps 1–2 (open/n_layers/layer/layers/find), not 3–4 (migrating PartitionCache/QueryLayer onto it), per explicit instruction to implement KmerPartition first and decide the wiring ("comment on branche tout ça dans la construction") separately, later.

pub struct KmerPartition {
    layers: Vec<obilayeredmap::Layer>,
}

impl KmerPartition {
    pub fn open(index_dir: &Path, mode: &IndexMode, n_layers: usize, with_counts: bool) -> OLMResult<Self>;
    pub fn n_layers(&self) -> usize;
    pub fn layer(&self, i: usize) -> &Layer;
    pub fn layers(&self) -> &[Layer];
    pub fn find(&self, kmer: CanonicalKmer) -> Option<usize>;
}

open takes index_dir/mode/n_layers/with_counts as plain arguments — no reach-back into KmerIndex (would need obikpartition → obikindex, the wrong direction) — and builds each layer's path via obilayeredmap::layer_dir(index_dir, l), the same shared naming primitive KmerIndex::layer_dir itself delegates to, not a second copy of the layer_N convention. find mirrors PartitionCache::find's semantics (first layer that carries the kmer wins) but doesn't yet cover find_presence_batch/find_presence_batch_fast — those exist only to serve PartitionCache, so they're part of the (2b) migration, not this step; building them now against the current sibling-specific tuple shape (CanonicalKmer, usize, u8, u8) would either bake phylo vocabulary (family_idx, base) into obikpartition or require deciding a generic payload shape — a real design fork, deferred to when (2b) is actually tackled rather than guessed at here.

Crate deps: obikseq, obilayeredmap only (dev-deps add obiskio, obicompactvec, tempfile for tests) — matches the "Definitions" constraint (obikpartition depends on obilayeredmap and below, never obikindex/obikpartitionner/obikphylo). Registered as a new workspace member (src/Cargo.toml). 3 new tests (open_reads_every_layer_in_order, find_reports_the_first_layer_that_carries_the_kmer, find_returns_none_for_an_absent_kmer). Full workspace suite green (cargo check --workspace --all-targets then cargo test --workspace) after.

Still not done: (2b) — migrating obikphylo::siblings::cache:: PartitionCache (currently Vec<Vec<Layer>>) and obikindex::query_layer::QueryLayer (currently uncached, bypasses Layer entirely) onto KmerPartition/Vec<KmerPartition>; deciding whether that collection lives in obikpartition or obikindex; deciding the batch-lookup surface's exact shape (generic payload vs. as-is sibling tuple moved in wholesale); scan_layer_families's still-independent PartitionMeta::load (see "Remaining instance…" below) — all explicitly 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_spectrummark_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

Reading a layer's data (MPHF + matrix) is not free: MphfLayer::open mmaps mphf.bin plus (evidence.bin/fingerprint.bin + unitigs.bin), and the matrix side mmaps matrix.pbmx/matrix.pcmx (or one file per genome column if not yet packed). Any code path that reopens a layer per lookup instead of once per run pays this cost repeatedly.

obikphylo::siblings::cache::PartitionCache was built to avoid exactly this for build_sibling_annex/sibling_annex_stats: those commands probe many partitions, once per source layer, over the whole run. Profiling a real run showed wall-clock time dominated by repeated open()/mmap syscalls, not computation — parallelising the naive per-lookup opens spread the cost across cores without reducing it. PartitionCache::build opens every partition's every layer once, up front, in parallel, and keeps the handles alive for the run.

Three independent implementations of the same bundle (historical — (1) fixed this)

As of 2026-08-20 this table describes the pre-(1) state. Mat no longer exists (deleted when obilayeredmap::Layer replaced it — see "(1) done" above); Layer<D> in the table below is what's now called TypedLayer<D>. QueryLayer is unaffected and still stands as described — still uncached, still not using Layer at all — which is exactly what (2) needs to fix. Kept for the original motivation, not as current fact:

Searching the codebase for "who bundles MPHF + matrix, with per-layer format auto-detection" turned up three unrelated implementations:

lives in scope cached?
Layer<D> (now TypedLayer<D>) obilayeredmap one layer, D fixed at compile time held alive by whoever owns the Layer, no policy of its own
Mat (now deleted; superseded by obilayeredmap::Layer) obikphylo::siblings::cache one layer, format resolved per instance from an enum of 3 Layer<D> variants yes, via PartitionCache
QueryLayer (unchanged, still current) obikindex::query_layer (moved crates since this was written — see "Major restructuring") one layer, (MphfLayer, PersistentBitMatrix|PersistentCompactIntMatrix) pair, bypasses TypedLayer<D>/Layer entirely no — opened fresh inside query_partition_with on every call

query_partition_with is obikmer query's normal query path — the one most exposed to repeated cross-partition lookups — and it is still the one with no cache at all. obikphylo built a cache first only because sibling-annex construction hits the cost hardest, not because the need is sibling-specific.

The gap in obilayeredmap's existing cache (historical — (1) fixed this)

obilayeredmap::LayeredMap<D> already caches correctly at the granularity of one partition: open(root) opens every layer once, keeps Vec<TypedLayer<D>> alive for the LayeredMap's lifetime. But it is monomorphic — every layer in the Vec must share the same concrete D. In practice this was false: layers in the same partition are packed independently over time (pack --sparse converts one layer's presence matrix at a time). This motivated (1) — obilayeredmap::Layer, done — but note the specific PersistentSparseBitMatrix-mixing scenario described here turned out to be moot: PersistentBitMatrix itself absorbed sparse storage as a 4th internal variant before (1) was built (see "One bug found … one earlier claim retracted" below), so the only heterogeneity Layer actually needs to represent is Count vs. Presence, not dense-vs-sparse presence. LayeredMap<D> itself is unaffected by any of this — it's still monomorphic, still not used by Layer/KmerPartition (which bypass it entirely, opening each TypedLayer<D> directly, the same way Mat did).

Resource cost: mmap does not hold a file descriptor

Before deciding how many layers/partitions a cache may hold open simultaneously, the binding constraint needs to be identified correctly.

Confirmed against upstream documentation, not inferred from behaviour:

"After the mmap() call has returned, the file descriptor, fd, can be closed immediately without invalidating the mapping." — mmap(2), man7.org

"The close(2) function does not unmap pages" — mmap(2), Apple Developer

"A file backed Mmap ... will remain valid even after the File is dropped. ... the Mmap handle is completely independent of the File used to create it." — memmap2::Mmap, docs.rs

Every read-only mmap in this codebase already follows this: Mmap::map(&File::open(path)?)? — the File is a temporary, dropped (fd closed) immediately after the mapping is established; every persistent struct (PersistentBitVec, PersistentCompactIntVec, PackedBitMatrix, Evidence, FingerprintVec, ...) stores only the Mmap, never the File. So a cache built on these types does not consume the process's open-file-descriptor budget (ulimit -n, notoriously low by default on macOS) proportionally to how many mmapped files it holds.

It does consume a different resource — the process's virtual-memory mapping table (one entry per active mmap() region). Linux exposes this as vm.max_map_count (default 65530). No documented macOS equivalent (fixed numeric ceiling) was found; the constraint there appears to be virtual address space rather than an explicit mapping counter, but this is not sourced and should not be assumed. This is the resource actually worth measuring before deciding on cache size, not fd count — and it is why packing (matrix.pbmx/matrix.pcmx, one mmap for all columns) matters independently of any caching decision: an unpacked Columnar matrix opens one mmap per genome column, multiplying the mapping count a cache would have to hold by n_genomes.

Layering: who owns what (superseded — see Definitions above)

This section used to argue nobody owned "the collection of partitions." That's resolved: KmerIndex (obikindex) owns it now, directly (see "Major restructuring" below). What's still genuinely unowned is one partition's open layersKmerPartition, in the not-yet-created obikpartition — see "Definitions" at the top of this file for the current, authoritative answer. Left here only so old links/references to this heading don't 404; don't read this section for current facts.

Direction agreed, not yet implemented

Only (2) remains — (1) shipped as obilayeredmap::Layer (see "(1) done" above). Concretely, in order:

  1. Create the obikpartition crate (obikindex → obikpartition → obilayeredmap, no other edges — see "Definitions" above for the exact constraint and why).
  2. KmerPartition { layers: Vec<obilayeredmap::Layer> }open, n_layers, layer(i), find, plus whatever batch-lookup surface obikphylo::siblings::cache::PartitionCache currently needs (find_presence_batch/find_presence_batch_fast; fast_mode is sibling-specific bookkeeping and should probably stay in obikphylo, wrapping a KmerPartition/Vec<KmerPartition> rather than living inside it — same "generic vs. domain-specific" split iter_minorants_batch already went through for Layer in (1)).
  3. Migrate obikphylo::siblings::cache::PartitionCache to hold Vec<KmerPartition> instead of Vec<Vec<Layer>>.
  4. Migrate obikindex::query_layer::QueryLayer/query_partition_with to use KmerPartition too, closing the "no cache at all" gap on obikmer's normal query path (see "Three independent implementations," historical, above).

Open before implementing: exact API shape of KmerPartition (propose, confirm before coding — non-trivial), and whether the multi-partition Vec<KmerPartition> needs an eviction policy or can simply hold every partition open for the process lifetime (revisit once the VM-mapping-count question above has a real number behind it for this codebase's scale — still not measured).

Preparatory work done (2026-08-20)

Groundwork for (1)/(2), landed ahead of the design itself. Note: at the time this was written, the collection type these bullets describe was named KmerPartition (singular) in this doc; it was renamed KmerPartitions (plural) shortly after, then deleted entirely and merged into KmerIndex (see "Major restructuring" above). The bullets below are edited to say KmerPartitions throughout, to not collide with the unrelated, brand-new singular KmerPartition defined at the top of this file — the accessors described here live on KmerIndex today, not on any type called KmerPartition.

  • KmerPartitions (obikpartitionner, at the time) gained partition_dir/index_dir/layer_dir as the single source of truth for a partition's on-disk layout, replacing per-module duplicated const INDEX_SUBDIR: &str = "index" (7 copies) and ad hoc path joins — including one found duplicated inside the struct itself (ensure_writer rebuilt part_dir's own logic by hand).
  • Same struct gained partition_meta/n_layers/index_mode, wrapping obilayeredmap::meta::PartitionMeta::load (via the existing common::load_meta, which also recovers indexes built before meta.json existed). Before this, obikphylo and obikindex imported obilayeredmap::meta::PartitionMeta directly and called ::load() themselves at 21 call sites, each redoing its own error-mapping — every one of those crates knew the on-disk metadata format instead of going through an interface. Fixed everywhere except one remaining spot (below). Caught as a side effect: dump_layer.rs/query_layer.rs had been calling PartitionMeta::load directly, bypassing load_meta entirely — they never got the missing-meta.json recovery the other callers did.
  • Layer introspection API discussed but not yet implemented — three axes, deliberately kept separate after an initial draft conflated them:
    • LayerContent { Count, Presence } — what the layer stores; a const on LayerData (compile-time, zero-cost), not a runtime field.
    • StorageKind { Implicit, Columnar, Packed, Sparse } — how it's stored; only meaningful for D that actually carry data (Layer<()> has neither this nor LayerContent — it's a write-time-only state, never a queryable content: once a layer is closed, "no matrix file" reads back as Presence/Implicit via PersistentBitMatrix::open's own fallback, not as some third "empty" content).
    • EvidenceKind { Exact, Approx, Hybrid } — from MphfLayer's own already-in-memory LayerEvidence discriminant.
    • Not all (LayerContent, StorageKind) pairs are legal: Count never has Implicit or Sparse.

Implemented (2026-08-20). LayerContent/StorageKind/EvidenceKind now exist, each with two forms:

  • A runtime accessor on an already-open value (Layer<D>::content()/ storage_kind()/evidence_kind(), PersistentBitMatrix::storage_kind(), PersistentCompactIntMatrix::storage_kind(), MphfLayer::evidence_kind()) — reads a discriminant already in memory, zero disk access.
  • A lightweight detect()/detect_storage() disk probe that mirrors the corresponding open()'s own priority order by hand (file-existence checks only, no mmap) — usable before committing to a D, unlike the runtime accessors. Exposed per-layer on LayeredMap<D> as detect_layer_content/detect_layer_storage/detect_layer_evidence (work regardless of D, since they only use self.root + the layer index).

StorageKind lives in obicompactvec (owner of PersistentBitMatrix/ PersistentCompactIntMatrix); LayerContent/EvidenceKind live in obilayeredmap. HasLayerContent/HasStorageKind gate Layer<()> out of content()/storage_kind() (no matrix, nothing to report), matching the "empty is transitional" conclusion above. 42 new tests across obilayeredmap's tests/layer.rs and tests/map.rs; full workspace suite green (0 failed) after.

Not done: these detect() probes don't yet replace Mat::open's or QueryLayer::open's own hand-rolled equivalents (still duplicated content/ storage decisions, now a third copy of the same logic to keep in sync) — that consolidation is (1)/(2)'s job, not this prep step's.

One bug found while reading around this (signalled, not fixed); one earlier claim retracted

  • obicompactvec::bitmatrix::sparse.rs's module doc says "Not used by any production code path yet" — false since obikmer pack --sparse (cmd/pack/mod.rs) is wired to pack_sparse_bit_matrix and Mat::open already reads the result back in the sibling-annex path. Stale comment, not corrected.
  • Retracted (2026-08-20): an earlier pass through this doc claimed obikpartition::query_layer::QueryLayer::open had no sparse-format detection and would silently corrupt reads on a pack --sparsed layer. False — PersistentBitMatrix (obicompactvec::bitmatrix::persistent) is a 4-way enum (Columnar/Packed/Sparse/Implicit), not 3-way as first read; its open() already detects Sparse via presence/sparse_meta.json, and every method on the type (row, fill_row, nonzero_iter, …) already dispatches all 4 arms. QueryLayer::open's PersistentBitMatrix::open(layer_dir) call was never the bug. Root cause of the false claim: a grep -n "Implicit\|Columnar\|Packed" used to read the enum definition silently skipped the Sparse(...) line because it matched none of those three words — a self-inflicted blind spot from a filtered read, not a fact about the code. Lesson: for a pub enum whose variant list matters, read the definition unfiltered, don't grep for the variant names you expect to find.
  • One real consequence of that same correction, fixed (2026-08-20): obikphylo::siblings::cache::Mat::SparsePresence(Layer< PersistentSparseBitMatrix>) was redundant — Mat::Presence(Layer< PersistentBitMatrix>) alone already handles sparse layers transparently, since PersistentBitMatrix absorbs Sparse internally. Removed the variant, the presence/is_multi.prsb probe in Mat::open (now just opens Layer::<PersistentBitMatrix> unconditionally for the non-count case — sparse-vs-dense is PersistentBitMatrix::open's own concern), and every now-single-armed match in find_slot/index_batch/ iter_minorants_batch/n_cols/fill_sub_matrix_carries. Full workspace test suite green after, including the 27 obikphylo::siblings tests that exercise pack_matrices(true)/sparse through Mat.

Remaining instance of the PartitionMeta-encapsulation problem

obikphylo::siblings::family_scan::scan_layer_families still re-derives index_dir from layer_dir.parent() and calls PartitionMeta::load itself, purely to get .mode for obilayeredmap::Layer::open (was Mat::open, same gap, survived the MatLayer swap in (1) unchanged). Fixing it the way the 21 other call sites were fixed needs more than a 1:1 swap: scan_layer_families only receives a bare layer_dir: &Path, not a (partition, part, layer) triple, and its single upstream source of layer paths, sibling_layer_dirs, returns a flat Vec<PathBuf> with the partition/layer indices already discarded. Fixing it properly means either having sibling_layer_dirs return (PathBuf, IndexMode) (or (part, layer)) pairs, or threading &KmerIndex + indices through instead of paths (not &KmerPartition — that type doesn't exist yet, and once it does it still won't know IndexMode, which lives on KmerIndex/ PartitionMeta) — and touching every one of scan_layer_families's 8 callers (distance.rs, alignment.rs, cardinality.rs, entropy.rs ×2, sankoff_bundle.rs ×2, stats.rs). Left alone this round; worth doing as part of the same pass that builds KmerPartition, since those callers are exactly the sibling-annex consumers it's meant to serve.