Introduce session persistence and artifact caching
This change implements a robust session management system allowing users to persist distance calculation samples and parameters across invocations. It includes concurrency-safe directory locking, deterministic parameter encoding, artifact integrity checks, and caching mechanisms to skip resampling when valid data is available.
This commit is contained in:
Generated
+146
@@ -213,6 +213,29 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29"
|
||||
dependencies = [
|
||||
"bytecheck_derive",
|
||||
"ptr_meta",
|
||||
"rancor",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecheck_derive"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@@ -1236,6 +1259,26 @@ dependencies = [
|
||||
"windows 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "munge"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c"
|
||||
dependencies = [
|
||||
"munge_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "munge_macro"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanorand"
|
||||
version = "0.6.1"
|
||||
@@ -1499,6 +1542,7 @@ dependencies = [
|
||||
"obikrope",
|
||||
"obikselect",
|
||||
"obikseq",
|
||||
"obiksession",
|
||||
"obikstats",
|
||||
"obipipeline",
|
||||
"obiread",
|
||||
@@ -1541,6 +1585,7 @@ dependencies = [
|
||||
"obikindex",
|
||||
"obikindexer",
|
||||
"obikseq",
|
||||
"obiksession",
|
||||
"obipipeline",
|
||||
"obiread",
|
||||
"obiskbuilder",
|
||||
@@ -1549,6 +1594,7 @@ dependencies = [
|
||||
"petgraph",
|
||||
"rand 0.10.2",
|
||||
"rayon",
|
||||
"rkyv",
|
||||
"speedytree",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
@@ -1615,6 +1661,17 @@ dependencies = [
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "obiksession"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"memmap2",
|
||||
"obisys",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "obikstats"
|
||||
version = "0.1.0"
|
||||
@@ -1963,6 +2020,26 @@ dependencies = [
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0"
|
||||
dependencies = [
|
||||
"ptr_meta_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta_derive"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -1990,6 +2067,15 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rancor"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572"
|
||||
dependencies = [
|
||||
"ptr_meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
@@ -2151,6 +2237,15 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2165,6 +2260,36 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.8.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399"
|
||||
dependencies = [
|
||||
"bytecheck",
|
||||
"bytes",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"munge",
|
||||
"ptr_meta",
|
||||
"rancor",
|
||||
"rend",
|
||||
"rkyv_derive",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv_derive"
|
||||
version = "0.8.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2359,6 +2484,12 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -2587,6 +2718,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["obikseq", "obiread", "obiskbuilder", "obifastwrite", "obikmer","obikrope","obipipeline", "obiskio","obidebruinj", "obicompactvec", "obisys", "obikindex", "obikindexer", "obikquery", "obikdump", "obikfilter", "obikselect", "obikrebuild", "obikmerge", "obikstats", "obikidxcache", "obitaxonomy", "obikentropy", "obikphylo", "obikalgorithm"]
|
||||
members = ["obikseq", "obiread", "obiskbuilder", "obifastwrite", "obikmer","obikrope","obipipeline", "obiskio","obidebruinj", "obicompactvec", "obisys", "obikindex", "obikindexer", "obikquery", "obikdump", "obikfilter", "obikselect", "obikrebuild", "obikmerge", "obikstats", "obikidxcache", "obitaxonomy", "obikentropy", "obikphylo", "obikalgorithm", "obiksession"]
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
@@ -24,6 +24,7 @@ obikstats = { path = "../obikstats" }
|
||||
obikquery = { path = "../obikquery" }
|
||||
obikidxcache = { path = "../obikidxcache" }
|
||||
obikphylo = { path = "../obikphylo" }
|
||||
obiksession = { path = "../obiksession" }
|
||||
obikrope = { path = "../obikrope" }
|
||||
obifastwrite = { path = "../obifastwrite" }
|
||||
obiskbuilder = { path = "../obiskbuilder" }
|
||||
|
||||
@@ -224,6 +224,27 @@ pub struct PhyloArgs {
|
||||
#[arg(long)]
|
||||
pub entropy_sd: Option<f64>,
|
||||
|
||||
/// Persist the `snp-*` `--distance` sample (the pairwise substitution
|
||||
/// tally) in `DIR` across separate `obikmer phylo` invocations: a later
|
||||
/// run against the same index with the exact same
|
||||
/// `--subsample`/`--free-loss`/`--no-ambiguity`/`--exclude-genome`/
|
||||
/// `--min-shared-family`/`--entropy`/`--entropy-sd` restores it instead
|
||||
/// of resampling. `DIR` is created if it doesn't exist. If it already
|
||||
/// holds a sample built under *different* selection parameters, this
|
||||
/// run is rejected with an error listing what changed, unless
|
||||
/// `--session-force` is also given (which discards the old sample and
|
||||
/// starts fresh under this run's parameters). Only covers a `snp-*`
|
||||
/// `--distance` today — `--sankoff`/`--tnt`/`--phyg`/`--iqtree` don't
|
||||
/// read or write a `--session` yet.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub session: Option<PathBuf>,
|
||||
|
||||
/// With `--session DIR`: overwrite its saved selection parameters (and
|
||||
/// discard its cached sample) instead of erroring out when this run's
|
||||
/// parameters don't match. No effect without `--session`.
|
||||
#[arg(long, requires = "session")]
|
||||
pub session_force: bool,
|
||||
|
||||
/// Calibrate a 16-state Sankoff cost matrix (and its matching
|
||||
/// pseudo-alignment) from an already-built sibling annex — requires
|
||||
/// `--subsample <N>`, and shares `--free-loss`/`--no-ambiguity`/
|
||||
|
||||
@@ -24,6 +24,43 @@ use phylip::write_phylip_relaxed;
|
||||
use sankoff::{write_sankoff_alignment_fasta, write_sankoff_matrix_csv, write_sankoff_params};
|
||||
use tnt::write_sankoff_tnt;
|
||||
|
||||
/// Deterministic byte encoding of the `snp-*` `--distance` selection
|
||||
/// parameters, for `obiksession::Session::open`'s params-conflict check.
|
||||
/// `obiksession` treats this as an opaque blob (see its own crate docs) —
|
||||
/// only this module needs to know the layout, and only well enough to
|
||||
/// produce/compare it, never to pretty-print a per-field diff (v1 keeps
|
||||
/// the conflict message generic rather than decoding both sides field by
|
||||
/// field — see its use in `run` below).
|
||||
fn session_params_blob(
|
||||
n: Option<usize>,
|
||||
free_loss: bool,
|
||||
no_ambiguity: bool,
|
||||
excluded: &[bool],
|
||||
entropy_bias: Option<EntropyBias>,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
match n {
|
||||
Some(v) => {
|
||||
buf.push(1);
|
||||
buf.extend_from_slice(&(v as u64).to_le_bytes());
|
||||
}
|
||||
None => buf.push(0),
|
||||
}
|
||||
buf.push(free_loss as u8);
|
||||
buf.push(no_ambiguity as u8);
|
||||
buf.extend_from_slice(&(excluded.len() as u64).to_le_bytes());
|
||||
buf.extend(excluded.iter().map(|&b| b as u8));
|
||||
match entropy_bias {
|
||||
Some(EntropyBias { mu, sigma }) => {
|
||||
buf.push(1);
|
||||
buf.extend_from_slice(&mu.to_le_bytes());
|
||||
buf.extend_from_slice(&sigma.to_le_bytes());
|
||||
}
|
||||
None => buf.push(0),
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
pub use args::PhyloArgs;
|
||||
|
||||
pub fn run(args: PhyloArgs) {
|
||||
@@ -236,6 +273,47 @@ pub fn run(args: PhyloArgs) {
|
||||
None
|
||||
};
|
||||
|
||||
// ── `--session`: persist the snp-* sample across separate invocations ──
|
||||
// Only covers `snp-*` `--distance` today (see `--session`'s own docs);
|
||||
// `None` when `--session` isn't given, matching today's always-resample
|
||||
// default exactly.
|
||||
let session = args.session.as_ref().map(|dir| {
|
||||
let params = session_params_blob(
|
||||
args.subsample,
|
||||
args.free_loss,
|
||||
args.no_ambiguity,
|
||||
&snp_exclude_mask,
|
||||
entropy_bias,
|
||||
);
|
||||
match obiksession::Session::open(dir, ¶ms, args.session_force) {
|
||||
Ok(Ok(session)) => {
|
||||
match session.outcome() {
|
||||
obiksession::SessionOutcome::Created => {
|
||||
info!("--session {}: starting fresh", dir.display());
|
||||
}
|
||||
obiksession::SessionOutcome::Reused => {
|
||||
info!("--session {}: found, will reuse its cached sample if present", dir.display());
|
||||
}
|
||||
}
|
||||
session
|
||||
}
|
||||
Ok(Err(_conflict)) => {
|
||||
eprintln!(
|
||||
"error: --session {} was built with different selection parameters \
|
||||
(--subsample/--free-loss/--no-ambiguity/--exclude-genome/--min-shared-family/\
|
||||
--entropy/--entropy-sd) than this run — pass --session-force to discard its \
|
||||
cached sample and start fresh, or point --session at a different directory",
|
||||
dir.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error opening --session {}: {e}", dir.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── SNP pseudo-alignment (`--pseudo-alignment`) ─────────────────────────────
|
||||
if args.pseudo_alignment {
|
||||
let Some(subsample_n) = args.subsample else {
|
||||
@@ -389,6 +467,7 @@ pub fn run(args: PhyloArgs) {
|
||||
&snp_exclude_mask,
|
||||
entropy_bias,
|
||||
gamma_shape,
|
||||
session.as_ref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ obicompactvec = { path = "../obicompactvec" }
|
||||
obikidxcache = { path = "../obikidxcache" }
|
||||
obiskbuilder = { path = "../obiskbuilder" }
|
||||
obipipeline = { path = "../obipipeline" }
|
||||
obiksession = { path = "../obiksession" }
|
||||
kodama = "0.3.0"
|
||||
memmap2 = "0.9"
|
||||
ndarray = "0.17"
|
||||
petgraph = "0.6.4"
|
||||
rand = "0.10"
|
||||
rayon = "1"
|
||||
rkyv = "0.8.18"
|
||||
speedytree = "0.1"
|
||||
tracing = "0.1.44"
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! machines), not something to optimise away.
|
||||
|
||||
use ndarray::Array2;
|
||||
use rkyv::{Archive, Deserialize, Serialize};
|
||||
|
||||
use super::subsample::SurvivingFamily;
|
||||
|
||||
@@ -30,7 +31,12 @@ use super::subsample::SurvivingFamily;
|
||||
/// [`BasePairTally`] the latter (they are *not* the same population — see
|
||||
/// `BasePairTally::same`'s own docs). `cardinality` is restricted to
|
||||
/// variable families only, matching [`CardinalityTally`]'s own scope.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
///
|
||||
/// `Archive`/`Serialize`/`Deserialize`: part of [`PairwiseTally`]'s own
|
||||
/// `--session` persistence (see that struct's docs) — `rkyv` needs every
|
||||
/// field type to derive these too, hence deriving here on an otherwise
|
||||
/// purely internal struct.
|
||||
#[derive(Default, Clone, Copy, Archive, Serialize, Deserialize)]
|
||||
struct PairStats {
|
||||
subst: [[u64; 4]; 4],
|
||||
same_all: [u64; 4],
|
||||
@@ -40,6 +46,13 @@ struct PairStats {
|
||||
|
||||
/// See the module docs. `pub(crate)`: consumed by
|
||||
/// `algorithms::sankoff`'s orchestration, not yet exposed past this crate.
|
||||
///
|
||||
/// `Archive`/`Serialize`/`Deserialize` (`rkyv`): lets `--session` dump/
|
||||
/// restore a tally across separate CLI invocations as opaque bytes handed
|
||||
/// to `obiksession::Session::store`/`restore` — `obiksession` itself knows
|
||||
/// nothing about this type, it only stores/checksums/mmaps the bytes this
|
||||
/// crate produces (see `obiksession`'s own crate docs on that split).
|
||||
#[derive(Archive, Serialize, Deserialize)]
|
||||
pub(crate) struct PairwiseTally {
|
||||
n_genomes: usize,
|
||||
/// Upper triangle only (`i < j`), flat-indexed via [`Self::flat_index`].
|
||||
@@ -300,7 +313,7 @@ pub struct CardinalityTally {
|
||||
/// applied here either, matching `reduce_pairwise`'s own raw per-pair fold
|
||||
/// (`PairStats` accumulates over every genome pair unconditionally;
|
||||
/// exclusion is a filter applied only in derived, post-hoc views).
|
||||
#[derive(Default, Clone)]
|
||||
#[derive(Default, Clone, Archive, Serialize, Deserialize)]
|
||||
pub(crate) struct PartitionDispersion {
|
||||
/// One entry per partition id seen so far (grows lazily — partitions
|
||||
/// are visited in increasing order in practice, but nothing here
|
||||
|
||||
@@ -23,12 +23,51 @@
|
||||
|
||||
use ndarray::Array2;
|
||||
use obikidxcache::index_cache::IndexCache;
|
||||
use obikindex::OKIResult;
|
||||
use obikindex::{OKIError, OKIResult};
|
||||
use obiksession::Session;
|
||||
|
||||
use super::pairwise::{PairwiseTally, PartitionDispersion};
|
||||
use super::sibling_family_size_histogram;
|
||||
use super::subsample::{EntropyBias, sample_index};
|
||||
|
||||
const TALLY_ARTIFACT: &str = "pairwise_tally";
|
||||
const DISPERSION_ARTIFACT: &str = "partition_dispersion";
|
||||
|
||||
/// Restore a `PairwiseTally`/`PartitionDispersion` pair from `session`, if
|
||||
/// both artifacts are cached there — `None` on any miss (not cached, I/O
|
||||
/// error, or a deserialization failure), each treated identically:
|
||||
/// something's missing or unusable, fall back to recomputing rather than
|
||||
/// surfacing a hard error over what's meant to be a transparent cache.
|
||||
///
|
||||
/// Full owned deserialization (`rkyv::from_bytes`), not zero-copy
|
||||
/// (`rkyv::access`) — simpler to integrate with `PairwiseTally`'s existing
|
||||
/// query methods, which all take `&self` by value semantics, not an
|
||||
/// `Archived<PairwiseTally>`. Still skips the expensive part (`sample_index`
|
||||
/// re-scanning the sibling annex); the `O(n²)` deserialize of an already-
|
||||
/// in-memory-sized byte buffer is comparatively cheap. Making this
|
||||
/// genuinely zero-copy later would mean every `PairwiseTally` accessor
|
||||
/// (`categories`, `base_freq`, ...) working generically over
|
||||
/// `Archived<PairwiseTally>` too — a bigger, separate change.
|
||||
fn restore_tally(session: &Session) -> Option<(PairwiseTally, PartitionDispersion)> {
|
||||
let tally_bytes = session.restore(TALLY_ARTIFACT).ok().flatten()?;
|
||||
let dispersion_bytes = session.restore(DISPERSION_ARTIFACT).ok().flatten()?;
|
||||
let tally = rkyv::from_bytes::<PairwiseTally, rkyv::rancor::Error>(&tally_bytes[..]).ok()?;
|
||||
let dispersion =
|
||||
rkyv::from_bytes::<PartitionDispersion, rkyv::rancor::Error>(&dispersion_bytes[..]).ok()?;
|
||||
tracing::info!("--session: restored cached SNP tally — skipping resampling");
|
||||
Some((tally, dispersion))
|
||||
}
|
||||
|
||||
fn store_tally(session: &Session, tally: &PairwiseTally, dispersion: &PartitionDispersion) -> OKIResult<()> {
|
||||
let tally_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(tally)
|
||||
.map_err(|e| OKIError::Io(std::io::Error::other(e.to_string())))?;
|
||||
session.store(TALLY_ARTIFACT, &tally_bytes).map_err(OKIError::Io)?;
|
||||
let dispersion_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(dispersion)
|
||||
.map_err(|e| OKIError::Io(std::io::Error::other(e.to_string())))?;
|
||||
session.store(DISPERSION_ARTIFACT, &dispersion_bytes).map_err(OKIError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One `snp-*` `--distance` value. `pub`: part of
|
||||
/// [`crate::siblings::extensions::SiblingExt::snp_distance`]'s public
|
||||
/// signature.
|
||||
@@ -231,6 +270,17 @@ fn formula(kind: SnpDistanceKind) -> fn(&PairwiseTally, usize, usize, Option<f64
|
||||
/// estimation finds no measurable signal (see its own docs for when), the
|
||||
/// correction is silently left off rather than applied with a fabricated
|
||||
/// value — logged either way.
|
||||
///
|
||||
/// `session`: `--session`, `None` means no persistence (today's default
|
||||
/// behavior — always resample). `Some(session)` is expected to already be
|
||||
/// open under the *exact* selection parameters this call is about to use
|
||||
/// (`n`/`free_loss`/`no_ambiguity`/`excluded`/`entropy_bias`) — checking
|
||||
/// that is the caller's job (`obiksession::Session::open`'s own
|
||||
/// params-conflict mechanism), not re-validated here. If a cached tally is
|
||||
/// found it's restored and `sample_index` is skipped entirely; otherwise
|
||||
/// the fresh tally is stored back into the session before this returns, so
|
||||
/// a later call under the same session/params restores instead of
|
||||
/// resampling.
|
||||
pub(crate) fn snp_distance(
|
||||
cache: &IndexCache,
|
||||
kind: SnpDistanceKind,
|
||||
@@ -240,6 +290,7 @@ pub(crate) fn snp_distance(
|
||||
excluded: &[bool],
|
||||
entropy_bias: Option<EntropyBias>,
|
||||
gamma_shape: GammaShape,
|
||||
session: Option<&Session>,
|
||||
) -> OKIResult<Array2<f64>> {
|
||||
// Fail fast, before paying for `sample_index`, not just inside
|
||||
// `distance_matrix` (which runs after sampling either way — the right
|
||||
@@ -251,33 +302,44 @@ pub(crate) fn snp_distance(
|
||||
));
|
||||
}
|
||||
|
||||
let n_genomes = cache.meta().genomes().len();
|
||||
let mut tally = PairwiseTally::new(n_genomes);
|
||||
let mut dispersion = PartitionDispersion::default();
|
||||
|
||||
let (target, entropy_bias) = match n {
|
||||
Some(target) => (target, entropy_bias),
|
||||
let cached = session.and_then(restore_tally);
|
||||
let (tally, dispersion) = match cached {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
let counts = sibling_family_size_histogram(cache)?;
|
||||
let total_eligible = (counts[1] + counts[2] + counts[3]) as usize;
|
||||
(total_eligible, None)
|
||||
let n_genomes = cache.meta().genomes().len();
|
||||
let mut tally = PairwiseTally::new(n_genomes);
|
||||
let mut dispersion = PartitionDispersion::default();
|
||||
|
||||
let (target, entropy_bias) = match n {
|
||||
Some(target) => (target, entropy_bias),
|
||||
None => {
|
||||
let counts = sibling_family_size_histogram(cache)?;
|
||||
let total_eligible = (counts[1] + counts[2] + counts[3]) as usize;
|
||||
(total_eligible, None)
|
||||
}
|
||||
};
|
||||
|
||||
if target > 0 {
|
||||
sample_index(
|
||||
cache,
|
||||
target,
|
||||
free_loss,
|
||||
no_ambiguity,
|
||||
excluded,
|
||||
entropy_bias,
|
||||
|partition, _layer, survivors| {
|
||||
super::pairwise::reduce_pairwise(&survivors, partition, &mut tally, &mut dispersion);
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(session) = session {
|
||||
store_tally(session, &tally, &dispersion)?;
|
||||
}
|
||||
(tally, dispersion)
|
||||
}
|
||||
};
|
||||
|
||||
if target > 0 {
|
||||
sample_index(
|
||||
cache,
|
||||
target,
|
||||
free_loss,
|
||||
no_ambiguity,
|
||||
excluded,
|
||||
entropy_bias,
|
||||
|partition, _layer, survivors| {
|
||||
super::pairwise::reduce_pairwise(&survivors, partition, &mut tally, &mut dispersion);
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
distance_matrix(&tally, &dispersion, kind, gamma_shape)
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,13 @@ pub trait SiblingExt {
|
||||
/// ([`SnpDistanceKind::supports_gamma`]). `Auto` estimates `alpha` from
|
||||
/// the data itself (method-of-moments over per-partition substitution
|
||||
/// rate dispersion) rather than requiring a user-supplied value.
|
||||
///
|
||||
/// `session`: `--session`, `None` disables persistence (always
|
||||
/// resample). `Some` must already be open under these exact selection
|
||||
/// parameters (the caller's responsibility, via
|
||||
/// `obiksession::Session::open`'s params-conflict check) — restores a
|
||||
/// cached tally when present instead of resampling, and stores a
|
||||
/// freshly computed one back for next time otherwise.
|
||||
fn snp_distance(
|
||||
&self,
|
||||
kind: SnpDistanceKind,
|
||||
@@ -156,6 +163,7 @@ pub trait SiblingExt {
|
||||
excluded: &[bool],
|
||||
entropy_bias: Option<EntropyBias>,
|
||||
gamma_shape: GammaShape,
|
||||
session: Option<&obiksession::Session>,
|
||||
) -> OKIResult<Array2<f64>>;
|
||||
|
||||
/// The Family Overlap annex — number of shared *variable* families per
|
||||
@@ -335,8 +343,9 @@ impl SiblingExt for IndexCache {
|
||||
excluded: &[bool],
|
||||
entropy_bias: Option<EntropyBias>,
|
||||
gamma_shape: GammaShape,
|
||||
session: Option<&obiksession::Session>,
|
||||
) -> OKIResult<Array2<f64>> {
|
||||
snp_distance(self, kind, n, free_loss, no_ambiguity, excluded, entropy_bias, gamma_shape)
|
||||
snp_distance(self, kind, n, free_loss, no_ambiguity, excluded, entropy_bias, gamma_shape, session)
|
||||
}
|
||||
|
||||
fn family_overlap(&self) -> OKIResult<FamilyOverlap> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "obiksession"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
obisys = { path = "../obisys", default-features = false }
|
||||
memmap2 = "0.9.11"
|
||||
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Domain-agnostic session persistence: a named directory that survives
|
||||
//! across separate CLI invocations, holding an opaque parameter blob (used
|
||||
//! to detect stale/mismatched reuse) plus a checksummed cache of named
|
||||
//! binary artifacts.
|
||||
//!
|
||||
//! Deliberately knows nothing about what the parameters or artifacts
|
||||
//! *mean* — no k-mer/genome/tally concept anywhere in this crate. A
|
||||
//! caller (e.g. `obikphylo`, for its `--session` support around
|
||||
//! `PairwiseTally`/`PartitionDispersion`/`SnpAlignment`) serializes its own
|
||||
//! parameter struct and artifacts to bytes however it likes (`rkyv`,
|
||||
//! `bincode`, ...) and hands raw `&[u8]` to this crate; [`Session`] only
|
||||
//! deals in byte blobs, directory lifecycle, and locking. Same split
|
||||
//! already used elsewhere in this project between a generic mechanism
|
||||
//! crate and the domain crate that plugs into it (`obisys`/`obikindex`,
|
||||
//! `obicompactvec`/`obikindexer`).
|
||||
|
||||
mod session;
|
||||
|
||||
pub use session::{ParamsConflict, Session, SessionOutcome};
|
||||
@@ -0,0 +1,249 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use memmap2::Mmap;
|
||||
use obisys::DirLock;
|
||||
|
||||
const PARAMS_FILE: &str = "params.bin";
|
||||
const ARTIFACTS_DIR: &str = "artifacts";
|
||||
const CHECKSUM_EXT: &str = "xxh3";
|
||||
|
||||
/// Whether [`Session::open`] found a fresh (just-created, or just-reset by
|
||||
/// `force`) directory or reused one whose saved parameters matched the
|
||||
/// requested ones exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionOutcome {
|
||||
/// New directory, or an existing one reset because its saved
|
||||
/// parameters didn't match and `force: true` was passed. No cached
|
||||
/// artifact in it can be trusted — there either isn't one yet, or the
|
||||
/// ones that were there have just been deleted.
|
||||
Created,
|
||||
/// Existing directory whose saved parameters matched byte-for-byte.
|
||||
/// Every artifact already cached under it was produced under these
|
||||
/// same parameters and is safe to [`Session::restore`].
|
||||
Reused,
|
||||
}
|
||||
|
||||
/// Returned by [`Session::open`] when the directory already holds a
|
||||
/// parameter blob that doesn't match the one just requested, and `force`
|
||||
/// wasn't set. This crate has no idea what the bytes mean, so it can't
|
||||
/// produce a useful diagnostic on its own — the caller, who serialized
|
||||
/// both, is expected to deserialize `saved`/`requested` itself and report
|
||||
/// exactly which field(s) differ before deciding whether to error out,
|
||||
/// point at a different directory, or retry [`Session::open`] with
|
||||
/// `force: true`.
|
||||
#[derive(Debug)]
|
||||
pub struct ParamsConflict {
|
||||
pub saved: Vec<u8>,
|
||||
pub requested: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An open session directory: exclusively locked (via [`obisys::DirLock`])
|
||||
/// for as long as this value lives, so no other `obiksession`-using
|
||||
/// process can read a half-written artifact or reset the directory out
|
||||
/// from under this one.
|
||||
#[derive(Debug)]
|
||||
pub struct Session {
|
||||
dir: PathBuf,
|
||||
outcome: SessionOutcome,
|
||||
_lock: DirLock,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Open (or create, or reset) a session directory — see
|
||||
/// [`SessionOutcome`]/[`ParamsConflict`] for the three possible
|
||||
/// outcomes. Blocks until the directory's lock is free (see
|
||||
/// [`obisys::DirLock::acquire`]) — a second process pointed at the
|
||||
/// same session directory waits rather than racing it.
|
||||
pub fn open(dir: &Path, params: &[u8], force: bool) -> io::Result<Result<Session, ParamsConflict>> {
|
||||
let lock = DirLock::acquire(dir)?;
|
||||
let params_path = dir.join(PARAMS_FILE);
|
||||
|
||||
let saved = read_checked(¶ms_path)?;
|
||||
match saved {
|
||||
Some(saved) if saved == params => Ok(Ok(Session {
|
||||
dir: dir.to_path_buf(),
|
||||
outcome: SessionOutcome::Reused,
|
||||
_lock: lock,
|
||||
})),
|
||||
Some(saved) if !force => Ok(Err(ParamsConflict { saved, requested: params.to_vec() })),
|
||||
_ => {
|
||||
// No saved params yet, or a mismatch with `force: true`:
|
||||
// start clean. Any artifact cached under different params
|
||||
// is stale by construction — deleting the whole
|
||||
// `artifacts/` directory is simpler and safer than trying
|
||||
// to figure out which entries are still valid.
|
||||
let artifacts_dir = dir.join(ARTIFACTS_DIR);
|
||||
if artifacts_dir.exists() {
|
||||
fs::remove_dir_all(&artifacts_dir)?;
|
||||
}
|
||||
fs::create_dir_all(&artifacts_dir)?;
|
||||
write_checked(¶ms_path, params)?;
|
||||
Ok(Ok(Session {
|
||||
dir: dir.to_path_buf(),
|
||||
outcome: SessionOutcome::Created,
|
||||
_lock: lock,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
pub fn outcome(&self) -> SessionOutcome {
|
||||
self.outcome
|
||||
}
|
||||
|
||||
fn artifact_path(&self, name: &str) -> PathBuf {
|
||||
self.dir.join(ARTIFACTS_DIR).join(name)
|
||||
}
|
||||
|
||||
/// Restore a previously [`store`](Self::store)d artifact, `mmap`ed
|
||||
/// read-only for zero-copy access by the caller (e.g. `rkyv::access`
|
||||
/// straight over the returned bytes).
|
||||
///
|
||||
/// `Ok(None)` if `name` isn't cached yet, *or* if its checksum doesn't
|
||||
/// match what was recorded at [`store`](Self::store) time — treated
|
||||
/// the same as "not cached" rather than an error, so a partial dump
|
||||
/// left behind by a killed process just triggers a recompute on the
|
||||
/// next run instead of a hard failure.
|
||||
pub fn restore(&self, name: &str) -> io::Result<Option<Mmap>> {
|
||||
verify_mmap(&self.artifact_path(name))
|
||||
}
|
||||
|
||||
/// Store `bytes` under `name`, plus a checksum sidecar, atomically:
|
||||
/// both are written to a temporary file and renamed into place, so a
|
||||
/// crash mid-write can never leave a corrupt file that a later
|
||||
/// [`restore`](Self::restore) mistakes for valid (the checksum file is
|
||||
/// only ever renamed into place *after* the data file's rename
|
||||
/// succeeds, so the two can't disagree about which write completed).
|
||||
pub fn store(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
|
||||
write_checked(&self.artifact_path(name), bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn checksum_path_for(path: &Path) -> PathBuf {
|
||||
let mut os_str = path.as_os_str().to_owned();
|
||||
os_str.push(".");
|
||||
os_str.push(CHECKSUM_EXT);
|
||||
PathBuf::from(os_str)
|
||||
}
|
||||
|
||||
/// `mmap` `path` and verify it against its checksum sidecar. `Ok(None)`
|
||||
/// when `path` doesn't exist, its checksum sidecar doesn't exist/parse, or
|
||||
/// the checksum doesn't match — every case here means "nothing usable is
|
||||
/// cached," never an error the caller has to handle specially.
|
||||
fn verify_mmap(path: &Path) -> io::Result<Option<Mmap>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(expected_str) = fs::read_to_string(checksum_path_for(path)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(expected) = expected_str.trim().parse::<u64>() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let file = fs::File::open(path)?;
|
||||
// SAFETY: the file is only ever written by `write_checked` (atomic
|
||||
// temp-then-rename) and this whole directory is held under this
|
||||
// process's exclusive `DirLock` for the `Session`'s entire lifetime —
|
||||
// no other process can be concurrently modifying it underneath this
|
||||
// mapping.
|
||||
let mmap = unsafe { Mmap::map(&file)? };
|
||||
let actual = xxhash_rust::xxh3::xxh3_64(&mmap[..]);
|
||||
if actual != expected {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"session artifact failed checksum verification — treating as absent"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(mmap))
|
||||
}
|
||||
|
||||
fn read_checked(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
||||
Ok(verify_mmap(path)?.map(|mmap| mmap[..].to_vec()))
|
||||
}
|
||||
|
||||
fn write_checked(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, bytes)?;
|
||||
fs::rename(&tmp, path)?;
|
||||
|
||||
let checksum = xxhash_rust::xxh3::xxh3_64(bytes);
|
||||
let checksum_path = checksum_path_for(path);
|
||||
let checksum_tmp = checksum_path.with_extension("tmp");
|
||||
fs::write(&checksum_tmp, checksum.to_string())?;
|
||||
fs::rename(&checksum_tmp, checksum_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresh_directory_is_created() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("session");
|
||||
let session = Session::open(&dir, b"params-v1", false).unwrap().unwrap();
|
||||
assert_eq!(session.outcome(), SessionOutcome::Created);
|
||||
assert!(session.restore("missing").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_params_reuse_and_restore_artifact() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("session");
|
||||
{
|
||||
let session = Session::open(&dir, b"params-v1", false).unwrap().unwrap();
|
||||
session.store("tally", b"some serialized bytes").unwrap();
|
||||
}
|
||||
let session = Session::open(&dir, b"params-v1", false).unwrap().unwrap();
|
||||
assert_eq!(session.outcome(), SessionOutcome::Reused);
|
||||
let restored = session.restore("tally").unwrap().unwrap();
|
||||
assert_eq!(&restored[..], b"some serialized bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_params_without_force_conflicts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("session");
|
||||
Session::open(&dir, b"params-v1", false).unwrap().unwrap();
|
||||
let conflict = Session::open(&dir, b"params-v2", false).unwrap().unwrap_err();
|
||||
assert_eq!(conflict.saved, b"params-v1");
|
||||
assert_eq!(conflict.requested, b"params-v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_params_with_force_resets_artifacts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("session");
|
||||
{
|
||||
let session = Session::open(&dir, b"params-v1", false).unwrap().unwrap();
|
||||
session.store("tally", b"old data").unwrap();
|
||||
}
|
||||
let session = Session::open(&dir, b"params-v2", true).unwrap().unwrap();
|
||||
assert_eq!(session.outcome(), SessionOutcome::Created);
|
||||
assert!(session.restore("tally").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_artifact_is_treated_as_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("session");
|
||||
let session = Session::open(&dir, b"params-v1", false).unwrap().unwrap();
|
||||
session.store("tally", b"good data").unwrap();
|
||||
// Corrupt the data file after the fact without touching the
|
||||
// checksum sidecar — simulates a partial/garbled write.
|
||||
std::fs::write(dir.join("artifacts").join("tally"), b"corrupted!").unwrap();
|
||||
assert!(session.restore("tally").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use tracing::info;
|
||||
/// Windows) via `std::fs::File::lock`/`try_lock`, not a hand-rolled PID
|
||||
/// file: the OS releases it automatically on process exit, including a
|
||||
/// crash — no stale-lock cleanup logic needed.
|
||||
#[derive(Debug)]
|
||||
pub struct DirLock {
|
||||
_file: std::fs::File,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user