Introduces a design specification for inferring substitution rates directly from k-mers with conserved flanks. The document details a memory-efficient implementation that computes 4x4 base-pair tallies using existing MPHF structures, enabling classical corrections without de Bruijn graph materialization. Updates MkDocs navigation to include the new theory page.
330 lines
17 KiB
Markdown
330 lines
17 KiB
Markdown
# Central-position SNP distance (discussion)
|
|
|
|
Not implemented. Design discussion for a substitution-rate estimator that
|
|
observes SNPs directly from paired-genome k-mer comparison, as an alternative
|
|
to Mash's Poisson-Jaccard inference (see [obicompactvec](../implementation/obicompactvec.md)
|
|
for the implemented Jaccard/Mash distances).
|
|
|
|
## Motivation
|
|
|
|
Mash infers a mutation rate from a single scalar (Jaccard) via a model that
|
|
assumes independence across the k positions. For k odd, a substitution at the
|
|
**central base** of a k-mer, with the `2m` flanking bases (`m = (k-1)/2`)
|
|
otherwise conserved, is directly observable: it is a SNP. This gives access not
|
|
just to a rate but to the substitution's nature (transition/transversion),
|
|
enabling classical corrected distances.
|
|
|
|
## Statistic and correspondence with `shared`
|
|
|
|
A genomic position `p` is covered by `k` overlapping k-mer windows. Requiring
|
|
the substitution to sit at the window's **center** makes exactly one window
|
|
per SNP eligible — a 1:1 correspondence between SNP and center-neighbor k-mer
|
|
pair, avoiding the ~k-fold overcount of an any-position neighbor search.
|
|
|
|
A locus with a fully conserved `k`-window (flanks **and** center) is precisely
|
|
an exact-shared k-mer — already produced by the existing `shared_kmers` matrix
|
|
(`--shared-kmers`, `BitPartials::partial_jaccard` / `CountPartials::partial_threshold_jaccard`).
|
|
A locus with conserved flanks but a substituted center is a "central SNP". Both
|
|
count each locus exactly once, in matching units:
|
|
|
|
```
|
|
p_hat[i,j] = SNP[i,j] / (SNP[i,j] + shared[i,j])
|
|
```
|
|
|
|
`p_hat` is `P(center substituted | 2m flanks conserved)`. Only the numerator
|
|
`SNP[i,j]` needs computing; the denominator term is already available.
|
|
|
|
**Canonical invariance**: for odd k, the central position maps to itself under
|
|
reverse-complement (`m -> k-1-m = m`, base complemented). A transition maps to
|
|
a transition, a transversion to a transversion — the transition/transversion
|
|
split is well-defined in canonical space.
|
|
|
|
## Sufficient statistic: 4x4 base-pair tally
|
|
|
|
Tabulating the joint distribution of `(center_i, center_j)` over conserved-flank
|
|
loci, per genome pair, is sufficient for every downstream correction:
|
|
|
|
| Estimator | Input | Formula |
|
|
|---|---|---|
|
|
| Raw p-distance | total off-diagonal / total | `p = SNP / (SNP + shared)` |
|
|
| Jukes-Cantor | p | `d = -3/4 * ln(1 - 4p/3)` |
|
|
| Kimura 2-parameter | transition rate P, transversion rate Q | `d = 1/2 ln(1/(1-2P-Q)) + 1/4 ln(1/(1-2Q))` |
|
|
| LogDet/paralinear | full 4x4 + base-composition margins | `d ~= -1/4 ln det(F)`, robust to non-stationary base composition |
|
|
|
|
JC/K2P need only the total and the transition/transversion split. LogDet needs
|
|
the full 4x4 (adds a diagonal tally of shared k-mers' central base — cheap).
|
|
|
|
Memory for the 4x4 tally: `n^2 * 16` counters. Trivial for the project's
|
|
genome-scale use case (tens to hundreds of genomes); ~13 GB at n=10^4 — outside
|
|
scope but worth flagging if n grows.
|
|
|
|
## Biases (properties of the estimator, not defects)
|
|
|
|
1. **Conserved-flank ascertainment bias.** Only SNPs with intact `2m`-base
|
|
flanks are visible; window-intact probability decays as `(1-p)^{2m}`. For
|
|
k=31 (2m=30): 0.74 at p=1%, 0.21 at p=5%, 0.04 at p=10%. This estimator
|
|
targets **closely related genomes**. Under rate heterogeneity across sites
|
|
(universal in practice), conserved flanks correlate with slow centers, so
|
|
`p_hat` underestimates the genome-wide average rate — it specifically
|
|
estimates the substitution rate of **conserved regions**.
|
|
2. **Bias toward isolated SNPs.** Two SNPs within k of each other disqualify
|
|
each other's flanks. Hypervariable regions are invisible by construction.
|
|
3. **Indels are invisible.** A frameshift destroys k-mer matches in a block;
|
|
this channel captures substitutions only. Indel divergence shows up as lost
|
|
shared k-mers (lower Jaccard/Mash), not as SNP signal.
|
|
4. **k-dependent specificity.** "A k-mer match implies common ancestry" is
|
|
quantitative. For a 3 Gbp genome, expected random flank-30 collisions
|
|
(k=31): `(3e9)^2 / 4^30 ~= 8` — negligible. At k=21: `(3e9)^2 / 4^20 ~= 2e6`
|
|
— no longer negligible. k=31 is safe; k<=21 is marginal to unreliable for
|
|
large genomes. The large k that guarantees homology is the same k that
|
|
shrinks the detectable-divergence window — an inherent tension.
|
|
|
|
## Implementation: avoid materializing a de Bruijn graph
|
|
|
|
A central-SNP pair is topologically a simple bubble in the colored de Bruijn
|
|
graph (source/sink k-mer shared, two length-k branches differing only at the
|
|
midpoint). Classical bubble-calling (Cortex/discoSNP-style) finds these, but
|
|
requires the graph — nodes plus adjacency for ~10^9 colored k-mers — resident
|
|
in memory. **Rejected**: prohibitive RAM for this project's scale.
|
|
|
|
A naive per-pair generalisation of variant lookup across n genomes (query each
|
|
non-shared k-mer's 3 central variants against every counterpart genome's
|
|
index) costs `O(n^2 . N . 3)` random lookups, with the same k-mer's 3 variants
|
|
regenerated and requeried once per counterpart genome — pure redundant work.
|
|
**Rejected** as the basis for an n-genome design.
|
|
|
|
## Implementation: sequential per-partition sweep (no scratch, no graph)
|
|
|
|
`KmerIndex::distance()` already opens every partition's `presence_store`/
|
|
`count_store` simultaneously, memory-mapped, into one `LayeredStore`
|
|
(`distance.rs:73-77`). "Querying another partition" is therefore not a new
|
|
I/O pattern to design — it is the same O(1) MPHF+evidence lookup the `query`
|
|
command already performs at scale. This lets the SNP tally be computed with
|
|
**no scratch files and no auxiliary graph**, by sweeping partitions once each
|
|
as a source:
|
|
|
|
1. For source partition `p`, enumerate its **distinct** k-mers (one per MPHF
|
|
slot; each already carries its full multi-genome presence/count vector —
|
|
no need to explode per (k-mer, genome) occurrence).
|
|
2. For each, generate the 3 central-substitution variants and **canonicalise
|
|
each independently** (`min(kmer, revcomp)`, exactly as any normal query) —
|
|
this avoids the orientation edge case a masked-flank grouping would have
|
|
(a substitution that flips canonical orientation is handled correctly
|
|
because each variant is canonicalised on its own, not inferred from a
|
|
fixed-orientation flank key).
|
|
3. Compute each variant's target partition `q` via its minimizer; batch/sort
|
|
the partition's outgoing variant queries by `q` for locality.
|
|
4. Look up each variant in `q`'s already-mmap'd MPHF+evidence; on a hit,
|
|
combine the source's presence vector (base `a`) with the variant's
|
|
presence vector (base `b`): for every `i` carrying `a` and `j` carrying
|
|
`b`, `tally[i,j][a,b] += 1`.
|
|
|
|
**Deduplication needs no persisted state.** Sweeping partitions in a fixed
|
|
order `p = 0, 1, ..., P-1` and only acting on a variant when its target
|
|
partition `q >= p` guarantees each unordered SNP pair is counted exactly
|
|
once: a pair with `q < p` was already resolved earlier, when `q` was itself
|
|
the source partition and `p` (being `>= q`) was a valid forward target. No
|
|
cross-partition flag array is needed — the sweep order *is* the
|
|
deduplication rule. Within the same partition (`q == p`), a lightweight
|
|
transient tie-break suffices: either a `#slots(p)`-bit scratch flag reset per
|
|
partition, or simply comparing the two k-mers' raw `u64` encodings and only
|
|
counting when `kmer_source < kmer_variant` — no storage at all.
|
|
|
|
This is a distinct computation stage, not a `partial_*` in the existing
|
|
additive-by-partition sense: step 3-4 read across partition boundaries by
|
|
construction, unlike the row-local `partial_jaccard`/`partial_threshold_jaccard`
|
|
primitives. But it requires no new index files, permanent or scratch:
|
|
`unitigs.bin`, `mphf.bin`, `evidence.bin`, and the presence/count columns are
|
|
read as-is, and the only extra memory is the current partition's small
|
|
outgoing-query batch (`#kmers(p) * 3`, released once `p` is done) plus the
|
|
persistent `tally` accumulator (`n^2 * 16` counters, see above).
|
|
|
|
**Outer loop (over source partitions `p`) must stay sequential.** Two
|
|
independent reasons, not just one: (a) memory — the bounded-footprint claim
|
|
above only holds with one partition's outgoing-query batch in flight; running
|
|
`T` source partitions concurrently multiplies that batch by `T`, exactly the
|
|
blowup the design avoids; (b) correctness — the `q >= p` deduplication rule
|
|
requires partitions to be claimed as sources in a fixed order; running `p1 <
|
|
p2` concurrently gives no guarantee `p1` has finished claiming its `q >= p1`
|
|
targets before `p2` starts claiming its own, breaking the "counted exactly
|
|
once" property.
|
|
|
|
**Inner loop (target-partition lookups for a fixed `p`) parallelises safely.**
|
|
Each lookup is an O(1) read against an already-mmap'd structure, independent
|
|
of the others, with no growing allocation — no memory blowup, no ordering
|
|
dependency between different `q`. The only shared mutable state is `tally`;
|
|
give each worker thread a **thread-local partial tally** (fixed `n^2 * 16`
|
|
size, independent of partition size) and merge into the global `tally` once
|
|
`p`'s inner loop completes — the same reduce-then-merge pattern Rayon already
|
|
uses elsewhere in this codebase to open partitions in parallel. Extra memory:
|
|
`#threads * n^2 * 16`, negligible (~512 MB at n~1000, 32 threads) and
|
|
unrelated to partition size.
|
|
|
|
**Cost**: `3 * N_distinct` MPHF lookups total across the whole index (each
|
|
partition swept once as source) — the same order of magnitude and the same
|
|
operation as running `query` over the index's entire k-mer content against
|
|
itself, three times. This is the tool's already-optimized regime, not a new
|
|
I/O profile to validate.
|
|
|
|
## Cheaper: subsampling
|
|
|
|
Since the target is a ratio, restricting the source-partition sweep to a
|
|
bottom-`s` hash sketch (only enumerate k-mers with `hash < threshold` as
|
|
sources) divides the lookup count by the sampling factor without biasing
|
|
`p_hat`. Mash-like tradeoff: rate estimated from a sample, not the full
|
|
k-mer set.
|
|
|
|
## Recommendation
|
|
|
|
Sequential per-partition sweep (Route D): reuse the already-mmap'd
|
|
per-partition MPHF/evidence/presence structures for O(1) variant lookups,
|
|
dedup via the fixed sweep-order rule (`q >= p`, plus an in-partition
|
|
tie-break), no scratch files, no graph materialisation. Denominator reused
|
|
from the existing `shared_kmers` matrix. Distances (p, JC, K2P, LogDet) as
|
|
finalisations of the resulting 4x4 tally, mirroring the `partial_* -> *_dist_matrix`
|
|
pattern used for Jaccard/Mash/Bray-Curtis/etc.
|
|
|
|
## Detailed implementation plan
|
|
|
|
Grounded in the current codebase. File/type references are anchors, not
|
|
prescriptions; adjust to reality when implementing.
|
|
|
|
### Step 0 — new low-level primitives (`obikseq`)
|
|
|
|
Two helpers do not yet exist and are prerequisites:
|
|
|
|
1. **Central neighbours.** `CanonicalKmerOf<L>` already exposes
|
|
`left_canonical_neighbors()` / `right_canonical_neighbors()`
|
|
(`obikseq/src/kmer.rs`), each returning the 4 canonicalised neighbours at
|
|
an end position. Add `central_canonical_neighbors()` returning the 4
|
|
variants at position `m = (k-1)/2` (each independently canonicalised via
|
|
`.canonical()`). The 3 that differ from the source are the query variants;
|
|
skip the identity. Building on `nucleotide(i)` / the raw 2-bit layout keeps
|
|
it O(1).
|
|
2. **Lone-k-mer minimiser.** Routing a *synthetic* variant to its partition
|
|
needs its minimiser, but `RollingStat` (`obiskbuilder/src/rolling_stat.rs`)
|
|
only computes minimisers incrementally along a sequence. Add a standalone
|
|
`minimizer(kmer) -> Minimizer` that scans the `k-m+1` m-mer windows
|
|
(`PackedSeq::mmer`, `obikseq/src/packed_seq.rs`), canonicalises each, and
|
|
takes the min by `seq_hash()` — the same selection `RollingStat` performs,
|
|
evaluated once. Partition index is then
|
|
`(minimizer.seq_hash() & (n_partitions - 1)) as usize`, exactly as
|
|
`QueryBatch::from_records` (`obikmer/src/cmd/query.rs:142`); `n_partitions`
|
|
is a power of two so the mask is valid.
|
|
|
|
### Step 1 — the tally accumulator (`obikindex`)
|
|
|
|
A `SnpTally` holding, per genome pair, the 4x4 joint count of central bases:
|
|
`n * n * 4 * 4` `u64` (or a packed lower-triangular form since it is
|
|
symmetric). Provide `merge(&mut self, other: &SnpTally)` for the thread-local
|
|
reduce, and accessors yielding, per pair `(i,j)`: total off-diagonal (SNP),
|
|
transition count `P`, transversion count `Q`. The diagonal need not be stored
|
|
for JC/K2P (the denominator comes from `shared_kmers`); store it only if
|
|
LogDet is wanted, by also tallying shared k-mers' central base.
|
|
|
|
### Step 2 — the sweep (`obikindex`, new `snp.rs`)
|
|
|
|
Mirror `distance.rs`: open the presence or count store per partition. But
|
|
instead of a per-partition `partial_*`, run the sequential source sweep:
|
|
|
|
```text
|
|
for p in 0..n_partitions: # OUTER — sequential
|
|
open source partition p's layers (QueryLayer-style, obikpartitionner)
|
|
enumerate distinct canonical k-mers of p (one per MPHF slot) with their
|
|
presence/count vectors # column-major, as query stage 2
|
|
par_iter over these source k-mers: # INNER — rayon, thread-local tally
|
|
for each of the 3 central variants:
|
|
q = partition_of(variant)
|
|
if q < p: continue # dedup: forward targets only
|
|
if q == p and variant <= source.raw(): continue # in-partition tie-break
|
|
slot = layers[q].find_slot(variant) # MphfLayer::find, mmap'd
|
|
if hit:
|
|
vb = variant presence/count vector
|
|
for i in genomes with source base a:
|
|
for j in genomes with variant base b:
|
|
thread_tally[i,j][a,b] += 1
|
|
merge thread-local tallies into global SnpTally
|
|
```
|
|
|
|
The inner lookup is precisely `QueryLayer::find_slot` +
|
|
`col_value(g, slot)` (`obikpartitionner/src/query_layer.rs`) — reuse or factor
|
|
out that path rather than reimplementing MPHF access. Enumerating "all distinct
|
|
k-mers of a partition with their vectors" is the `dump`/`query` stage-2
|
|
column-major scan already implemented in `dump_layer.rs` /
|
|
`query_partition_with`; factor a reusable iterator if none fits.
|
|
|
|
`presence_threshold` applies exactly as elsewhere: a genome "carries base b"
|
|
iff its count at that slot is `>= presence_threshold` (trivially `>= 1` for
|
|
presence indexes).
|
|
|
|
### Step 3 — finalisation (`obikindex`)
|
|
|
|
From the global `SnpTally` + the existing `shared_kmers` matrix, derive n x n
|
|
distance matrices, each a pure function of the accumulated counts (same shape
|
|
as `jaccard_to_mash`):
|
|
|
|
- `p_hat[i,j] = SNP / (SNP + shared)`
|
|
- Jukes-Cantor, Kimura-2P (from `P`, `Q`), optionally LogDet (needs the
|
|
diagonal + base-composition margins).
|
|
|
|
Guard the singularities (`p >= 3/4` for JC, `1-2P-Q <= 0` or `1-2Q <= 0` for
|
|
K2P) by clamping to a max distance, as `jaccard_to_mash` clamps `J <= 0`.
|
|
|
|
### Step 4 — surfacing (`obikindex` + `obikmer` CLI)
|
|
|
|
These metrics do not fit `DistanceMetric`'s current `LayeredStore`-partial
|
|
dispatch (they need the cross-partition sweep and produce a different
|
|
intermediate). Two options, to decide:
|
|
|
|
- **(a)** New `DistanceMetric` variants (`Pdistance`, `JukesCantor`,
|
|
`Kimura2P`, `LogDet`) whose `KmerIndex::distance` arm calls the sweep
|
|
(`snp.rs`) instead of the partial path, still returning `DistanceOutput`.
|
|
Keeps one CLI surface (`--metric jukes-cantor`), at the cost of a branch in
|
|
`distance()` that ignores the `LayeredStore` it built.
|
|
- **(b)** A dedicated pathway (`KmerIndex::snp_distance`) and a distinct CLI
|
|
entry, if mixing a cross-partition sweep into the partition-local `distance`
|
|
command is judged architecturally muddy.
|
|
|
|
Recommendation: (a) for user ergonomics (all pairwise distances under
|
|
`distance`, all feeding NJ/UPGMA/`--shared-kmers` unchanged), but compute the
|
|
sweep lazily only when an SNP-family metric is requested, so the existing
|
|
metrics keep their partition-local fast path untouched.
|
|
|
|
### Step 5 — subsampling flag
|
|
|
|
Add `--snp-sample <fraction>` (or a bottom-`s` hash threshold): restrict the
|
|
source-k-mer enumeration in Step 2 to `seq_hash(kmer) < threshold`. Divides
|
|
lookups proportionally; `p_hat` is unbiased. Off by default (exact).
|
|
|
|
### Testing
|
|
|
|
- **Primitive unit tests**: `central_canonical_neighbors` on hand-checked
|
|
k-mers incl. palindrome-boundary cases; lone-k-mer `minimizer` against
|
|
`RollingStat`'s incremental result on the same k-mer.
|
|
- **End-to-end tiny index**: two 1-genome indexes differing by a handful of
|
|
known isolated SNPs (transitions and transversions placed by hand), assert
|
|
exact `SNP`, `P`, `Q` counts and the resulting JC/K2P values.
|
|
- **Dedup invariant**: assert the tally is identical regardless of genome/
|
|
partition order and that no pair is double-counted (compare against a
|
|
brute-force all-pairs reference on a small index).
|
|
- **Subsampling**: `p_hat` within sampling error of the exact run.
|
|
|
|
### Suggested phasing
|
|
|
|
1. Step 0 primitives + their unit tests (self-contained, no distance wiring).
|
|
This also unblocks the long-declared-but-unimplemented `query --mismatch`
|
|
(`obikmer/src/cmd/query.rs:676`, currently a warning), which needs the same
|
|
neighbour + routing machinery.
|
|
2. `SnpTally` + finalisation math with a brute-force (non-swept) reference
|
|
backend, validated on a tiny index.
|
|
3. The real per-partition sweep (Step 2) behind the same finalisation; assert
|
|
it matches the brute-force backend.
|
|
4. CLI surfacing (Step 4a) and NJ/UPGMA integration (already generic over the
|
|
matrix).
|
|
5. Subsampling (Step 5).
|
|
|
|
## References
|
|
|
|
The Mash mutation-rate model this discussion contrasts with:
|
|
[@Mash-distances-doc; @Fan2015-mash-formula].
|