use crate::error::{ForecastError, Result};
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Node {
name: String,
children: Vec<usize>, parents: Vec<usize>,
level: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReconciliationMethod {
BottomUp,
TopDown,
MiddleOut {
middle_level: usize,
},
MinTraceOls,
MinTraceShrink,
MinTraceVariance,
MinTraceStruct,
}
#[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(),
parents: Vec::new(),
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].parents.contains(&pidx) {
nodes[cidx].parents.push(pidx);
}
if !nodes[pidx].children.contains(&cidx) {
nodes[pidx].children.push(cidx);
}
}
}
let roots: Vec<usize> = nodes
.iter()
.enumerate()
.filter(|(_, n)| n.parents.is_empty())
.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() {
if level > nodes[idx].level {
nodes[idx].level = level;
} else if level < nodes[idx].level {
continue;
}
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 from_summing_matrix(
node_names: &[String],
leaf_names: &[String],
leaf_ancestors: &[Vec<usize>],
) -> Result<Self> {
if node_names.is_empty() {
return Err(ForecastError::InvalidParameter(
"from_summing_matrix: node_names must be non-empty".into(),
));
}
if leaf_names.len() != leaf_ancestors.len() {
return Err(ForecastError::InvalidParameter(format!(
"from_summing_matrix: leaf_names ({}) and leaf_ancestors ({}) length mismatch",
leaf_names.len(),
leaf_ancestors.len(),
)));
}
let mut name_to_idx: HashMap<String, usize> = HashMap::with_capacity(node_names.len());
for (i, name) in node_names.iter().enumerate() {
if name_to_idx.insert(name.clone(), i).is_some() {
return Err(ForecastError::InvalidParameter(format!(
"from_summing_matrix: duplicate node name '{}'",
name
)));
}
}
let mut nodes: Vec<Node> = node_names
.iter()
.map(|name| Node {
name: name.clone(),
children: Vec::new(),
parents: Vec::new(),
level: 0,
})
.collect();
let mut leaf_indices: Vec<usize> = Vec::with_capacity(leaf_names.len());
let mut leaves_below: HashMap<usize, std::collections::BTreeSet<usize>> = HashMap::new();
for (i, leaf) in leaf_names.iter().enumerate() {
let leaf_idx = name_to_idx.get(leaf).copied().ok_or_else(|| {
ForecastError::InvalidParameter(format!(
"from_summing_matrix: leaf '{}' not in node_names",
leaf
))
})?;
leaf_indices.push(leaf_idx);
for &anc in &leaf_ancestors[i] {
if anc >= nodes.len() {
return Err(ForecastError::InvalidParameter(format!(
"from_summing_matrix: ancestor index {} out of range (have {} nodes)",
anc,
nodes.len(),
)));
}
if anc == leaf_idx {
continue;
}
leaves_below.entry(anc).or_default().insert(leaf_idx);
}
}
for (i, &leaf_idx) in leaf_indices.iter().enumerate() {
for &anc in &leaf_ancestors[i] {
if anc == leaf_idx {
continue;
}
if !nodes[leaf_idx].parents.contains(&anc) {
nodes[leaf_idx].parents.push(anc);
}
if !nodes[anc].children.contains(&leaf_idx) {
nodes[anc].children.push(leaf_idx);
}
}
}
let agg_indices: Vec<usize> = leaves_below.keys().copied().collect();
for &a in &agg_indices {
let la = &leaves_below[&a];
let supersets: Vec<usize> = agg_indices
.iter()
.copied()
.filter(|&b| {
b != a && {
let lb = &leaves_below[&b];
la.is_subset(lb) && la.len() < lb.len()
}
})
.collect();
for &b in &supersets {
let lb = &leaves_below[&b];
let is_immediate = !supersets.iter().any(|&c| {
if c == b {
return false;
}
let lc = &leaves_below[&c];
la.is_subset(lc) && lc.is_subset(lb) && lc.len() < lb.len()
});
if is_immediate {
if !nodes[a].parents.contains(&b) {
nodes[a].parents.push(b);
}
if !nodes[b].children.contains(&a) {
nodes[b].children.push(a);
}
}
}
}
let roots: Vec<usize> = nodes
.iter()
.enumerate()
.filter(|(_, n)| n.parents.is_empty())
.map(|(i, _)| i)
.collect();
if roots.len() != 1 {
return Err(ForecastError::InvalidParameter(format!(
"from_summing_matrix: must have exactly one root, found {} (a grouped \
hierarchy still needs a single grand-total node that every leaf rolls up to)",
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() {
if level > nodes[idx].level {
nodes[idx].level = level;
} else if level < nodes[idx].level {
continue;
}
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),
ReconciliationMethod::MinTraceVariance => {
self.min_trace_diagonal(&base_map, horizon, true)
}
ReconciliationMethod::MinTraceStruct => {
self.min_trace_diagonal(&base_map, horizon, false)
}
}
}
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() {
s[leaf][j] = 1.0;
for anc in self.ancestors_of(leaf) {
s[anc][j] = 1.0;
}
}
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 ancestors_of(&self, idx: usize) -> Vec<usize> {
let mut result = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back(idx);
while let Some(cur) = queue.pop_front() {
for &p in &self.nodes[cur].parents {
if seen.insert(p) {
result.push(p);
queue.push_back(p);
}
}
}
result
}
fn is_descendant_of(&self, node: usize, ancestor: usize) -> bool {
self.ancestors_of(node).contains(&ancestor)
}
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() {
s_mat[leaf][j] = 1.0;
for anc in self.ancestors_of(leaf) {
s_mat[anc][j] = 1.0;
}
}
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 min_trace_diagonal(
&self,
base_map: &HashMap<&str, &Vec<f64>>,
horizon: usize,
use_variance: bool,
) -> Result<Vec<(String, Vec<f64>)>> {
let n = self.nodes.len();
let leaves = self.leaves();
let m = leaves.len();
let w_diag: Vec<f64> = if use_variance {
let residuals = self.residuals.as_ref().ok_or_else(|| {
ForecastError::InvalidParameter(
"MinTraceVariance requires residuals; call set_residuals() first".into(),
)
})?;
self.nodes
.iter()
.map(|node| {
if let Some(r) = residuals.get(&node.name) {
let n_r = r.len() as f64;
if n_r < 2.0 {
return 1.0;
}
let mean = r.iter().sum::<f64>() / n_r;
let var = r.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n_r - 1.0);
var.max(1e-10)
} else {
1.0
}
})
.collect()
} else {
self.nodes
.iter()
.enumerate()
.map(|(idx, _)| self.count_leaves_below(idx).max(1) as f64)
.collect()
};
let w_inv: Vec<f64> = w_diag.iter().map(|&w| 1.0 / w).collect();
let sparse_s: Vec<Vec<usize>> = leaves
.iter()
.map(|&leaf| {
let mut ancestors = vec![leaf];
ancestors.extend(self.ancestors_of(leaf));
ancestors
})
.collect();
let sts_diag: Vec<f64> = sparse_s
.iter()
.map(|ancestors| ancestors.iter().map(|&k| w_inv[k]).sum::<f64>())
.collect();
const CG_AUTO_SWITCH: usize = 1000;
let use_cg = m > CG_AUTO_SWITCH;
let dense_factor: Option<Vec<f64>> = if use_cg {
None
} else {
let mut sts = vec![0.0_f64; m * m];
for i in 0..m {
sts[i * m + i] = sts_diag[i];
for j in (i + 1)..m {
let mut dot = 0.0;
for &anc_i in &sparse_s[i] {
for &anc_j in &sparse_s[j] {
if anc_i == anc_j {
dot += w_inv[anc_i];
}
}
}
sts[i * m + j] = dot;
sts[j * m + i] = dot;
}
}
Some(cholesky(m, &sts)?)
};
let bfs_order = self.bfs_order();
let mut reconciled = vec![vec![0.0; horizon]; n];
let mut sx = vec![0.0_f64; 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_f64; m];
for (j, ancestors) in sparse_s.iter().enumerate() {
z[j] = ancestors.iter().map(|&k| w_inv[k] * base_vec[k]).sum();
}
let w_sol = if let Some(l) = dense_factor.as_ref() {
cholesky_solve_vec(m, l, &z)
} else {
min_trace_cg_solve(&sparse_s, &w_inv, &sts_diag, &z, &mut sx)
};
for r in reconciled.iter_mut() {
r[h] = 0.0;
}
for (j, ancestors) in sparse_s.iter().enumerate() {
for &node_idx in ancestors {
reconciled[node_idx][h] += w_sol[j];
}
}
}
Ok(bfs_order
.iter()
.map(|&idx| (self.nodes[idx].name.clone(), reconciled[idx].clone()))
.collect())
}
fn count_leaves_below(&self, idx: usize) -> usize {
if self.nodes[idx].children.is_empty() {
return 1;
}
self.nodes[idx]
.children
.iter()
.map(|&c| self.count_leaves_below(c))
.sum()
}
fn bfs_order(&self) -> Vec<usize> {
let mut order = Vec::with_capacity(self.nodes.len());
let mut seen = std::collections::HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back(self.root);
seen.insert(self.root);
while let Some(idx) = queue.pop_front() {
order.push(idx);
for &c in &self.nodes[idx].children {
if seen.insert(c) {
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 min_trace_cg_solve(
sparse_s: &[Vec<usize>],
w_inv: &[f64],
diag_preconditioner: &[f64],
z: &[f64],
sx: &mut [f64],
) -> Vec<f64> {
const MAX_ITER: usize = 200;
const TOL: f64 = 1e-8;
const FLOOR: f64 = 1e-30;
let m = z.len();
let n = w_inv.len();
debug_assert_eq!(sparse_s.len(), m);
debug_assert_eq!(diag_preconditioner.len(), m);
debug_assert!(sx.len() >= n, "scratch buffer too small");
let z_norm_sq: f64 = z.iter().map(|v| v * v).sum();
if z_norm_sq == 0.0 {
return vec![0.0; m];
}
let tol_sq = TOL * TOL * z_norm_sq;
let mut x = vec![0.0_f64; m]; let mut r = z.to_vec(); let mut z_pre = vec![0.0_f64; m]; let mut p = vec![0.0_f64; m]; let mut ap = vec![0.0_f64; m]; let mut sw = &mut sx[..n];
for j in 0..m {
let d = diag_preconditioner[j].max(FLOOR);
z_pre[j] = r[j] / d;
p[j] = z_pre[j];
}
let mut rz: f64 = r.iter().zip(&z_pre).map(|(ri, zi)| ri * zi).sum();
for _iter in 0..MAX_ITER {
for v in sw.iter_mut() {
*v = 0.0;
}
for (j, ancestors) in sparse_s.iter().enumerate() {
let pj = p[j];
for &k in ancestors {
sw[k] += pj;
}
}
for k in 0..n {
sw[k] *= w_inv[k];
}
for (j, ancestors) in sparse_s.iter().enumerate() {
ap[j] = ancestors.iter().map(|&k| sw[k]).sum();
}
let p_ap: f64 = p.iter().zip(&ap).map(|(pi, ai)| pi * ai).sum();
let alpha = rz / p_ap.max(FLOOR);
let mut r_norm_sq = 0.0_f64;
for j in 0..m {
x[j] += alpha * p[j];
r[j] -= alpha * ap[j];
r_norm_sq += r[j] * r[j];
}
if r_norm_sq < tol_sq {
break;
}
let mut rz_new = 0.0_f64;
for j in 0..m {
let d = diag_preconditioner[j].max(FLOOR);
z_pre[j] = r[j] / d;
rz_new += r[j] * z_pre[j];
}
let beta = rz_new / rz.max(FLOOR);
rz = rz_new;
for j in 0..m {
p[j] = z_pre[j] + beta * p[j];
}
sw = &mut sx[..n]; }
x
}
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);
}
#[test]
fn min_trace_variance_produces_coherent_forecasts() {
let mut tree =
HierarchyTree::new(vec![("Total", &["A", "B"]), ("A", &["A1", "A2"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![55.0]),
("B".into(), vec![40.0]),
("A1".into(), vec![25.0]),
("A2".into(), vec![20.0]),
];
let mut residuals = std::collections::HashMap::new();
residuals.insert("Total".into(), vec![1.0, -1.0, 0.5, -0.5]);
residuals.insert("A".into(), vec![0.8, -0.8, 0.3, -0.3]);
residuals.insert("B".into(), vec![0.5, -0.5, 0.2, -0.2]);
residuals.insert("A1".into(), vec![0.6, -0.6, 0.2, -0.2]);
residuals.insert("A2".into(), vec![0.4, -0.4, 0.1, -0.1]);
tree.set_residuals(residuals);
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceVariance)
.unwrap();
let map: std::collections::HashMap<String, Vec<f64>> = result.into_iter().collect();
let total = map["Total"][0];
let a = map["A"][0];
let b = map["B"][0];
let a1 = map["A1"][0];
let a2 = map["A2"][0];
approx_eq(total, a + b, 0.01);
approx_eq(a, a1 + a2, 0.01);
}
#[test]
fn from_summing_matrix_grouped_hierarchy_construction() {
let node_names: Vec<String> = vec![
"Total".into(),
"site_SI10".into(),
"site_SI20".into(),
"material_P1".into(),
"material_P2".into(),
"SI10_P1".into(),
"SI10_P2".into(),
"SI20_P1".into(),
"SI20_P2".into(),
];
let leaf_names: Vec<String> = vec![
"SI10_P1".into(),
"SI10_P2".into(),
"SI20_P1".into(),
"SI20_P2".into(),
];
let leaf_ancestors: Vec<Vec<usize>> = vec![
vec![0, 1, 3], vec![0, 1, 4], vec![0, 2, 3], vec![0, 2, 4], ];
let tree =
HierarchyTree::from_summing_matrix(&node_names, &leaf_names, &leaf_ancestors).unwrap();
assert_eq!(tree.len(), 9);
let mut site10_children = tree.children_of("site_SI10").unwrap();
site10_children.sort();
assert_eq!(site10_children, vec!["SI10_P1", "SI10_P2"]);
let mut mat1_children = tree.children_of("material_P1").unwrap();
mat1_children.sort();
assert_eq!(mat1_children, vec!["SI10_P1", "SI20_P1"]);
let mut total_children = tree.children_of("Total").unwrap();
total_children.sort();
assert!(total_children.contains(&"SI10_P1"));
assert!(total_children.contains(&"SI20_P2"));
}
#[test]
fn from_summing_matrix_rejects_duplicate_node_names() {
let node_names: Vec<String> = vec!["A".into(), "A".into()];
let leaf_names: Vec<String> = vec!["A".into()];
let leaf_ancestors: Vec<Vec<usize>> = vec![vec![]];
assert!(
HierarchyTree::from_summing_matrix(&node_names, &leaf_names, &leaf_ancestors).is_err()
);
}
#[test]
fn from_summing_matrix_rejects_out_of_range_ancestor() {
let node_names: Vec<String> = vec!["Total".into(), "Leaf".into()];
let leaf_names: Vec<String> = vec!["Leaf".into()];
let leaf_ancestors: Vec<Vec<usize>> = vec![vec![5]]; assert!(
HierarchyTree::from_summing_matrix(&node_names, &leaf_names, &leaf_ancestors).is_err()
);
}
#[test]
fn from_summing_matrix_rejects_unknown_leaf_name() {
let node_names: Vec<String> = vec!["Total".into(), "Leaf".into()];
let leaf_names: Vec<String> = vec!["Unknown".into()];
let leaf_ancestors: Vec<Vec<usize>> = vec![vec![0]];
assert!(
HierarchyTree::from_summing_matrix(&node_names, &leaf_names, &leaf_ancestors).is_err()
);
}
#[test]
fn grouped_hierarchy_min_trace_variance_coherent() {
let node_names: Vec<String> = vec![
"Total".into(),
"site_SI10".into(),
"site_SI20".into(),
"material_P1".into(),
"material_P2".into(),
"SI10_P1".into(),
"SI10_P2".into(),
"SI20_P1".into(),
"SI20_P2".into(),
];
let leaf_names: Vec<String> = vec![
"SI10_P1".into(),
"SI10_P2".into(),
"SI20_P1".into(),
"SI20_P2".into(),
];
let leaf_ancestors: Vec<Vec<usize>> =
vec![vec![0, 1, 3], vec![0, 1, 4], vec![0, 2, 3], vec![0, 2, 4]];
let mut tree =
HierarchyTree::from_summing_matrix(&node_names, &leaf_names, &leaf_ancestors).unwrap();
let base: Vec<(String, Vec<f64>)> = vec![
("Total".into(), vec![100.0]),
("site_SI10".into(), vec![55.0]),
("site_SI20".into(), vec![44.0]),
("material_P1".into(), vec![50.0]),
("material_P2".into(), vec![48.0]),
("SI10_P1".into(), vec![25.0]),
("SI10_P2".into(), vec![28.0]),
("SI20_P1".into(), vec![24.0]),
("SI20_P2".into(), vec![19.0]),
];
let mut residuals = std::collections::HashMap::new();
for n in node_names.iter() {
residuals.insert(n.clone(), vec![1.0, -1.0, 0.5, -0.5]);
}
tree.set_residuals(residuals);
let reconciled = tree
.reconcile(&base, ReconciliationMethod::MinTraceVariance)
.unwrap();
let map: std::collections::HashMap<String, Vec<f64>> = reconciled.into_iter().collect();
let p11 = map["SI10_P1"][0];
let p12 = map["SI10_P2"][0];
let p21 = map["SI20_P1"][0];
let p22 = map["SI20_P2"][0];
approx_eq(map["site_SI10"][0], p11 + p12, 0.01);
approx_eq(map["site_SI20"][0], p21 + p22, 0.01);
approx_eq(map["material_P1"][0], p11 + p21, 0.01);
approx_eq(map["material_P2"][0], p12 + p22, 0.01);
approx_eq(map["Total"][0], p11 + p12 + p21 + p22, 0.01);
}
#[test]
fn min_trace_struct_produces_coherent_forecasts() {
let tree = HierarchyTree::new(vec![("Total", &["A", "B"]), ("A", &["A1", "A2"])]).unwrap();
let base = vec![
("Total".into(), vec![100.0]),
("A".into(), vec![55.0]),
("B".into(), vec![40.0]),
("A1".into(), vec![25.0]),
("A2".into(), vec![20.0]),
];
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceStruct)
.unwrap();
let map: std::collections::HashMap<String, Vec<f64>> = result.into_iter().collect();
let total = map["Total"][0];
let a = map["A"][0];
let b = map["B"][0];
let a1 = map["A1"][0];
let a2 = map["A2"][0];
approx_eq(total, a + b, 0.01);
approx_eq(a, a1 + a2, 0.01);
assert!(total > 0.0);
assert!(a > 0.0);
assert!(b > 0.0);
}
fn build_grouped_panel(
n_sites: usize,
n_parts: usize,
) -> (HierarchyTree, Vec<(String, Vec<f64>)>) {
let mut node_names: Vec<String> = vec!["Total".into()];
let total_idx = 0;
let mut site_indices = vec![0usize; n_sites];
for s in 0..n_sites {
site_indices[s] = node_names.len();
node_names.push(format!("site_{}", s));
}
let mut part_indices = vec![0usize; n_parts];
for p in 0..n_parts {
part_indices[p] = node_names.len();
node_names.push(format!("part_{}", p));
}
let mut leaf_names = Vec::with_capacity(n_sites * n_parts);
let mut leaf_ancestors = Vec::with_capacity(n_sites * n_parts);
for s in 0..n_sites {
for p in 0..n_parts {
leaf_names.push(format!("s{}_p{}", s, p));
leaf_ancestors.push(vec![total_idx, site_indices[s], part_indices[p]]);
node_names.push(format!("s{}_p{}", s, p));
}
}
let tree =
HierarchyTree::from_summing_matrix(&node_names, &leaf_names, &leaf_ancestors).unwrap();
let base: Vec<(String, Vec<f64>)> = (0..node_names.len())
.map(|i| {
let n_below = if i == total_idx {
(n_sites * n_parts) as f64
} else if i <= n_sites {
n_parts as f64
} else if i <= n_sites + n_parts {
n_sites as f64
} else {
1.0
};
(node_names[i].clone(), vec![n_below])
})
.collect();
(tree, base)
}
#[test]
fn min_trace_cg_path_agrees_with_dense_on_small_grouped() {
let (mut tree, base) = build_grouped_panel(5, 4);
let mut residuals = std::collections::HashMap::new();
for (name, _) in &base {
residuals.insert(name.clone(), vec![1.0, -1.0, 0.5, -0.5]);
}
tree.set_residuals(residuals);
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceVariance)
.unwrap();
let map: std::collections::HashMap<String, Vec<f64>> = result.into_iter().collect();
let mut leaf_sum = 0.0_f64;
for s in 0..5 {
for p in 0..4 {
leaf_sum += map[&format!("s{}_p{}", s, p)][0];
}
}
approx_eq(map["Total"][0], leaf_sum, 0.01);
for s in 0..5 {
let mut site_sum = 0.0_f64;
for p in 0..4 {
site_sum += map[&format!("s{}_p{}", s, p)][0];
}
approx_eq(map[&format!("site_{}", s)][0], site_sum, 0.01);
}
for p in 0..4 {
let mut part_sum = 0.0_f64;
for s in 0..5 {
part_sum += map[&format!("s{}_p{}", s, p)][0];
}
approx_eq(map[&format!("part_{}", p)][0], part_sum, 0.01);
}
}
#[test]
fn min_trace_cg_path_scales_past_dense_threshold() {
let n_sites = 40;
let n_parts = 40;
let (tree, base) = build_grouped_panel(n_sites, n_parts);
let result = tree
.reconcile(&base, ReconciliationMethod::MinTraceStruct)
.unwrap();
let map: std::collections::HashMap<String, Vec<f64>> = result.into_iter().collect();
let mut leaf_sum = 0.0_f64;
for s in 0..n_sites {
for p in 0..n_parts {
leaf_sum += map[&format!("s{}_p{}", s, p)][0];
}
}
approx_eq(map["Total"][0], leaf_sum, 0.05);
for s in 0..n_sites {
let mut site_sum = 0.0_f64;
for p in 0..n_parts {
site_sum += map[&format!("s{}_p{}", s, p)][0];
}
approx_eq(map[&format!("site_{}", s)][0], site_sum, 0.05);
}
for p in 0..n_parts {
let mut part_sum = 0.0_f64;
for s in 0..n_sites {
part_sum += map[&format!("s{}_p{}", s, p)][0];
}
approx_eq(map[&format!("part_{}", p)][0], part_sum, 0.05);
}
}
}