Skip to content

Commit 5ce34bf

Browse files
committed
gamma and invariant
1 parent 44e2c8b commit 5ce34bf

19 files changed

Lines changed: 574 additions & 68 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ jobs:
167167
workspaces: wasm -> wasm/target
168168
- uses: actions/setup-node@v4
169169
with:
170-
node-version: "22"
170+
node-version: "24"
171171
registry-url: https://registry.npmjs.org
172172
- run: cp README.md wasm/README.md
173173
- run: npm ci
@@ -176,5 +176,3 @@ jobs:
176176
working-directory: wasm
177177
- run: npm publish --access public --provenance
178178
working-directory: wasm
179-
env:
180-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ single version, bumped together via `make bump-{patch,minor,major}`.
99
## [Unreleased]
1010

1111
### Added
12+
- Among-site rate-variation corrections for the distance models: a gamma
13+
rate-heterogeneity shape parameter (`gamma_shape`, Jin & Nei 1990) and a
14+
proportion of invariant sites (`p_invar`). For distance methods both are exact
15+
closed-form corrections to each model's `−ln(...)` step, so no discrete rate
16+
categories are needed; they apply to every correctable model (`JukesCantor`,
17+
`Kimura2P`, `TajimaNei`, `Tamura`, `Poisson`, `KimuraProtein`) and compose as
18+
`+I+Γ`. The gamma correction converges back to the uncorrected model as the
19+
shape parameter grows; `PDiff` (raw p-distance) is unaffected. Exposed across
20+
the CLI (`-g/--gamma-shape`, `-i/--p-invar`), Python, and WASM bindings, with
21+
`NJError::InvalidGammaShape` / `NJError::InvalidPInvar` for out-of-range values.
1222
- Three substitution models: `TajimaNei` (Tajima-Nei 1984, DNA — corrects
1323
Jukes-Cantor for unequal base frequencies), `Tamura` (Tamura 1992, DNA —
1424
Kimura two-parameter with a GC-content correction), and `KimuraProtein`

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ FASTA input / SequenceObject list
9191

9292
Models implement `ModelCalculation<A: AlphabetEncoding>`. DNA-only: `JukesCantor`, `Kimura2P`, `TajimaNei`, `Tamura`. Protein-only: `Poisson`, `KimuraProtein`. `PDiff` works for both alphabets. Model–alphabet compatibility is enforced at runtime inside `nj()` (the `dispatch_run!` macro in `lib.rs`).
9393

94+
`distance()` takes a `RateHet { gamma_shape: Option<f64>, p_invar: f64 }` value (threaded through `into_dist`/`from_msa`/`pairwise_distance_with`) that applies among-site rate variation to each model's `−c·ln(arg)` term: gamma rate heterogeneity becomes `c·α·(arg^(−1/α) − 1)` via the shared `corrected_term` helper, and invariant sites divide observed proportions by `(1 − p_invar)` then scale the result by `(1 − p_invar)`. `RateHet::NONE` reproduces the classic formulas exactly; `PDiff` ignores it. The `gamma_shape`/`p_invar` config fields are validated in `validate_rate_params` (`NJError::InvalidGammaShape` / `InvalidPInvar`).
95+
9496
### Bootstrap support
9597

9698
Bootstrap runs N replicate NJ trees on column-resampled MSAs, tallies clade membership using `BitVec`-keyed `HashMap` counters (`count_clades`), then maps support values back onto the main tree's internal nodes (`add_bootstrap_to_tree`).

nj/benches/nj_bench.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ fn dist_config(msa: Vec<SequenceObject>) -> DistConfig {
4747
substitution_model: SubstitutionModel::JukesCantor,
4848
alphabet: None,
4949
num_threads: None,
50+
gamma_shape: None,
51+
p_invar: None,
5052
}
5153
}
5254

@@ -59,6 +61,8 @@ fn nj_config(msa: Vec<SequenceObject>, n_bootstrap_samples: usize) -> NJConfig {
5961
num_threads: None,
6062
return_distance_matrix: false,
6163
return_average_distance: false,
64+
gamma_shape: None,
65+
p_invar: None,
6266
}
6367
}
6468

nj/src/config.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ pub struct DistConfig {
6464
/// When `None` (the default), Rayon uses all available hardware threads.
6565
#[serde(default)]
6666
pub num_threads: Option<usize>,
67+
/// Gamma rate-heterogeneity shape parameter `α` (must be `> 0`). When `None`
68+
/// (the default), substitution rates are uniform across sites. Has no effect
69+
/// on the `PDiff` model. See [`crate::models::RateHet`].
70+
#[serde(default)]
71+
pub gamma_shape: Option<f64>,
72+
/// Proportion of invariant sites in `[0, 1)`. When `None` (the default), no
73+
/// invariant-sites correction is applied. Has no effect on the `PDiff` model.
74+
/// See [`crate::models::RateHet`].
75+
#[serde(default)]
76+
pub p_invar: Option<f64>,
6777
}
6878

6979
/// Full configuration for a single Neighbor-Joining run.
@@ -105,6 +115,16 @@ pub struct NJConfig {
105115
/// Defaults to `false`.
106116
#[serde(default)]
107117
pub return_average_distance: bool,
118+
/// Gamma rate-heterogeneity shape parameter `α` (must be `> 0`). When `None`
119+
/// (the default), substitution rates are uniform across sites. Has no effect
120+
/// on the `PDiff` model. See [`crate::models::RateHet`].
121+
#[serde(default)]
122+
pub gamma_shape: Option<f64>,
123+
/// Proportion of invariant sites in `[0, 1)`. When `None` (the default), no
124+
/// invariant-sites correction is applied. Has no effect on the `PDiff` model.
125+
/// See [`crate::models::RateHet`].
126+
#[serde(default)]
127+
pub p_invar: Option<f64>,
108128
}
109129

110130
/// Combined result returned by [`crate::nj`].

nj/src/distance_matrix.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
use crate::MSA;
1111
use crate::alphabet::AlphabetEncoding;
12-
use crate::models::{ModelCalculation, pairwise_distance};
12+
use crate::models::{ModelCalculation, RateHet, pairwise_distance_with};
1313
use crate::nj::NJState;
1414
use crate::tree::TreeNode;
1515
use serde::{Deserialize, Serialize};
@@ -86,7 +86,7 @@ impl DistMat {
8686
///
8787
/// The `Send + Sync` bounds are required for the `parallel` feature and are
8888
/// trivially satisfied by all built-in alphabet and model types.
89-
pub fn from_msa<M, A>(msa: &MSA<A>) -> DistMat
89+
pub fn from_msa<M, A>(msa: &MSA<A>, rates: RateHet) -> DistMat
9090
where
9191
M: ModelCalculation<A> + Send + Sync,
9292
A: AlphabetEncoding + Sync,
@@ -104,7 +104,8 @@ impl DistMat {
104104
.collect::<Vec<_>>()
105105
.into_par_iter()
106106
.map(|(i, j)| {
107-
let d = pairwise_distance::<M, A>(&msa.sequences[i], &msa.sequences[j]);
107+
let d =
108+
pairwise_distance_with::<M, A>(&msa.sequences[i], &msa.sequences[j], rates);
108109
(i, j, d)
109110
})
110111
.collect();
@@ -118,7 +119,11 @@ impl DistMat {
118119
{
119120
for i in 0..n {
120121
for j in 0..i {
121-
dist.set(i, j, pairwise_distance::<M, A>(&msa.sequences[i], &msa.sequences[j]));
122+
dist.set(
123+
i,
124+
j,
125+
pairwise_distance_with::<M, A>(&msa.sequences[i], &msa.sequences[j], rates),
126+
);
122127
}
123128
}
124129
}
@@ -234,7 +239,7 @@ mod tests {
234239
// seq0 vs seq1 differ at middle position only
235240
let seqs: Vec<String> = vec!["ACG".into(), "ATG".into(), "A-G".into()];
236241
let msa = MSA::<DNA>::from_unnamed_sequences(seqs).unwrap();
237-
let mat = msa.into_dist::<PDiff>();
242+
let mat = msa.into_dist::<PDiff>(RateHet::NONE);
238243
// names default Seq0, Seq1, Seq2
239244
assert_eq!(mat.names, vec!["Seq0", "Seq1", "Seq2"]);
240245
// seq0 vs seq1: one mismatch across three aligned positions -> 1/3
@@ -251,7 +256,7 @@ mod tests {
251256
// n_comparable=2, diffs=1 (T vs C at pos 1), so 1/2.
252257
let seqs: Vec<String> = vec!["AT-".into(), "ACG".into()];
253258
let msa = MSA::<DNA>::from_unnamed_sequences(seqs).unwrap();
254-
let mat = msa.into_dist::<PDiff>();
259+
let mat = msa.into_dist::<PDiff>(RateHet::NONE);
255260

256261
assert!((mat.get(0, 1) - (1.0 / 2.0)).abs() < 1e-12);
257262
}
@@ -261,7 +266,7 @@ mod tests {
261266
// no non-gap mismatches are counted, so the distance remains 0.0
262267
let seqs = vec![("X".into(), "A--".into()), ("Y".into(), "--A".into())];
263268
let msa = MSA::<DNA>::from_iter(seqs);
264-
let mat = msa.into_dist::<PDiff>();
269+
let mat = msa.into_dist::<PDiff>(RateHet::NONE);
265270
assert_eq!(mat.names, vec!["X", "Y"]);
266271
assert!((mat.get(0, 1) - 0.0).abs() < 1e-12);
267272
}

nj/src/error.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ pub enum NJError {
2525
model: SubstitutionModel,
2626
alphabet: Alphabet,
2727
},
28+
/// The gamma rate-heterogeneity shape parameter is not finite and positive.
29+
InvalidGammaShape { value: f64 },
30+
/// The proportion of invariant sites is not finite and in `[0, 1)`.
31+
InvalidPInvar { value: f64 },
2832
/// An internal NJ algorithm failure (should be unreachable for valid input).
2933
AlgorithmFailure(String),
3034
/// Failed to collect entropy from the OS PRNG (bootstrap only).
@@ -46,6 +50,8 @@ impl NJError {
4650
NJError::SequenceLengthMismatch { .. } => "SequenceLengthMismatch",
4751
NJError::DuplicateIdentifier { .. } => "DuplicateIdentifier",
4852
NJError::IncompatibleModel { .. } => "IncompatibleModel",
53+
NJError::InvalidGammaShape { .. } => "InvalidGammaShape",
54+
NJError::InvalidPInvar { .. } => "InvalidPInvar",
4955
NJError::AlgorithmFailure(_) => "AlgorithmFailure",
5056
NJError::RngError(_) => "RngError",
5157
NJError::ParseError(_) => "ParseError",
@@ -69,6 +75,14 @@ impl fmt::Display for NJError {
6975
f,
7076
"Substitution model {model:?} is incompatible with {alphabet:?} alphabet"
7177
),
78+
NJError::InvalidGammaShape { value } => write!(
79+
f,
80+
"Gamma shape parameter must be a finite positive number, got {value}"
81+
),
82+
NJError::InvalidPInvar { value } => write!(
83+
f,
84+
"Proportion of invariant sites must be a finite number in [0, 1), got {value}"
85+
),
7286
NJError::AlgorithmFailure(msg) => write!(f, "NJ algorithm failure: {msg}"),
7387
NJError::RngError(msg) => write!(f, "RNG error: {msg}"),
7488
NJError::ParseError(msg) => write!(f, "FASTA parse error: {msg}"),

0 commit comments

Comments
 (0)