use std::fmt;
use std::sync::OnceLock;
use num_integer::Integer;
use num_traits::{One, Signed, ToPrimitive, Zero};
use crate::base::errors::SymplexError;
use crate::base::graph::strongly_connected_components;
use crate::domains::exact_matrix::QMatrix;
use super::common::invalid;
use super::data::Q;
use super::sample::Rng;
fn failed(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::computation_failed(op, reason)
}
fn mul_square(a: &QMatrix, b: &QMatrix) -> QMatrix {
let n = a.nrows();
QMatrix::from_fn(n, n, |i, j| {
(0..n).fold(Q::zero(), |acc, k| acc + a.get(i, k) * b.get(k, j))
})
}
fn pick(m: &QMatrix, rows: &[usize], cols: &[usize]) -> QMatrix {
QMatrix::from_fn(rows.len(), cols.len(), |i, j| {
m.get(rows[i], cols[j]).clone()
})
}
#[derive(Clone)]
pub struct MarkovChain {
p: QMatrix,
labels: Option<Vec<String>>,
classes: OnceLock<Vec<Vec<usize>>>,
}
impl PartialEq for MarkovChain {
fn eq(&self, other: &Self) -> bool {
self.p == other.p && self.labels == other.labels
}
}
impl Eq for MarkovChain {}
impl fmt::Debug for MarkovChain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MarkovChain")
.field("p", &self.p)
.field("labels", &self.labels)
.finish()
}
}
impl MarkovChain {
pub fn new(p: QMatrix) -> Result<Self, SymplexError> {
const OP: &str = "MarkovChain::new";
if !p.is_square() {
return Err(invalid(
OP,
format!(
"a transition matrix must be square, got {}×{}",
p.nrows(),
p.ncols()
),
));
}
for (i, row) in p.rows().enumerate() {
if let Some(v) = row.iter().find(|v| v.is_negative()) {
return Err(invalid(OP, format!("negative entry {v} in row {i}")));
}
let sum = row.iter().fold(Q::zero(), |acc, v| acc + v);
if !sum.is_one() {
return Err(invalid(OP, format!("row {i} sums to {sum}, not 1")));
}
}
Ok(MarkovChain {
p,
labels: None,
classes: OnceLock::new(),
})
}
pub fn with_labels(p: QMatrix, labels: Vec<String>) -> Result<Self, SymplexError> {
if labels.len() != p.nrows() {
return Err(invalid(
"MarkovChain::with_labels",
format!("{} labels for {} states", labels.len(), p.nrows()),
));
}
let mut chain = Self::new(p)?;
chain.labels = Some(labels);
Ok(chain)
}
pub fn transition_matrix(&self) -> &QMatrix {
&self.p
}
pub fn n_states(&self) -> usize {
self.p.nrows()
}
pub fn labels(&self) -> Option<&[String]> {
self.labels.as_deref()
}
pub fn state_index(&self, label: &str) -> Option<usize> {
self.labels
.as_ref()
.and_then(|l| l.iter().position(|s| s == label))
}
fn check_state(&self, op: &'static str, state: usize) -> Result<(), SymplexError> {
if state >= self.n_states() {
return Err(invalid(
op,
format!("state {state} out of range for {} states", self.n_states()),
));
}
Ok(())
}
pub fn n_step(&self, k: usize) -> QMatrix {
let mut result = QMatrix::identity(self.n_states());
let mut base = self.p.clone();
let mut e = k;
while e > 0 {
if e & 1 == 1 {
result = mul_square(&result, &base);
}
e >>= 1;
if e > 0 {
base = mul_square(&base, &base);
}
}
result
}
pub fn distribution_after(&self, initial: &[Q], k: usize) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "MarkovChain::distribution_after";
let n = self.n_states();
if initial.len() != n {
return Err(invalid(
OP,
format!(
"the initial distribution has {} entries for {n} states",
initial.len()
),
));
}
if initial.iter().any(|v| v.is_negative()) {
return Err(invalid(OP, "the initial distribution has a negative entry"));
}
let total = initial.iter().fold(Q::zero(), |acc, v| acc + v);
if !total.is_one() {
return Err(invalid(
OP,
format!("the initial distribution sums to {total}, not 1"),
));
}
let pk = self.n_step(k);
Ok((0..n)
.map(|j| (0..n).fold(Q::zero(), |acc, i| acc + &initial[i] * pk.get(i, j)))
.collect())
}
fn reachability(&self) -> Vec<Vec<bool>> {
let n = self.n_states();
let mut reach: Vec<Vec<bool>> = (0..n)
.map(|i| (0..n).map(|j| self.p.get(i, j).is_positive()).collect())
.collect();
for k in 0..n {
let via_k = reach[k].clone();
for row in reach.iter_mut() {
if !row[k] {
continue;
}
for (cell, &through) in row.iter_mut().zip(&via_k) {
if through {
*cell = true;
}
}
}
}
reach
}
fn classes(&self) -> &[Vec<usize>] {
self.classes.get_or_init(|| {
let n = self.n_states();
let adj: Vec<Vec<usize>> = (0..n)
.map(|i| (0..n).filter(|&j| self.p.get(i, j).is_positive()).collect())
.collect();
strongly_connected_components(&adj)
})
}
pub fn communication_classes(&self) -> Vec<Vec<usize>> {
self.classes().to_vec()
}
pub fn closed_classes(&self) -> Vec<Vec<usize>> {
let n = self.n_states();
self.classes()
.iter()
.filter(|class| {
class
.iter()
.all(|&i| (0..n).all(|j| class.contains(&j) || !self.p.get(i, j).is_positive()))
})
.cloned()
.collect()
}
pub fn transient_states(&self) -> Vec<usize> {
let closed = self.closed_classes();
(0..self.n_states())
.filter(|i| !closed.iter().any(|c| c.contains(i)))
.collect()
}
pub fn is_irreducible(&self) -> bool {
self.classes().len() == 1
}
pub fn period_of(&self, state: usize) -> Result<Option<usize>, SymplexError> {
self.check_state("MarkovChain::period_of", state)?;
let class = self
.classes()
.iter()
.find(|c| c.contains(&state))
.cloned()
.unwrap_or_else(|| vec![state]);
Ok(self.class_period(&class))
}
fn class_period(&self, class: &[usize]) -> Option<usize> {
let n = self.n_states();
let &start = class.first()?;
let mut dist: Vec<Option<usize>> = vec![None; n];
dist[start] = Some(0);
let mut queue = std::collections::VecDeque::from([start]);
while let Some(u) = queue.pop_front() {
let du = dist[u].unwrap_or(0);
for &v in class {
if self.p.get(u, v).is_positive() && dist[v].is_none() {
dist[v] = Some(du + 1);
queue.push_back(v);
}
}
}
let mut g = 0usize;
for &u in class {
for &v in class {
if !self.p.get(u, v).is_positive() {
continue;
}
if let (Some(du), Some(dv)) = (dist[u], dist[v]) {
g = g.gcd(&(du + 1).abs_diff(dv));
}
}
}
(g > 0).then_some(g)
}
pub fn is_aperiodic(&self) -> bool {
self.classes()
.iter()
.all(|c| self.class_period(c).is_none_or(|d| d == 1))
}
pub fn is_ergodic(&self) -> bool {
self.is_irreducible()
}
pub fn is_regular(&self) -> bool {
self.is_irreducible() && self.is_aperiodic()
}
pub fn stationary_distributions(&self) -> Vec<Vec<Q>> {
let n = self.n_states();
self.closed_classes()
.iter()
.filter_map(|class| {
let sub = pick(&self.p, class, class);
let m = class.len();
let a = sub.transpose().sub(&QMatrix::identity(m)).ok()?;
let basis = a.nullspace();
let v = basis.first()?.col(0);
let total = v.iter().fold(Q::zero(), |acc, x| acc + x);
if total.is_zero() {
return None;
}
let mut pi = vec![Q::zero(); n];
for (k, &state) in class.iter().enumerate() {
pi[state] = &v[k] / &total;
}
Some(pi)
})
.collect()
}
pub fn stationary_distribution(&self) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "MarkovChain::stationary_distribution";
let mut all = self.stationary_distributions();
match all.len() {
1 => all
.pop()
.ok_or_else(|| failed(OP, "no stationary distribution")),
k => Err(invalid(
OP,
format!(
"the chain has {k} closed classes, so its stationary distribution is not unique"
),
)),
}
}
pub fn absorbing_states(&self) -> Vec<usize> {
(0..self.n_states())
.filter(|&i| self.p.get(i, i).is_one())
.collect()
}
pub fn is_absorbing_chain(&self) -> bool {
let absorbing = self.absorbing_states();
if absorbing.is_empty() {
return false;
}
let reach = self.reachability();
(0..self.n_states()).all(|i| absorbing.iter().any(|&a| a == i || reach[i][a]))
}
fn absorbing_split(&self, op: &'static str) -> Result<(Vec<usize>, Vec<usize>), SymplexError> {
if !self.is_absorbing_chain() {
return Err(invalid(
op,
"not an absorbing chain (some state cannot reach an absorbing state)",
));
}
let absorbing = self.absorbing_states();
let transient: Vec<usize> = (0..self.n_states())
.filter(|i| !absorbing.contains(i))
.collect();
if transient.is_empty() {
return Err(invalid(
op,
"every state is absorbing; there are no transient states",
));
}
Ok((transient, absorbing))
}
pub fn fundamental_matrix(&self) -> Result<QMatrix, SymplexError> {
const OP: &str = "MarkovChain::fundamental_matrix";
let (transient, _) = self.absorbing_split(OP)?;
let q = pick(&self.p, &transient, &transient);
QMatrix::identity(transient.len())
.sub(&q)?
.inv()
.map_err(|e| failed(OP, format!("I − Q is singular: {e}")))
}
pub fn absorption_probabilities(&self) -> Result<QMatrix, SymplexError> {
let (transient, absorbing) =
self.absorbing_split("MarkovChain::absorption_probabilities")?;
let n = self.fundamental_matrix()?;
let r = pick(&self.p, &transient, &absorbing);
n.matmul(&r)
}
pub fn expected_steps_to_absorption(&self) -> Result<Vec<Q>, SymplexError> {
let n = self.fundamental_matrix()?;
Ok(n.rows()
.map(|row| row.iter().fold(Q::zero(), |acc, v| acc + v))
.collect())
}
fn check_target(&self, op: &'static str, target: &[usize]) -> Result<Vec<usize>, SymplexError> {
if target.is_empty() {
return Err(invalid(op, "the target set is empty"));
}
for &t in target {
self.check_state(op, t)?;
}
let mut sorted = target.to_vec();
sorted.sort_unstable();
sorted.dedup();
Ok(sorted)
}
pub fn hitting_probability(&self, target: &[usize]) -> Result<Vec<Q>, SymplexError> {
self.hitting_probability_of("MarkovChain::hitting_probability", target)
}
fn hitting_probability_of(
&self,
op: &'static str,
target: &[usize],
) -> Result<Vec<Q>, SymplexError> {
let target = self.check_target(op, target)?;
let n = self.n_states();
let reach = self.reachability();
let mut h = vec![Q::zero(); n];
for &t in &target {
h[t] = Q::one();
}
let unknown: Vec<usize> = (0..n)
.filter(|&i| !target.contains(&i) && target.iter().any(|&t| reach[i][t]))
.collect();
if unknown.is_empty() {
return Ok(h);
}
let a = QMatrix::identity(unknown.len()).sub(&pick(&self.p, &unknown, &unknown))?;
let b = QMatrix::col_vector(
unknown
.iter()
.map(|&i| {
target
.iter()
.fold(Q::zero(), |acc, &t| acc + self.p.get(i, t))
})
.collect(),
);
let sol = a.solve(&b).map_err(|e| {
failed(
op,
format!("the hitting-probability system is singular: {e}"),
)
})?;
for (k, &i) in unknown.iter().enumerate() {
h[i] = sol.get(k, 0).clone();
}
Ok(h)
}
pub fn expected_hitting_time(&self, target: &[usize]) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "MarkovChain::expected_hitting_time";
let target = self.check_target(OP, target)?;
let h = self.hitting_probability_of(OP, &target)?;
let n = self.n_states();
let infinite: Vec<usize> = (0..n).filter(|&i| !h[i].is_one()).collect();
if !infinite.is_empty() {
return Err(failed(
OP,
format!(
"states {infinite:?} reach the target with probability < 1, so their expected hitting time is infinite"
),
));
}
let unknown: Vec<usize> = (0..n).filter(|i| !target.contains(i)).collect();
let mut k = vec![Q::zero(); n];
if unknown.is_empty() {
return Ok(k);
}
let a = QMatrix::identity(unknown.len()).sub(&pick(&self.p, &unknown, &unknown))?;
let b = QMatrix::col_vector(vec![Q::one(); unknown.len()]);
let sol = a
.solve(&b)
.map_err(|e| failed(OP, format!("the hitting-time system is singular: {e}")))?;
for (idx, &i) in unknown.iter().enumerate() {
k[i] = sol.get(idx, 0).clone();
}
Ok(k)
}
pub fn fundamental_matrix_ergodic(&self) -> Result<QMatrix, SymplexError> {
const OP: &str = "MarkovChain::fundamental_matrix_ergodic";
if !self.is_irreducible() {
return Err(invalid(
OP,
"the ergodic fundamental matrix needs an irreducible chain",
));
}
let pi = self.stationary_distribution()?;
let n = self.n_states();
let w = QMatrix::from_fn(n, n, |_, j| pi[j].clone());
QMatrix::identity(n)
.sub(&self.p)?
.add(&w)?
.inv()
.map_err(|e| failed(OP, format!("I − P + W is singular: {e}")))
}
pub fn mean_first_passage_times(&self) -> Result<QMatrix, SymplexError> {
let z = self.fundamental_matrix_ergodic()?;
let pi = self.stationary_distribution()?;
let n = self.n_states();
Ok(QMatrix::from_fn(n, n, |i, j| {
if i == j || pi[j].is_zero() {
Q::zero()
} else {
(z.get(j, j) - z.get(i, j)) / &pi[j]
}
}))
}
pub fn mean_recurrence_times(&self) -> Result<Vec<Q>, SymplexError> {
if !self.is_irreducible() {
return Err(invalid(
"MarkovChain::mean_recurrence_times",
"mean recurrence times need an irreducible chain",
));
}
Ok(self
.stationary_distribution()?
.iter()
.map(|p| if p.is_zero() { Q::zero() } else { p.recip() })
.collect())
}
pub fn sample_path(
&self,
initial: usize,
steps: usize,
rng: &mut Rng,
) -> Result<Vec<usize>, SymplexError> {
self.check_state("MarkovChain::sample_path", initial)?;
let n = self.n_states();
let rows: Vec<Vec<f64>> = self
.p
.rows()
.map(|row| {
row.iter()
.map(|q| q.numer().to_f64().unwrap_or(0.0) / q.denom().to_f64().unwrap_or(1.0))
.collect()
})
.collect();
let mut path = Vec::with_capacity(steps + 1);
let mut state = initial;
path.push(state);
for _ in 0..steps {
let u = rng.next_f64();
let row = &rows[state];
let mut acc = 0.0;
let mut next = None;
for (j, &pj) in row.iter().enumerate() {
if pj <= 0.0 {
continue;
}
acc += pj;
if u < acc {
next = Some(j);
break;
}
}
state = next
.or_else(|| (0..n).rev().find(|&j| row[j] > 0.0))
.unwrap_or(state);
path.push(state);
}
Ok(path)
}
pub fn limiting_distribution(&self) -> Result<Option<Vec<Q>>, SymplexError> {
if !self.is_regular() {
return Ok(None);
}
self.stationary_distribution().map(Some)
}
}
impl fmt::Display for MarkovChain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(labels) = &self.labels else {
return write!(f, "{}", self.p);
};
let n = self.n_states();
let cells: Vec<Vec<String>> = self
.p
.rows()
.map(|row| row.iter().map(ToString::to_string).collect())
.collect();
let width = |j: usize| {
cells
.iter()
.map(|r| r[j].chars().count())
.max()
.unwrap_or(0)
};
let widths: Vec<usize> = (0..n).map(width).collect();
let label_width = labels.iter().map(|l| l.chars().count()).max().unwrap_or(0);
writeln!(f, "[")?;
for (i, (label, row)) in labels.iter().zip(&cells).enumerate() {
let pad = label_width - label.chars().count();
write!(f, " {}{label}: [", " ".repeat(pad))?;
for (j, cell) in row.iter().enumerate() {
if j > 0 {
write!(f, ", ")?;
}
write!(f, "{}{cell}", " ".repeat(widths[j] - cell.chars().count()))?;
}
write!(f, "]")?;
if i + 1 < n {
writeln!(f, ",")?;
} else {
writeln!(f)?;
}
}
write!(f, "]")
}
}