This notebook tests the core simulation engine against exact expectations. Each test states its expectation, runs a fresh simulation, compares the result statistically and visually, and prints a PASS or FAIL verdict against a criterion stated in advance. To rerun everything with a different seed or simulation size:
rmarkdown::render("engine_validation.Rmd", params = list(seed = 1, n_sites = 3000))
The stationary-distribution and generic-transient tests use fitness
profiles shipped with the package (psi_c50_1). The dS tests
additionally construct a standard Zhang–Nielsen–Yang branch-site model
and a mutation–selection fitness and population-size shift.
data(psi_c50_1)
hky <- HasegawaKishinoYano(equilibrium = rep(0.25, 4))
psi_1 <- equilibrium_to_fitness(as.numeric(psi_c50_1[1, ]), 1000)
psi_2 <- equilibrium_to_fitness(as.numeric(psi_c50_1[2, ]), 1000)
n_sites <- params$n_sites
Expectation. A model built with
scaling_type = "synonymous-per-codon",
"non-synonymous", or "substitution" is
normalized so that one unit of branch length delivers, at stationarity,
one expected substitution of that class per codon site: synonymous,
non-synonymous, or of any kind, respectively. Simulating 3000 sites over
a two-taxon tree with branch lengths 1, the mean number of class events
per site per unit branch length should equal 1.
Criterion. For each scaling mode, the observed mean is within three standard errors of 1.
tree2 <- tempfile(fileext = ".newick"); writeLines("(A:1.0,B:1.0);", tree2)
p2 <- Phylogeny(tree2)
t1 <- do.call(rbind, lapply(c("synonymous-per-codon", "non-synonymous", "substitution"),
function(sc) {
ms <- MutationSelection(1000, 1e-8, hky, psi_1, scaling_type = sc)
sim <- simulate_over_phylogeny(p2, ms, sample_sequence(ms, n_sites))
sub <- sim$substitutions
in_class <- switch(sc,
`synonymous-per-codon` = sub$synonymous,
"non-synonymous" = !sub$synonymous,
substitution = rep(TRUE, nrow(sub)))
per_site <- tabulate(sub$site[in_class] + 1L, nbins = n_sites) / 2
data.frame(scaling = sc, mean = mean(per_site),
se = sd(per_site) / sqrt(n_sites),
z = (mean(per_site) - 1) / (sd(per_site) / sqrt(n_sites)))
}))
knitr::kable(t1, digits = 4)
| scaling | mean | se | z |
|---|---|---|---|
| synonymous-per-codon | 1.0042 | 0.0140 | 0.2975 |
| non-synonymous | 1.0155 | 0.0179 | 0.8672 |
| substitution | 1.0115 | 0.0141 | 0.8143 |
verdict("Test 1", all(abs(t1$z) < 3),
sprintf("class events per site per unit length: %s (|z| max %.2f, criterion < 3)",
paste(sprintf("%.3f", t1$mean), collapse = ", "), max(abs(t1$z))))
Test 1: PASS — class events per site per unit length: 1.004, 1.015, 1.012 (|z| max 0.87, criterion < 3)
The default scaling_type = "synonymous" is a dS gauge
rather than an event-count gauge. With amino-acid fitness differences
removed, synonymous histories divided by the model’s neutral synonymous
opportunity count should recover dS = 1. The neutral total rate is also
three substitutions per codon per dS unit.
ms_ds <- MutationSelection(1000, 1e-8, hky, rep(0, 20),
scaling_type = "synonymous")
set_palantir_seed(params$seed + 1L)
sim_ds <- simulate_over_phylogeny(p2, ms_ds, sample_sequence(ms_ds, n_sites))
per_site_total <- tabulate(sim_ds$substitutions$site + 1L,
nbins = n_sites) / 2
per_site_syn <- tabulate(sim_ds$substitutions$site[sim_ds$substitutions$synonymous] + 1L,
nbins = n_sites) / 2
ds_by_site <- per_site_syn / ms_ds$synonymous_opportunities
ds_checks <- data.frame(
quantity = c("dS from synonymous histories", "neutral events per codon"),
expected = c(1, 3),
observed = c(mean(ds_by_site), mean(per_site_total)),
se = c(sd(ds_by_site), sd(per_site_total)) / sqrt(n_sites))
ds_checks$z <- (ds_checks$observed - ds_checks$expected) / ds_checks$se
knitr::kable(ds_checks, digits = 4)
| quantity | expected | observed | se | z |
|---|---|---|---|---|
| dS from synonymous histories | 1 | 1.0032 | 0.0155 | 0.2033 |
| neutral events per codon | 3 | 3.0005 | 0.0223 | 0.0224 |
Expectation. Under the dS gauge, branch lengths can be recovered from complete synonymous histories after dividing by the appropriate synonymous opportunity exposure. This should hold for (i) all branches and site classes of a standard GY94 branch-site model and (ii) a mutation–selection branch entered after a simultaneous fitness-profile and population-size shift. The second expectation is computed independently by Runge–Kutta integration of the selected master equation and the neutral dS-clock functional.
Criterion. Each input branch length is within four Monte-Carlo standard errors of its history estimate. Four standard errors are used because the GY94 check evaluates six branches simultaneously.
n_ds <- max(12000L, 4L * ceiling(n_sites / 4L))
ds_tree_file <- tempfile(fileext = ".newick")
ds_mode_file <- tempfile(fileext = ".newick")
writeLines("((A:0.15,B:0.30):0.20,(C:0.45,D:0.60):0.25);", ds_tree_file)
writeLines("((A:0,B:1):0,(C:0,D:0):0);", ds_mode_file)
ds_tree <- Phylogeny(ds_tree_file)
ds_modes <- Phylogeny(ds_mode_file, type = "mode")
ds_branches <- phylogeny_to_intervals(ds_tree, ds_modes)
ds_branches$input_dS <- ds_branches$to - ds_branches$from
set_palantir_seed(params$seed + 101L)
zny <- ZNYBranchSiteModel(
mode_phylogeny = ds_modes,
site_counts = rep(n_ds / 4L, 4L),
omega0 = 0.2,
omega2 = 3.0,
kappa = 2.5,
frequencies = c(T = .20, C = .30, A = .25, G = .25),
frequency_model = "F1x4",
scaling_type = "dS")
zny_models <- unlist(lapply(zny$models, `[[`, "models"), recursive = FALSE)
zny_NS <- vapply(zny_models, `[[`, numeric(1), "synonymous_opportunities")
stopifnot(max(zny_NS) - min(zny_NS) < 1e-12)
zny_sim <- simulate_gy94_site_model(ds_tree, zny, rescale_method = "exact")
zny_dS <- do.call(rbind, lapply(seq_len(nrow(ds_branches)), function(i) {
node_i <- ds_branches$node[i]
keep <- zny_sim$substitutions$node == node_i &
zny_sim$substitutions$synonymous
by_site <- tabulate(zny_sim$substitutions$site[keep] + 1L, nbins = n_ds)
values <- by_site / zny_NS[1]
estimate <- mean(values)
se <- sd(values) / sqrt(n_ds)
data.frame(model = "GY94 branch-site", node = node_i,
mode = ds_branches$state[i],
input_dS = ds_branches$input_dS[i],
observed_dS = estimate, se = se,
z = (estimate - ds_branches$input_dS[i]) / se)
}))
knitr::kable(zny_dS, digits = 4)
| model | node | mode | input_dS | observed_dS | se | z |
|---|---|---|---|---|---|---|
| GY94 branch-site | 1 | 0 | 0.20 | 0.1972 | 0.0044 | -0.6284 |
| GY94 branch-site | 2 | 0 | 0.15 | 0.1485 | 0.0038 | -0.3962 |
| GY94 branch-site | 3 | 1 | 0.30 | 0.2942 | 0.0053 | -1.0924 |
| GY94 branch-site | 4 | 0 | 0.25 | 0.2515 | 0.0050 | 0.2917 |
| GY94 branch-site | 5 | 0 | 0.45 | 0.4542 | 0.0068 | 0.6195 |
| GY94 branch-site | 6 | 0 | 0.60 | 0.5924 | 0.0078 | -0.9735 |
The mutation–selection test uses a two-tip tree so that the shifted branch is entered directly from the ancestral equilibrium. Its independently integrated synonymous exposure is reported with the simulation estimate.
nuc <- c(T = .22, C = .28, A = .31, G = .19)
hky_ds <- HasegawaKishinoYano(nuc, transition_rate = 2.5,
transversion_rate = 1)
aa_order <- strsplit("ACDEFGHIKLMNPQRSTVWY", "")[[1]]
freq0 <- freq1 <- setNames(rep(.20 / 19, 20), aa_order)
freq0["W"] <- .80
freq1["A"] <- .80
Ne0 <- 500; Ne1 <- 20000
fit0 <- equilibrium_to_fitness(as.numeric(freq0), Ne0,
nucleotide_equilibrium = nuc)
fit1 <- equilibrium_to_fitness(as.numeric(freq1), Ne1,
nucleotide_equilibrium = nuc)
ms0 <- MutationSelection(Ne0, .01, hky_ds, fit0, scaling_type = "dS")
ms1 <- MutationSelection(Ne1, .01, hky_ds, fit1, scaling_type = "dS")
shift_tree_file <- tempfile(fileext = ".newick")
shift_mode_file <- tempfile(fileext = ".newick")
writeLines("(A:0.30,B:0.10);", shift_tree_file)
writeLines("(A:1,B:0);", shift_mode_file)
shift_tree <- Phylogeny(shift_tree_file)
shift_modes <- Phylogeny(shift_mode_file, type = "mode")
shift_length <- 0.30
p0_ds <- as.numeric(ms0$equilibrium)
h1_ds <- as.numeric(ms1$dS_outflux)
g1_syn <- syn_outflux(ms1)
target <- shift_length * sum(as.numeric(ms1$equilibrium) * h1_ds)
integrated <- rk4_to_budget(p0_ds, ms1$transition, h1_ds, g1_syn, target)
mean_syn_opportunity <- integrated$syn_exposure / shift_length
set_palantir_seed(params$seed + 201L)
shift_root <- sample_sequence(ms0, n_ds)
shift_sim <- simulate_over_interval_phylogeny(
phylogeny = shift_tree,
mode_phylogeny = shift_modes,
models = list(ms0, ms1),
sequence = shift_root,
start_mode = 0,
rescale_method = "exact")
shift_node <- shift_sim$intervals$node[shift_sim$intervals$state == 1]
shift_events <- shift_sim$substitutions[
shift_sim$substitutions$node == shift_node &
shift_sim$substitutions$synonymous, ]
shift_by_site <- tabulate(shift_events$site + 1L, nbins = n_ds)
shift_dS_by_site <- shift_by_site / mean_syn_opportunity
shift_estimate <- mean(shift_dS_by_site)
shift_se <- sd(shift_dS_by_site) / sqrt(n_ds)
shift_z <- (shift_estimate - shift_length) / shift_se
mutsel_dS <- data.frame(
model = "Mutation-selection fitness + Ne shift",
input_dS = shift_length,
observed_dS = shift_estimate,
se = shift_se,
z = shift_z,
intrinsic_time = integrated$tau,
synonymous_opportunities_per_codon_per_dS = mean_syn_opportunity)
knitr::kable(mutsel_dS, digits = 5)
| model | input_dS | observed_dS | se | z | intrinsic_time | synonymous_opportunities_per_codon_per_dS |
|---|---|---|---|---|---|---|
| Mutation-selection fitness + Ne shift | 0.3 | 0.28654 | 0.00931 | -1.44582 | 0.40184 | 0.32631 |
all_ds_z_max <- max(abs(c(ds_checks$z, zny_dS$z, shift_z)))
verdict("Test 2", all_ds_z_max < 4,
sprintf("homogeneous checks and all history estimates recover the dS scale (|z| max %.2f, criterion < 4)",
all_ds_z_max))
Test 2: PASS — homogeneous checks and all history estimates recover the dS scale (|z| max 1.45, criterion < 4)
Expectation. The model’s stationary distribution
(model$equilibrium) is both the distribution the root
sequence is sampled from and the invariant distribution of the
substitution process, so the states observed at the end of a branch of
any length are draws from it. Comparing the codon frequencies at a tip
across 3000 independent sites against the computed stationary
distribution tests the equilibrium computation, the root sampler, and
the propagation together.
Criterion. Monte-Carlo chi-square goodness of fit, observed tip codon counts against the stationary distribution, with both Holm-adjusted p-values greater than 0.01. The adjustment controls the family-wise false-positive rate across the codon- and amino-acid-level comparisons.
ms <- MutationSelection(1000, 1e-8, hky, psi_1)
sim <- simulate_over_phylogeny(p2, ms, sample_sequence(ms, n_sites))
codons <- names(GeneticCode())
obs_codon <- as.numeric(table(factor(sim$alignment["A", ], levels = codons)))
pi_codon <- as.numeric(ms$equilibrium)
chi_codon <- chisq.test(obs_codon, p = pi_codon, rescale.p = TRUE,
simulate.p.value = TRUE, B = 5000)
obs_aa <- codon_to_aa(obs_codon); th_aa <- codon_to_aa(pi_codon)
chi_aa <- chisq.test(obs_aa, p = th_aa, rescale.p = TRUE,
simulate.p.value = TRUE, B = 5000)
stationary_p <- p.adjust(c(codon = chi_codon$p.value,
amino_acid = chi_aa$p.value), method = "holm")
The stationary and observed amino-acid distributions as sequence logos (letter height = frequency):
plot_freqs(data.frame(stationary = th_aa, observed = obs_aa / sum(obs_aa)),
c("stationary", "observed"))
verdict("Test 3", all(stationary_p > 0.01),
sprintf("raw chi-square p = %.3f (codon), %.3f (amino acid); minimum Holm-adjusted p = %.3f; TV(observed, stationary) = %.4f at the amino-acid level; criterion adjusted p > 0.01",
chi_codon$p.value, chi_aa$p.value, min(stationary_p),
tv(obs_aa / sum(obs_aa), th_aa)))
Test 3: PASS — raw chi-square p = 0.408 (codon), 0.302 (amino acid); minimum Holm-adjusted p = 0.603; TV(observed, stationary) = 0.0189 at the amino-acid level; criterion adjusted p > 0.01
Expectation. In a time-heterogeneous simulation the sequence starts at the stationary distribution of the first model and relaxes to the stationary distribution of the second. The state distribution along the way follows the master equation of the second model’s generator, expressed in the branch’s own units: branch position is measured in expected substitutions of the scaled class, so the predicted trajectory is obtained by integrating \(dp/du = pQ / g(p)\), where \(g(p)\) is the class flux under the current state distribution. This is a demanding construction: the two fitness profiles used here are nearly disjoint, so the class flux starts at several hundred times its equilibrium value and the branch begins with a burst of substitutions. (This test found the defect fixed in version 1.2.1: the exact rescaler’s knot table could not resolve that burst and misplaced early event positions.)
Criterion. Under both rescale methods
("segments" and "exact"): cumulative class
events per site track branch position to within \(0.02 + 4\sqrt{u/n}\) at every checkpoint
(the count’s sampling standard deviation grows with \(u\), so the bound scales with the
checkpoint; the 1.2.1 defect exceeded the early-checkpoint bounds
five-fold), and no Monte-Carlo chi-square comparison of the observed
amino-acid distribution against the predicted trajectory is rejected at
family-wise alpha 0.01 after Holm adjustment across the twelve
checkpoints.
mA <- MutationSelection(1000, 1e-8, hky, psi_1, scaling_type = "non-synonymous")
mB <- MutationSelection(1000, 1e-8, hky, psi_2, scaling_type = "non-synonymous")
Q <- mB$transition
p0 <- as.numeric(mA$equilibrium)
piB <- as.numeric(mB$equilibrium)
W <- Q; W[outer(aa_of, aa_of, "==")] <- 0; diag(W) <- 0
w <- rowSums(W) # non-synonymous outflux per state
# Predicted state distribution as a function of branch position u:
# fourth-order Runge-Kutta on dp/du = pQ / g(p).
predict_traj <- function(p0, Q, w, L, du = 0.002) {
f <- function(p) as.numeric(p %*% Q) / sum(p * w)
n <- ceiling(L / du)
P <- matrix(NA, n + 1, length(p0)); P[1, ] <- p0
p <- p0
for (k in 1:n) {
k1 <- f(p); k2 <- f(p + du/2 * k1); k3 <- f(p + du/2 * k2); k4 <- f(p + du * k3)
p <- p + du/6 * (k1 + 2*k2 + 2*k3 + k4)
p[p < 0] <- 0; p <- p / sum(p)
P[k + 1, ] <- p
}
list(u = seq(0, n * du, by = du), P = P)
}
L <- 8
traj <- predict_traj(p0, Q, w, L)
pred_at <- function(u) traj$P[which.min(abs(traj$u - u)), ]
treeT <- tempfile(fileext = ".newick"); writeLines(sprintf("(A:%f,B:0.01);", L), treeT)
modeT <- tempfile(fileext = ".newick"); writeLines("(A:1,B:0);", modeT)
pT <- Phylogeny(treeT); mT <- Phylogeny(modeT, type = "mode")
checkpoints <- c(0.25, 0.5, 1, 2, 4, 8)
run_method <- function(method) {
root <- sample_sequence(mA, n_sites)
sim <- simulate_over_interval_phylogeny(pT, mT, list(mA, mB), root,
start_mode = 0, rescale_method = method)
nodeA <- sim$intervals$node[sim$intervals$state == 1]
subA <- sim$substitutions[sim$substitutions$node %in% nodeA, ]
subA <- subA[order(subA$site, subA$time), ]
ns_time <- subA$time[aa_of[subA$from + 1] != aa_of[subA$to + 1]]
state_at <- function(u) {
st <- root$index[, 1]
sel <- subA[subA$time <= u, ]
if (nrow(sel)) {
last <- sel[!duplicated(sel$site, fromLast = TRUE), ]
st[last$site + 1] <- last$to
}
st
}
rows <- lapply(checkpoints, function(u) {
emp <- tabulate(state_at(u) + 1L, nbins = length(p0))
pr <- pred_at(u)
emp_aa <- codon_to_aa(emp); pr_aa <- codon_to_aa(pr)
keep <- pr_aa * n_sites >= 5
chi <- chisq.test(c(emp_aa[keep], sum(emp_aa[!keep])),
p = c(pr_aa[keep], sum(pr_aa[!keep])),
rescale.p = TRUE, simulate.p.value = TRUE, B = 5000)
data.frame(method = method, u = u,
cum_class = sum(ns_time <= u) / n_sites,
p = chi$p.value,
tv_obs_pred = tv(emp_aa / n_sites, pr_aa),
tv_pred_eqB = tv(pr_aa, codon_to_aa(piB)))
})
list(table = do.call(rbind, rows), state_at = state_at)
}
res_seg <- run_method("segments")
res_ex <- run_method("exact")
t4 <- rbind(res_seg$table, res_ex$table)
t4$p_holm <- p.adjust(t4$p, method = "holm")
knitr::kable(t4, digits = 4)
| method | u | cum_class | p | tv_obs_pred | tv_pred_eqB | p_holm |
|---|---|---|---|---|---|---|
| segments | 0.25 | 0.2393 | 0.8044 | 0.0198 | 0.8524 | 1.0000 |
| segments | 0.50 | 0.4873 | 0.8540 | 0.0198 | 0.7805 | 1.0000 |
| segments | 1.00 | 0.9997 | 0.8340 | 0.0072 | 0.6777 | 1.0000 |
| segments | 2.00 | 2.0400 | 0.2460 | 0.0165 | 0.4595 | 1.0000 |
| segments | 4.00 | 4.0340 | 0.2667 | 0.0214 | 0.1908 | 1.0000 |
| segments | 8.00 | 8.0170 | 0.0016 | 0.0293 | 0.0648 | 0.0192 |
| exact | 0.25 | 0.2313 | 0.1298 | 0.0244 | 0.8524 | 1.0000 |
| exact | 0.50 | 0.4817 | 0.0920 | 0.0236 | 0.7805 | 0.8398 |
| exact | 1.00 | 0.9947 | 0.0420 | 0.0181 | 0.6777 | 0.4619 |
| exact | 2.00 | 2.0360 | 0.8254 | 0.0066 | 0.4595 | 1.0000 |
| exact | 4.00 | 4.0560 | 0.5859 | 0.0091 | 0.1908 | 1.0000 |
| exact | 8.00 | 8.0117 | 0.0840 | 0.0185 | 0.0648 | 0.8398 |
The distance from the new equilibrium decays along the branch, and the observed distributions (points) follow the predicted trajectory (line) under both methods:
u_dense <- seq(0, L, by = 0.1)
tv_pred <- sapply(u_dense, function(u) tv(codon_to_aa(pred_at(u)), codon_to_aa(piB)))
plot(u_dense, tv_pred, type = "l", lwd = 2,
xlab = "Branch position (expected non-synonymous substitutions per site)",
ylab = "Total variation distance from the new equilibrium")
u_pts <- seq(0.1, L, by = 0.26)
for (r in list(list(res_seg, "#E84B2A", 19), list(res_ex, "#2166AC", 1))) {
tv_obs <- sapply(u_pts, function(u)
tv(codon_to_aa(tabulate(r[[1]]$state_at(u) + 1L, nbins = length(p0))) / n_sites,
codon_to_aa(piB)))
points(u_pts, tv_obs, col = r[[2]], pch = r[[3]])
}
legend("topright", bty = "n",
c("predicted trajectory", "observed (segments)", "observed (exact)"),
lwd = c(2, NA, NA), pch = c(NA, 19, 1), col = c("black", "#E84B2A", "#2166AC"))
Amino-acid distributions at the start, in mid-transient, and at the end of the branch — predicted against observed (segments method):
plot_freqs(data.frame(
start_pred = codon_to_aa(p0),
u0.5_pred = codon_to_aa(pred_at(0.5)),
u0.5_obs = codon_to_aa(tabulate(res_seg$state_at(0.5) + 1L, nbins = length(p0))) / n_sites,
u8_pred = codon_to_aa(pred_at(8)),
u8_obs = codon_to_aa(tabulate(res_seg$state_at(8) + 1L, nbins = length(p0))) / n_sites,
eq_B = codon_to_aa(piB)),
c("start", "u=0.5 pred", "u=0.5 obs", "u=8 pred", "u=8 obs", "equilibrium B"))
calib_tol <- 0.02 + 4 * sqrt(t4$u / n_sites)
calib_ok <- all(abs(t4$cum_class - t4$u) < calib_tol)
p_ok <- all(t4$p_holm > 0.01)
verdict("Test 4", calib_ok && p_ok,
sprintf("max |cumulative class events - u| / tolerance = %.2f (criterion < 1); raw chi-square p range %.3f-%.3f, minimum Holm-adjusted p %.3f (criterion > 0.01)",
max(abs(t4$cum_class - t4$u) / calib_tol), min(t4$p),
max(t4$p), min(t4$p_holm)))
Test 4: PASS — max |cumulative class events - u| / tolerance = 0.34 (criterion < 1); raw chi-square p range 0.002-0.854, minimum Holm-adjusted p 0.019 (criterion > 0.01)
for (id in names(verdicts)) {
cat(sprintf("- **%s: %s**\n", id, ifelse(verdicts[[id]], "PASS", "FAIL")))
}
cat(sprintf("\n**Overall: %s** (PalantiR %s, seed %d, %d sites in standard tests; %d in tree-level dS tests)\n",
ifelse(all(unlist(verdicts)), "PASS", "FAIL"),
as.character(packageVersion("PalantiR")), params$seed, n_sites, n_ds))
Overall: PASS (PalantiR 1.3.0, seed 20260821, 3000 sites in standard tests; 12000 in tree-level dS tests)