# 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` 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 ` (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].