use crate::error::{ForecastError, Result};
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Node {
name: String,
children: Vec<usize>, parent: Option<usize>,
level: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReconciliationMethod {
BottomUp,
TopDown,
MiddleOut {
middle_level: usize,
},
MinTraceOls,
MinTraceShrink,
}
#[derive(Debug, Clone)]
pub struct HierarchyTree {
nodes: Vec<Node>,
name_to_idx: HashMap<String, usize>,
root: usize,
actuals: Option<HashMap<String, Vec<f64>>>,
residuals: Option<HashMap<String, Vec<f64>>>,
}
impl HierarchyTree {
pub fn new(edges: Vec<(&str, &[&str])>) -> Result<Self> {
if edges.is_empty() {
return Err(ForecastError::InvalidParameter(
"hierarchy must have at least one edge".into(),
));
}
let mut name_to_idx: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<Node> = Vec::new();
let ensure_node =
|name: &str, nodes: &mut Vec<Node>, map: &mut HashMap<String, usize>| -> usize {
if let Some(&idx) = map.get(name) {
idx
} else {
let idx = nodes.len();
nodes.push(Node {
name: name.to_string(),
children: Vec::new(),
parent: None,
level: 0,
});
map.insert(name.to_string(), idx);
idx
}
};
for (parent, children) in &edges {
let pidx = ensure_node(parent, &mut nodes, &mut name_to_idx);
for child in *children {
let cidx = ensure_node(child, &mut nodes, &mut name_to_idx);
if nodes[cidx].parent.is_some() {
return Err(ForecastError::InvalidParameter(format!(
"node '{}' has multiple parents",
child
)));
}
nodes[cidx].parent = Some(pidx);
nodes[pidx].children.push(cidx);
}
}
let roots: Vec<usize> = nodes
.iter()
.enumerate()
.filter(|(_, n)| n.parent.is_none())
.map(|(i, _)| i)
.collect();
if roots.len() != 1 {
return Err(ForecastError::InvalidParameter(format!(
"hierarchy must have exactly one root, found {}",
roots.len()
)));
}
let root = roots[0];
let mut queue = std::collections::VecDeque::new();
queue.push_back((root, 0usize));
while let Some((idx, level)) = queue.pop_front() {
nodes[idx].level = level;
for &c in &nodes[idx].children.clone() {
queue.push_back((c, level + 1));
}
}
Ok(Self {
nodes,
name_to_idx,
root,
actuals: None,
residuals: None,
})
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn children_of(&self, name: &str) -> Option<Vec<&str>> {
self.name_to_idx.get(name).map(|&idx| {
self.nodes[idx]
.children
.iter()
.map(|&c| self.nodes[c].name.as_str())
.collect()
})
}
pub fn set_actuals(&mut self, actuals: HashMap<String, Vec<f64>>) {
self.actuals = Some(actuals);
}
pub fn set_residuals(&mut self, residuals: HashMap<String, Vec<f64>>) {
self.residuals = Some(residuals);
}
pub fn node_names(&self) -> Vec<&str> {
let mut order = Vec::with_capacity(self.nodes.len());
let mut queue = std::collections::VecDeque::new();
queue.push_back(self.root);
while let Some(idx) = queue.pop_front() {
order.push(self.nodes[idx].name.as_str());
for &c in &self.nodes[idx].children {
queue.push_back(c);
}
}
order
}
fn leaves(&self) -> Vec<usize> {
self.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.children.is_empty())
.map(|(i, _)| i)
.collect()
}
pub fn reconcile(
&self,
base_forecasts: &[(String, Vec<f64>)],
method: ReconciliationMethod,
) -> Result<Vec<(String, Vec<f64>)>> {
let base_map: HashMap<&str, &Vec<f64>> = base_forecasts
.iter()
.map(|(name, vals)| (name.as_str(), vals))
.collect();
for node in &self.nodes {
if !base_map.contains_key(node.name.as_str()) {
return Err(ForecastError::InvalidParameter(format!(
"missing base forecast for node '{}'",
node.name
)));
}
}
let horizon = base_map[self.nodes[self.root].name.as_str()].len();
if horizon == 0 {
return Err(ForecastError::InvalidParameter(
"forecast horizon must be at least 1".into(),
));
}
for node in &self.nodes {
if base_map[node.name.as_str()].len() != horizon {
return Err(ForecastError::DimensionMismatch {
expected: horizon,
got: base_map[node.name.as_str()].len(),
});
}
}
match method {
ReconciliationMethod::BottomUp => self.bottom_up(&base_map, horizon),
ReconciliationMethod::TopDown => self.top_down(&base_map, horizon),
ReconciliationMethod::MiddleOut { middle_level } => {
self.middle_out(&base_map, horizon, middle_level)
}
ReconciliationMethod::MinTraceOls => self.min_trace_ols(&base_map, horizon),
ReconciliationMethod::MinTraceShrink => self.min_trace_shrink(&base_map, horizon),
}
}
fn bottom_up(
&self,
base_map: &HashMap<&str, &Vec<f64>>,
horizon: usize,
) -> Result<Vec<(String, Vec<f64>)>> {
let mut reconciled: Vec<Vec<f64>> = vec![vec![0.0; horizon]; self.nodes.len()];
for &leaf in &self.leaves() {
reconciled[leaf] = base_map[self.nodes[leaf].name.as_str()].clone();
}
let bfs_order = self.bfs_order();
for &idx in bfs_order.iter().rev() {
if !self.nodes[idx].children.is_empty() {
for h in 0..horizon {
reconciled[idx][h] = self.nodes[idx]
.children
.iter()
.map(|&c| reconciled[c][h])
.sum();
}
}
}
Ok(self.to_named_output(&reconciled))
}
fn top_down(
&self,
base_map: &HashMap<&str, &Vec<f64>>,
horizon: usize,
) -> Result<Vec<(String, Vec<f64>)>> {
let actuals = self.actuals.as_ref().ok_or_else(|| {
ForecastError::InvalidParameter(
"TopDown reconciliation requires historical actuals; call set_actuals() first"
.into(),
)
})?;
let leaves = self.leaves();
let root_actuals = actuals.get(&self.nodes[self.root].name).ok_or_else(|| {
ForecastError::InvalidParameter(format!(
"missing actuals for root node '{}'",
self.nodes[self.root].name
))
})?;
let mut proportions: Vec<f64> = Vec::with_capacity(leaves.len());
for &leaf in &leaves {
let leaf_actuals = actuals.get(&self.nodes[leaf].name).ok_or_else(|| {
ForecastError::InvalidParameter(format!(
"missing actuals for leaf node '{}'",
self.nodes[leaf].name
))
})?;
let n = leaf_actuals.len().min(root_actuals.len());
if n == 0 {
proportions.push(0.0);
continue;
}
let leaf_sum: f64 = leaf_actuals[..n].iter().sum();
let root_sum: f64 = root_actuals[..n].iter().sum();
if root_sum.abs() < 1e-15 {
proportions.push(0.0);
} else {
proportions.push(leaf_sum / root_sum);
}
}
let prop_sum: f64 = proportions.iter().sum();
if prop_sum > 1e-15 {
for p in &mut proportions {
*p /= prop_sum;
}
}
let top_forecast = base_map[self.nodes[self.root].name.as_str()];
let mut reconciled: Vec<Vec<f64>> = vec![vec![0.0; horizon]; self.nodes.len()];
for (i, &leaf) in leaves.iter().enumerate() {
for h in 0..horizon {
reconciled[leaf][h] = proportions[i] * top_forecast[h];
}
}
let bfs_order = self.bfs_order();
for &idx in bfs_order.iter().rev() {
if !self.nodes[idx].children.is_empty() {
for h in 0..horizon {
reconciled[idx][h] = self.nodes[idx]
.children
.iter()
.map(|&c| reconciled[c][h])
.sum();
}
}
}
Ok(self.to_named_output(&reconciled))
}
fn min_trace_ols(
&self,
base_map: &HashMap<&str, &Vec<f64>>,
horizon: usize,
) -> Result<Vec<(String, Vec<f64>)>> {
let n = self.nodes.len();
let leaves = self.leaves();
let m = leaves.len();
let mut s = vec![vec![0.0_f64; m]; n];
for (j, &leaf) in leaves.iter().enumerate() {
let mut cur = leaf;
s[cur][j] = 1.0;
while let Some(parent) = self.nodes[cur].parent {
s[parent][j] = 1.0;
cur = parent;
}
}
let mut sts = vec![vec![0.0; m]; m];
for i in 0..m {
for j in i..m {
let dot: f64 = (0..n).map(|k| s[k][i] * s[k][j]).sum();
sts[i][j] = dot;
sts[j][i] = dot;
}
}
let sts_flat: Vec<f64> = sts.iter().flat_map(|row| row.iter().copied()).collect();
let l = cholesky(m, &sts_flat)?;
let bfs_order = self.bfs_order();
let mut reconciled = vec![vec![0.0; horizon]; n];
for h in 0..horizon {
let base_vec: Vec<f64> = (0..n)
.map(|i| base_map[self.nodes[i].name.as_str()][h])
.collect();
let mut z = vec![0.0; m];
for j in 0..m {
z[j] = (0..n).map(|i| s[i][j] * base_vec[i]).sum();
}
let w = cholesky_solve_vec(m, &l, &z);
for i in 0..n {
reconciled[i][h] = (0..m).map(|j| s[i][j] * w[j]).sum();
}
}
Ok(bfs_order
.iter()
.map(|&idx| (self.nodes[idx].name.clone(), reconciled[idx].clone()))
.collect())
}
fn max_level(&self) -> usize {
self.nodes.iter().map(|n| n.level).max().unwrap_or(0)
}
fn nodes_at_level(&self, level: usize) -> Vec<usize> {
self.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.level == level)
.map(|(i, _)| i)
.collect()
}
fn leaf_descendants(&self, idx: usize) -> Vec<usize> {
if self.nodes[idx].children.is_empty() {
return vec![idx];
}
let mut leaves = Vec::new();
for &child in &self.nodes[idx].children {
leaves.extend(self.leaf_descendants(child));
}
leaves
}
fn middle_out(
&self,
base_map: &HashMap<&str, &Vec<f64>>,
horizon: usize,
middle_level: usize,
) -> Result<Vec<(String, Vec<f64>)>> {
let max_lvl = self.max_level();
if middle_level == 0 {
return self.top_down(base_map, horizon);
}
if middle_level >= max_lvl {
return self.bottom_up(base_map, horizon);
}
let actuals = self.actuals.as_ref().ok_or_else(|| {
ForecastError::InvalidParameter(
"MiddleOut reconciliation requires historical actuals; call set_actuals() first"
.into(),
)
})?;
let n = self.nodes.len();
let mut reconciled: Vec<Vec<f64>> = vec![vec![0.0; horizon]; n];
let middle_nodes = self.nodes_at_level(middle_level);
for &mid in &middle_nodes {
reconciled[mid] = base_map[self.nodes[mid].name.as_str()].clone();
}
for &mid in &middle_nodes {
let leaf_descs = self.leaf_descendants(mid);
let mid_actuals = actuals.get(&self.nodes[mid].name).ok_or_else(|| {
ForecastError::InvalidParameter(format!(
"missing actuals for middle node '{}'",
self.nodes[mid].name
))
})?;
let mut proportions = Vec::with_capacity(leaf_descs.len());
for &leaf in &leaf_descs {
let leaf_actuals = actuals.get(&self.nodes[leaf].name).ok_or_else(|| {
ForecastError::InvalidParameter(format!(
"missing actuals for leaf node '{}'",
self.nodes[leaf].name
))
})?;
let len = leaf_actuals.len().min(mid_actuals.len());
if len == 0 {
proportions.push(0.0);
continue;
}
let leaf_sum: f64 = leaf_actuals[..len].iter().sum();
let mid_sum: f64 = mid_actuals[..len].iter().sum();
if mid_sum.abs() < 1e-15 {
proportions.push(0.0);
} else {
proportions.push(leaf_sum / mid_sum);
}
}
let prop_sum: f64 = proportions.iter().sum();
if prop_sum > 1e-15 {
for p in &mut proportions {
*p /= prop_sum;
}
}
for (i, &leaf) in leaf_descs.iter().enumerate() {
for h in 0..horizon {
reconciled[leaf][h] = proportions[i] * reconciled[mid][h];
}
}
for lvl in (middle_level + 1..max_lvl).rev() {
for node_idx in 0..n {
if self.nodes[node_idx].level == lvl
&& !self.nodes[node_idx].children.is_empty()
{
if self.is_descendant_of(node_idx, mid) {
for h in 0..horizon {
reconciled[node_idx][h] = self.nodes[node_idx]
.children
.iter()
.map(|&c| reconciled[c][h])
.sum();
}
}
}
}
}
}
let bfs_order = self.bfs_order();
for &idx in bfs_order.iter().rev() {
if self.nodes[idx].level < middle_level && !self.nodes[idx].children.is_empty() {
for h in 0..horizon {
reconciled[idx][h] = self.nodes[idx]
.children
.iter()
.map(|&c| reconciled[c][h])
.sum();
}
}
}
Ok(self.to_named_output(&reconciled))
}
fn is_descendant_of(&self, node: usize, ancestor: usize) -> bool {
let mut cur = node;
while let Some(parent) = self.nodes[cur].parent {
if parent == ancestor {
return true;
}
cur = parent;
}
false
}
fn min_trace_shrink(
&self,
base_map: &HashMap<&str, &Vec<f64>>,
horizon: usize,
) -> Result<Vec<(String, Vec<f64>)>> {
let residuals = self.residuals.as_ref().ok_or_else(|| {
ForecastError::InvalidParameter(
"MinTraceShrink requires historical residuals; call set_residuals() first".into(),
)
})?;
let n = self.nodes.len();
let leaves = self.leaves();
let m = leaves.len();
let first_name = &self.nodes[0].name;
let res_first = residuals.get(first_name).ok_or_else(|| {
ForecastError::InvalidParameter(format!("missing residuals for node '{}'", first_name))
})?;
let t = res_first.len();
if t < 2 {
return Err(ForecastError::InvalidParameter(
"MinTraceShrink requires at least 2 residual observations".into(),
));
}
let mut res_matrix: Vec<Vec<f64>> = Vec::with_capacity(n);
for node in &self.nodes {
let r = residuals.get(&node.name).ok_or_else(|| {
ForecastError::InvalidParameter(format!(
"missing residuals for node '{}'",
node.name
))
})?;
if r.len() != t {
return Err(ForecastError::DimensionMismatch {
expected: t,
got: r.len(),
});
}
res_matrix.push(r.clone());
}
let means: Vec<f64> = res_matrix
.iter()
.map(|r| r.iter().sum::<f64>() / t as f64)
.collect();
let mut sample_cov = vec![vec![0.0; n]; n];
for i in 0..n {
for j in i..n {
let cov: f64 = (0..t)
.map(|k| (res_matrix[i][k] - means[i]) * (res_matrix[j][k] - means[j]))
.sum::<f64>()
/ (t - 1) as f64;
sample_cov[i][j] = cov;
sample_cov[j][i] = cov;
}
}
let diag: Vec<f64> = (0..n).map(|i| sample_cov[i][i]).collect();
let alpha = ledoit_wolf_alpha(&res_matrix, &sample_cov, &diag, t, n);
let mut sigma = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
sigma[i][j] = (1.0 - alpha) * sample_cov[i][j];
}
sigma[i][i] += alpha * diag[i];
}
let sigma_flat: Vec<f64> = sigma.iter().flat_map(|row| row.iter().copied()).collect();
let l_sigma = cholesky(n, &sigma_flat)?;
let mut s_mat = vec![vec![0.0_f64; m]; n];
for (j, &leaf) in leaves.iter().enumerate() {
let mut cur = leaf;
s_mat[cur][j] = 1.0;
while let Some(parent) = self.nodes[cur].parent {
s_mat[parent][j] = 1.0;
cur = parent;
}
}
let mut sigma_inv_s = vec![vec![0.0; m]; n];
for j in 0..m {
let col: Vec<f64> = (0..n).map(|i| s_mat[i][j]).collect();
let x = cholesky_solve_vec(n, &l_sigma, &col);
for i in 0..n {
sigma_inv_s[i][j] = x[i];
}
}
let mut st_sigma_inv_s = vec![vec![0.0; m]; m];
for i in 0..m {
for j in i..m {
let dot: f64 = (0..n).map(|k| s_mat[k][i] * sigma_inv_s[k][j]).sum();
st_sigma_inv_s[i][j] = dot;
st_sigma_inv_s[j][i] = dot;
}
}
let st_sigma_inv_s_flat: Vec<f64> = st_sigma_inv_s
.iter()
.flat_map(|row| row.iter().copied())
.collect();
let l_inner = cholesky(m, &st_sigma_inv_s_flat)?;
let bfs_order = self.bfs_order();
let mut reconciled = vec![vec![0.0; horizon]; n];
for h in 0..horizon {
let base_vec: Vec<f64> = (0..n)
.map(|i| base_map[self.nodes[i].name.as_str()][h])
.collect();
let z = cholesky_solve_vec(n, &l_sigma, &base_vec);
let mut w = vec![0.0; m];
for j in 0..m {
w[j] = (0..n).map(|i| s_mat[i][j] * z[i]).sum();
}
let v = cholesky_solve_vec(m, &l_inner, &w);
for i in 0..n {
reconciled[i][h] = (0..m).map(|j| s_mat[i][j] * v[j]).sum();
}
}
Ok(bfs_order
.iter()
.map(|&idx| (self.nodes[idx].name.clone(), reconciled[idx].clone()))
.collect())
}
fn bfs_order(&self) -> Vec<usize> {
let mut order = Vec::with_capacity(self.nodes.len());
let mut queue = std::collections::VecDeque::new();
queue.push_back(self.root);
while let Some(idx) = queue.pop_front() {
order.push(idx);
for &c in &self.nodes[idx].children {
queue.push_back(c);
}
}
order
}
fn to_named_output(&self, reconciled: &[Vec<f64>]) -> Vec<(String, Vec<f64>)> {
self.bfs_order()
.iter()
.map(|&idx| (self.nodes[idx].name.clone(), reconciled[idx].clone()))
.collect()
}
}
fn cholesky(n: usize, a: &[f64]) -> Result<Vec<f64>> {
let mut l = vec![0.0; n * n];
for i in 0..n {
for j in 0..=i {
let mut sum = 0.0;
for k in 0..j {
sum += l[i * n + k] * l[j * n + k];
}
if i == j {
let diag = a[i * n + i] - sum;
if diag <= 0.0 {
return Err(ForecastError::SingularMatrix(
"hierarchy summing matrix S'S is singular".into(),
));
}
l[i * n + j] = diag.sqrt();
} else {
l[i * n + j] = (a[i * n + j] - sum) / l[j * n + j];
}
}
}
Ok(l)
}
fn cholesky_solve_vec(n: usize, l: &[f64], b: &[f64]) -> Vec<f64> {
let mut z = vec![0.0; n];
for i in 0..n {
let mut s = 0.0;
for j in 0..i {
s += l[i * n + j] * z[j];
}
z[i] = (b[i] - s) / l[i * n + i];
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut s = 0.0;
for j in (i + 1)..n {
s += l[j * n + i] * x[j];
}
x[i] = (z[i] - s) / l[i * n + i];
}
x
}
fn ledoit_wolf_alpha(
res_matrix: &[Vec<f64>],
sample_cov: &[Vec<f64>],
diag: &[f64],
t: usize,
n: usize,
) -> f64 {
let tf = t as f64;
let means: Vec<f64> = res_matrix
.iter()
.map(|r| r.iter().sum::<f64>() / tf)
.collect();
let mut delta = 0.0;
for i in 0..n {
for j in 0..n {
let diff = sample_cov[i][j] - if i == j { diag[i] } else { 0.0 };
delta += diff * diff;
}
}
let mut gamma = 0.0;
for i in 0..n {
for j in 0..n {
let mut sum_sq = 0.0;
for k in 0..t {
let zi = res_matrix[i][k] - means[i];
let zj = res_matrix[j][k] - means[j];
let dev = zi * zj - sample_cov[i][j];
sum_sq += dev * dev;
}
gamma += sum_sq / tf;
}
}
if delta < 1e-30 {
return 1.0;
}
(gamma / (tf * delta)).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64, tol: f64) {
assert!(
(a - b).abs() < tol,
"expected {} ≈ {}, diff = {}",
a,
b,
(a - b).abs()
);
}
#[test]
fn simple_tree() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
assert_eq!(tree.nodes.len(), 3);
assert_eq!(tree.node_names(), vec!["Total", "A", "B"]);
}
#[test]
fn three_level_tree() {
let tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
assert_eq!(tree.nodes.len(), 7);
assert_eq!(tree.leaves().len(), 4);
}
#[test]
fn empty_edges_fails() {
assert!(HierarchyTree::new(vec![]).is_err());
}
#[test]
fn multiple_parents_fails() {
let result = HierarchyTree::new(vec![("A", &["C"]), ("B", &["C"])]);
assert!(result.is_err());
}
#[test]
fn multiple_roots_fails() {
let result = HierarchyTree::new(vec![("A", &["C"]), ("B", &["D"])]);
assert!(result.is_err());
}
#[test]
fn bottom_up_simple() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0, 110.0]),
("A".into(), vec![70.0, 75.0]),
("B".into(), vec![40.0, 45.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::BottomUp)
.unwrap();
assert_eq!(result[0].0, "Total");
approx_eq(result[0].1[0], 110.0, 1e-10);
approx_eq(result[0].1[1], 120.0, 1e-10);
assert_eq!(result[1].1, vec![70.0, 75.0]);
assert_eq!(result[2].1, vec![40.0, 45.0]);
}
#[test]
fn bottom_up_three_levels() {
let tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let base = vec![
("Total".into(), vec![999.0]), ("East".into(), vec![999.0]), ("West".into(), vec![999.0]), ("NY".into(), vec![10.0]),
("MA".into(), vec![20.0]),
("CA".into(), vec![30.0]),
("WA".into(), vec![40.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::BottomUp)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["East"][0], 30.0, 1e-10); approx_eq(map["West"][0], 70.0, 1e-10); approx_eq(map["Total"][0], 100.0, 1e-10); }
#[test]
fn top_down_simple() {
let mut tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let mut actuals = HashMap::new();
actuals.insert("Total".into(), vec![100.0, 100.0, 100.0]);
actuals.insert("A".into(), vec![60.0, 60.0, 60.0]);
actuals.insert("B".into(), vec![40.0, 40.0, 40.0]);
tree.set_actuals(actuals);
let base = vec![
("Total".into(), vec![200.0]),
("A".into(), vec![999.0]), ("B".into(), vec![999.0]), ];
let result = tree
.reconcile(&base, ReconciliationMethod::TopDown)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["A"][0], 120.0, 1e-10);
approx_eq(map["B"][0], 80.0, 1e-10);
approx_eq(map["Total"][0], 200.0, 1e-10);
}
#[test]
fn top_down_requires_actuals() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![50.0]),
("B".into(), vec![50.0]),
];
assert!(tree
.reconcile(&base, ReconciliationMethod::TopDown)
.is_err());
}
#[test]
fn mint_ols_coherent() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![55.0]),
("B".into(), vec![40.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceOls)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["Total"][0], map["A"][0] + map["B"][0], 1e-10);
}
#[test]
fn mint_ols_three_levels_coherent() {
let tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let base = vec![
("Total".into(), vec![100.0, 200.0]),
("East".into(), vec![55.0, 110.0]),
("West".into(), vec![50.0, 95.0]),
("NY".into(), vec![25.0, 55.0]),
("MA".into(), vec![28.0, 52.0]),
("CA".into(), vec![26.0, 48.0]),
("WA".into(), vec![22.0, 50.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceOls)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
for h in 0..2 {
approx_eq(map["East"][h], map["NY"][h] + map["MA"][h], 1e-10);
approx_eq(map["West"][h], map["CA"][h] + map["WA"][h], 1e-10);
approx_eq(map["Total"][h], map["East"][h] + map["West"][h], 1e-10);
}
}
#[test]
fn mint_ols_already_coherent_unchanged() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![60.0]),
("B".into(), vec![40.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceOls)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["Total"][0], 100.0, 1e-6);
approx_eq(map["A"][0], 60.0, 1e-6);
approx_eq(map["B"][0], 40.0, 1e-6);
}
#[test]
fn missing_node_forecast_fails() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![60.0]),
];
assert!(tree
.reconcile(&base, ReconciliationMethod::BottomUp)
.is_err());
}
#[test]
fn mismatched_horizon_fails() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0, 110.0]),
("A".into(), vec![60.0]), ("B".into(), vec![40.0, 45.0]),
];
assert!(tree
.reconcile(&base, ReconciliationMethod::BottomUp)
.is_err());
}
#[test]
fn zero_horizon_fails() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![]),
("A".into(), vec![]),
("B".into(), vec![]),
];
assert!(tree
.reconcile(&base, ReconciliationMethod::BottomUp)
.is_err());
}
#[test]
fn middle_out_level0_equals_top_down() {
let mut tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let mut actuals = HashMap::new();
actuals.insert("Total".into(), vec![100.0; 5]);
actuals.insert("East".into(), vec![60.0; 5]);
actuals.insert("West".into(), vec![40.0; 5]);
actuals.insert("NY".into(), vec![35.0; 5]);
actuals.insert("MA".into(), vec![25.0; 5]);
actuals.insert("CA".into(), vec![25.0; 5]);
actuals.insert("WA".into(), vec![15.0; 5]);
tree.set_actuals(actuals);
let base = vec![
("Total".into(), vec![200.0]),
("East".into(), vec![999.0]),
("West".into(), vec![999.0]),
("NY".into(), vec![999.0]),
("MA".into(), vec![999.0]),
("CA".into(), vec![999.0]),
("WA".into(), vec![999.0]),
];
let td = tree
.reconcile(&base, ReconciliationMethod::TopDown)
.unwrap();
let mo = tree
.reconcile(&base, ReconciliationMethod::MiddleOut { middle_level: 0 })
.unwrap();
let td_map: HashMap<&str, &Vec<f64>> = td.iter().map(|(k, v)| (k.as_str(), v)).collect();
let mo_map: HashMap<&str, &Vec<f64>> = mo.iter().map(|(k, v)| (k.as_str(), v)).collect();
for name in &["Total", "East", "West", "NY", "MA", "CA", "WA"] {
approx_eq(td_map[name][0], mo_map[name][0], 1e-10);
}
}
#[test]
fn middle_out_level_max_equals_bottom_up() {
let tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let base = vec![
("Total".into(), vec![999.0]),
("East".into(), vec![999.0]),
("West".into(), vec![999.0]),
("NY".into(), vec![10.0]),
("MA".into(), vec![20.0]),
("CA".into(), vec![30.0]),
("WA".into(), vec![40.0]),
];
let bu = tree
.reconcile(&base, ReconciliationMethod::BottomUp)
.unwrap();
let mo = tree
.reconcile(&base, ReconciliationMethod::MiddleOut { middle_level: 99 })
.unwrap();
let bu_map: HashMap<&str, &Vec<f64>> = bu.iter().map(|(k, v)| (k.as_str(), v)).collect();
let mo_map: HashMap<&str, &Vec<f64>> = mo.iter().map(|(k, v)| (k.as_str(), v)).collect();
for name in &["Total", "East", "West", "NY", "MA", "CA", "WA"] {
approx_eq(bu_map[name][0], mo_map[name][0], 1e-10);
}
}
#[test]
fn middle_out_level1_coherent() {
let mut tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let mut actuals = HashMap::new();
actuals.insert("Total".into(), vec![100.0; 5]);
actuals.insert("East".into(), vec![60.0; 5]);
actuals.insert("West".into(), vec![40.0; 5]);
actuals.insert("NY".into(), vec![35.0; 5]);
actuals.insert("MA".into(), vec![25.0; 5]);
actuals.insert("CA".into(), vec![25.0; 5]);
actuals.insert("WA".into(), vec![15.0; 5]);
tree.set_actuals(actuals);
let base = vec![
("Total".into(), vec![999.0]),
("East".into(), vec![120.0]),
("West".into(), vec![80.0]),
("NY".into(), vec![999.0]),
("MA".into(), vec![999.0]),
("CA".into(), vec![999.0]),
("WA".into(), vec![999.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MiddleOut { middle_level: 1 })
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["East"][0], map["NY"][0] + map["MA"][0], 1e-10);
approx_eq(map["West"][0], map["CA"][0] + map["WA"][0], 1e-10);
approx_eq(map["Total"][0], map["East"][0] + map["West"][0], 1e-10);
approx_eq(map["East"][0], 120.0, 1e-10);
approx_eq(map["West"][0], 80.0, 1e-10);
approx_eq(map["Total"][0], 200.0, 1e-10);
approx_eq(map["NY"][0], 120.0 * 35.0 / 60.0, 1e-10);
approx_eq(map["MA"][0], 120.0 * 25.0 / 60.0, 1e-10);
approx_eq(map["CA"][0], 80.0 * 25.0 / 40.0, 1e-10);
approx_eq(map["WA"][0], 80.0 * 15.0 / 40.0, 1e-10);
}
#[test]
fn middle_out_requires_actuals() {
let tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("East".into(), vec![50.0]),
("West".into(), vec![50.0]),
("NY".into(), vec![25.0]),
("MA".into(), vec![25.0]),
("CA".into(), vec![25.0]),
("WA".into(), vec![25.0]),
];
assert!(tree
.reconcile(&base, ReconciliationMethod::MiddleOut { middle_level: 1 },)
.is_err());
}
#[test]
fn mint_shrink_coherent() {
let mut tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let mut residuals = HashMap::new();
let ra: Vec<f64> = (0..50).map(|i| (i as f64 * 0.7).sin() * 0.5).collect();
let rb: Vec<f64> = (0..50).map(|i| (i as f64 * 1.1).cos() * 0.3).collect();
let rt: Vec<f64> = ra.iter().zip(rb.iter()).map(|(a, b)| a + b).collect();
residuals.insert("Total".into(), rt);
residuals.insert("A".into(), ra);
residuals.insert("B".into(), rb);
tree.set_residuals(residuals);
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![55.0]),
("B".into(), vec![40.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceShrink)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["Total"][0], map["A"][0] + map["B"][0], 1e-10);
}
#[test]
fn mint_shrink_three_levels_coherent() {
let mut tree = HierarchyTree::new(vec![
("Total", &["East", "West"]),
("East", &["NY", "MA"]),
("West", &["CA", "WA"]),
])
.unwrap();
let t = 50;
let ny: Vec<f64> = (0..t).map(|i| (i as f64 * 0.3).sin()).collect();
let ma: Vec<f64> = (0..t).map(|i| (i as f64 * 0.5).cos()).collect();
let ca: Vec<f64> = (0..t).map(|i| (i as f64 * 0.7).sin() * 0.8).collect();
let wa: Vec<f64> = (0..t).map(|i| (i as f64 * 0.2).cos() * 0.6).collect();
let east: Vec<f64> = ny.iter().zip(ma.iter()).map(|(a, b)| a + b).collect();
let west: Vec<f64> = ca.iter().zip(wa.iter()).map(|(a, b)| a + b).collect();
let total: Vec<f64> = east.iter().zip(west.iter()).map(|(a, b)| a + b).collect();
let mut residuals = HashMap::new();
residuals.insert("Total".into(), total);
residuals.insert("East".into(), east);
residuals.insert("West".into(), west);
residuals.insert("NY".into(), ny);
residuals.insert("MA".into(), ma);
residuals.insert("CA".into(), ca);
residuals.insert("WA".into(), wa);
tree.set_residuals(residuals);
let base = vec![
("Total".into(), vec![100.0, 200.0]),
("East".into(), vec![55.0, 110.0]),
("West".into(), vec![50.0, 95.0]),
("NY".into(), vec![25.0, 55.0]),
("MA".into(), vec![28.0, 52.0]),
("CA".into(), vec![26.0, 48.0]),
("WA".into(), vec![22.0, 50.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceShrink)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
for h in 0..2 {
approx_eq(map["East"][h], map["NY"][h] + map["MA"][h], 1e-10);
approx_eq(map["West"][h], map["CA"][h] + map["WA"][h], 1e-10);
approx_eq(map["Total"][h], map["East"][h] + map["West"][h], 1e-10);
}
}
#[test]
fn mint_shrink_requires_residuals() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![50.0]),
("B".into(), vec![50.0]),
];
assert!(tree
.reconcile(&base, ReconciliationMethod::MinTraceShrink)
.is_err());
}
#[test]
fn mint_shrink_already_coherent_unchanged() {
let mut tree = HierarchyTree::new(vec![("Total", &["A", "B"])]).unwrap();
let t = 100;
let ra: Vec<f64> = (0..t).map(|i| (i as f64 * 0.3).sin() * 0.1).collect();
let rb: Vec<f64> = (0..t).map(|i| (i as f64 * 0.5).cos() * 0.1).collect();
let rt: Vec<f64> = ra.iter().zip(rb.iter()).map(|(a, b)| a + b).collect();
let mut residuals = HashMap::new();
residuals.insert("Total".into(), rt);
residuals.insert("A".into(), ra);
residuals.insert("B".into(), rb);
tree.set_residuals(residuals);
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![60.0]),
("B".into(), vec![40.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceShrink)
.unwrap();
let map: HashMap<&str, &Vec<f64>> = result.iter().map(|(k, v)| (k.as_str(), v)).collect();
approx_eq(map["Total"][0], 100.0, 1.0);
approx_eq(map["A"][0], 60.0, 1.0);
approx_eq(map["B"][0], 40.0, 1.0);
}
}