use crate::error::{Result, TimeSeriesError};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnlineReconcileMethod {
MinT,
BottomUp,
TopDown,
OLS,
}
impl Default for OnlineReconcileMethod {
fn default() -> Self {
Self::MinT
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct OnlineReconciliationConfig {
pub method: OnlineReconcileMethod,
pub forgetting_factor: f64,
pub regularization: f64,
pub n_bottom: usize,
pub n_total: usize,
}
impl Default for OnlineReconciliationConfig {
fn default() -> Self {
Self {
method: OnlineReconcileMethod::default(),
forgetting_factor: 0.99,
regularization: 1e-6,
n_bottom: 4,
n_total: 7,
}
}
}
#[derive(Debug, Clone)]
pub struct ReconciliationResult {
pub reconciled: Vec<f64>,
pub residuals: Vec<f64>,
pub coherent: bool,
}
pub struct OnlineReconciler {
config: OnlineReconciliationConfig,
s_matrix: Vec<f64>,
covariance: Vec<f64>,
n_observations: usize,
}
impl OnlineReconciler {
pub fn new(config: OnlineReconciliationConfig, s_matrix: Vec<f64>) -> Result<Self> {
let expected = config.n_total * config.n_bottom;
if s_matrix.len() != expected {
return Err(TimeSeriesError::InvalidInput(format!(
"s_matrix length {} != n_total({}) * n_bottom({})",
s_matrix.len(),
config.n_total,
config.n_bottom,
)));
}
let n = config.n_total;
let mut covariance = vec![0.0_f64; n * n];
for i in 0..n {
covariance[i * n + i] = 1.0;
}
Ok(Self {
config,
s_matrix,
covariance,
n_observations: 0,
})
}
pub fn update(&mut self, forecast_errors: &[f64]) -> Result<()> {
let n = self.config.n_total;
if forecast_errors.len() != n {
return Err(TimeSeriesError::InvalidInput(format!(
"forecast_errors length {} != n_total {}",
forecast_errors.len(),
n,
)));
}
let lam = self.config.forgetting_factor;
let one_minus_lam = 1.0 - lam;
for i in 0..n {
for j in 0..n {
let outer = forecast_errors[i] * forecast_errors[j];
self.covariance[i * n + j] =
lam * self.covariance[i * n + j] + one_minus_lam * outer;
}
}
self.n_observations += 1;
Ok(())
}
pub fn reconcile(&self, y_hat: &[f64]) -> Result<ReconciliationResult> {
let n = self.config.n_total;
if y_hat.len() != n {
return Err(TimeSeriesError::InvalidInput(format!(
"y_hat length {} != n_total {}",
y_hat.len(),
n,
)));
}
let reconciled = match self.config.method {
OnlineReconcileMethod::MinT => self.mint_reconcile(y_hat),
OnlineReconcileMethod::OLS => self.ols_reconcile(y_hat),
OnlineReconcileMethod::BottomUp => self.bottom_up_reconcile(y_hat),
OnlineReconcileMethod::TopDown => self.top_down_reconcile(y_hat),
_ => self.mint_reconcile(y_hat),
};
let residuals = reconciled
.iter()
.zip(y_hat.iter())
.map(|(r, h)| r - h)
.collect::<Vec<_>>();
let coherent = self.check_coherence(&reconciled);
Ok(ReconciliationResult {
reconciled,
residuals,
coherent,
})
}
fn mint_reconcile(&self, y_hat: &[f64]) -> Vec<f64> {
let nt = self.config.n_total;
let nb = self.config.n_bottom;
let mut w_reg = self.covariance.clone();
for i in 0..nt {
w_reg[i * nt + i] += self.config.regularization;
}
let w_inv = match Self::invert_symmetric(&w_reg, nt) {
Some(m) => m,
None => {
let mut id = vec![0.0_f64; nt * nt];
for i in 0..nt {
id[i * nt + i] = 1.0;
}
id
}
};
let st_winv = Self::mat_mul_t_left(&self.s_matrix, &w_inv, nb, nt, nt, nt);
let a = Self::mat_mul(&st_winv, &self.s_matrix, nb, nt, nt, nb);
let mut a_reg = a.clone();
for i in 0..nb {
a_reg[i * nb + i] += self.config.regularization;
}
let a_inv = match Self::invert_symmetric(&a_reg, nb) {
Some(m) => m,
None => {
return self.ols_reconcile(y_hat);
}
};
let b = Self::mat_vec_mul(&w_inv, y_hat, nt, nt);
let c = Self::mat_t_vec_mul(&self.s_matrix, &b, nt, nb);
let d = Self::mat_vec_mul(&a_inv, &c, nb, nb);
Self::mat_vec_mul(&self.s_matrix, &d, nt, nb)
}
fn ols_reconcile(&self, y_hat: &[f64]) -> Vec<f64> {
let nt = self.config.n_total;
let nb = self.config.n_bottom;
let a = Self::mat_mul_t_left_identity(&self.s_matrix, nb, nt);
let mut a_reg = a.clone();
for i in 0..nb {
a_reg[i * nb + i] += self.config.regularization;
}
let a_inv = match Self::invert_symmetric(&a_reg, nb) {
Some(m) => m,
None => {
return self.bottom_up_reconcile(y_hat);
}
};
let c = Self::mat_t_vec_mul(&self.s_matrix, y_hat, nt, nb);
let d = Self::mat_vec_mul(&a_inv, &c, nb, nb);
Self::mat_vec_mul(&self.s_matrix, &d, nt, nb)
}
fn bottom_up_reconcile(&self, y_hat: &[f64]) -> Vec<f64> {
let nt = self.config.n_total;
let nb = self.config.n_bottom;
let bottom: Vec<f64> = if nt > nb {
y_hat[nt - nb..].to_vec()
} else {
y_hat.to_vec()
};
Self::mat_vec_mul(&self.s_matrix, &bottom, nt, nb)
}
fn top_down_reconcile(&self, y_hat: &[f64]) -> Vec<f64> {
let nt = self.config.n_total;
let nb = self.config.n_bottom;
let total = y_hat[0];
let prop = 1.0 / nb as f64;
let bottom: Vec<f64> = (0..nb).map(|_| total * prop).collect();
Self::mat_vec_mul(&self.s_matrix, &bottom, nt, nb)
}
fn check_coherence(&self, reconciled: &[f64]) -> bool {
let nt = self.config.n_total;
let nb = self.config.n_bottom;
if nt == 0 || nb == 0 || reconciled.len() != nt {
return false;
}
let bottom: Vec<f64> = if nt > nb {
reconciled[nt - nb..].to_vec()
} else {
reconciled.to_vec()
};
let recon_check = Self::mat_vec_mul(&self.s_matrix, &bottom, nt, nb);
let tol = 1e-6;
for (a, b) in reconciled.iter().zip(recon_check.iter()) {
let denom = a.abs().max(1.0);
if (a - b).abs() / denom > tol {
return false;
}
}
true
}
fn invert_symmetric(a: &[f64], n: usize) -> Option<Vec<f64>> {
let mut aug = vec![0.0_f64; n * n * 2];
for i in 0..n {
for j in 0..n {
aug[i * 2 * n + j] = a[i * n + j];
}
aug[i * 2 * n + n + i] = 1.0;
}
for col in 0..n {
let mut pivot = None;
let mut max_val = 0.0_f64;
for row in col..n {
let v = aug[row * 2 * n + col].abs();
if v > max_val {
max_val = v;
pivot = Some(row);
}
}
let pivot = pivot?;
if aug[pivot * 2 * n + col].abs() < 1e-14 {
return None;
}
if pivot != col {
for j in 0..(2 * n) {
aug.swap(col * 2 * n + j, pivot * 2 * n + j);
}
}
let diag = aug[col * 2 * n + col];
for j in 0..(2 * n) {
aug[col * 2 * n + j] /= diag;
}
for row in 0..n {
if row == col {
continue;
}
let factor = aug[row * 2 * n + col];
for j in 0..(2 * n) {
let delta = factor * aug[col * 2 * n + j];
aug[row * 2 * n + j] -= delta;
}
}
}
let mut inv = vec![0.0_f64; n * n];
for i in 0..n {
for j in 0..n {
inv[i * n + j] = aug[i * 2 * n + n + j];
}
}
Some(inv)
}
fn mat_mul(a: &[f64], b: &[f64], ra: usize, ca: usize, rb: usize, cb: usize) -> Vec<f64> {
debug_assert_eq!(ca, rb, "inner dimensions must match");
let _ = rb; let mut c = vec![0.0_f64; ra * cb];
for i in 0..ra {
for k in 0..ca {
let aik = a[i * ca + k];
for j in 0..cb {
c[i * cb + j] += aik * b[k * cb + j];
}
}
}
c
}
fn mat_mul_t_left(
a: &[f64],
b: &[f64],
ca: usize,
ra: usize,
_rb: usize,
cb: usize,
) -> Vec<f64> {
let mut c = vec![0.0_f64; ca * cb];
for k in 0..ra {
for i in 0..ca {
let aki = a[k * ca + i];
for j in 0..cb {
c[i * cb + j] += aki * b[k * cb + j];
}
}
}
c
}
fn mat_mul_t_left_identity(s: &[f64], nb: usize, nt: usize) -> Vec<f64> {
let mut c = vec![0.0_f64; nb * nb];
for k in 0..nt {
for i in 0..nb {
let ski = s[k * nb + i];
for j in 0..nb {
c[i * nb + j] += ski * s[k * nb + j];
}
}
}
c
}
fn mat_vec_mul(a: &[f64], x: &[f64], ra: usize, ca: usize) -> Vec<f64> {
let mut y = vec![0.0_f64; ra];
for i in 0..ra {
for j in 0..ca {
y[i] += a[i * ca + j] * x[j];
}
}
y
}
fn mat_t_vec_mul(a: &[f64], x: &[f64], ra: usize, ca: usize) -> Vec<f64> {
let mut y = vec![0.0_f64; ca];
for i in 0..ra {
for j in 0..ca {
y[j] += a[i * ca + j] * x[i];
}
}
y
}
pub fn n_observations(&self) -> usize {
self.n_observations
}
pub fn covariance(&self) -> &[f64] {
&self.covariance
}
pub fn config(&self) -> &OnlineReconciliationConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_s_matrix() -> Vec<f64> {
#[rustfmt::skip]
let s = vec![
1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 1.0,
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
];
s
}
fn default_config() -> OnlineReconciliationConfig {
OnlineReconciliationConfig {
method: OnlineReconcileMethod::BottomUp,
n_total: 7,
n_bottom: 4,
..Default::default()
}
}
#[test]
fn test_config_default() {
let cfg = OnlineReconciliationConfig::default();
assert_eq!(cfg.n_bottom, 4);
assert_eq!(cfg.n_total, 7);
assert!((cfg.forgetting_factor - 0.99).abs() < 1e-10);
assert_eq!(cfg.method, OnlineReconcileMethod::MinT);
}
#[test]
fn test_bottom_up_aggregate() {
let cfg = default_config();
let s = default_s_matrix();
let rec = OnlineReconciler::new(cfg, s).expect("new should succeed");
let y_hat = vec![0.0_f64, 0.0, 0.0, 2.0, 3.0, 5.0, 7.0];
let res = rec.reconcile(&y_hat).expect("reconcile should succeed");
assert!((res.reconciled[0] - 17.0).abs() < 1e-9, "total mismatch");
assert!((res.reconciled[1] - 5.0).abs() < 1e-9, "A mismatch");
assert!((res.reconciled[2] - 12.0).abs() < 1e-9, "B mismatch");
}
#[test]
fn test_mint_constraints_satisfied() {
let cfg = OnlineReconciliationConfig {
method: OnlineReconcileMethod::MinT,
n_total: 7,
n_bottom: 4,
..Default::default()
};
let s = default_s_matrix();
let rec = OnlineReconciler::new(cfg, s).expect("new should succeed");
let y_hat = vec![20.0_f64, 8.0, 12.0, 2.0, 3.0, 5.0, 7.0];
let res = rec.reconcile(&y_hat).expect("reconcile should succeed");
let n_total = 7;
let n_bottom = 4;
let s_mat = default_s_matrix();
let bottom = &res.reconciled[n_total - n_bottom..];
for (i, r_i) in res.reconciled.iter().enumerate() {
let expected: f64 = (0..n_bottom)
.map(|j| s_mat[i * n_bottom + j] * bottom[j])
.sum();
assert!(
(r_i - expected).abs() < 1e-6,
"constraint not satisfied at row {i}: {r_i} != {expected}"
);
}
assert!(res.coherent);
}
#[test]
fn test_ols_reconcile() {
let cfg = OnlineReconciliationConfig {
method: OnlineReconcileMethod::OLS,
n_total: 7,
n_bottom: 4,
..Default::default()
};
let s = default_s_matrix();
let rec = OnlineReconciler::new(cfg, s).expect("new should succeed");
let y_hat = vec![20.0_f64, 8.0, 12.0, 2.0, 3.0, 5.0, 7.0];
let res = rec.reconcile(&y_hat).expect("reconcile should succeed");
assert_eq!(res.reconciled.len(), 7);
assert!(res.coherent);
}
#[test]
fn test_update_changes_covariance() {
let cfg = OnlineReconciliationConfig {
method: OnlineReconcileMethod::MinT,
n_total: 7,
n_bottom: 4,
..Default::default()
};
let s = default_s_matrix();
let mut rec = OnlineReconciler::new(cfg, s).expect("new should succeed");
let cov_before: Vec<f64> = rec.covariance().to_vec();
let errors = vec![0.5_f64, -0.3, 0.1, 0.2, -0.1, 0.4, -0.2];
rec.update(&errors).expect("update should succeed");
let cov_after = rec.covariance();
assert!(
cov_before[0 * 7 + 1] != cov_after[0 * 7 + 1],
"off-diagonal should change"
);
assert_eq!(rec.n_observations(), 1);
}
#[test]
fn test_coherent_flag() {
let cfg = OnlineReconciliationConfig {
method: OnlineReconcileMethod::BottomUp,
n_total: 7,
n_bottom: 4,
..Default::default()
};
let s = default_s_matrix();
let rec = OnlineReconciler::new(cfg, s).expect("new should succeed");
let y_hat = vec![0.0_f64, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0];
let res = rec.reconcile(&y_hat).expect("reconcile should succeed");
assert!(res.coherent, "bottom-up should always be coherent");
}
}