use super::{MissingDataPolicy, TransformError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StackOffset {
#[default]
Diverging,
Zero,
Expand,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StackOrder {
#[default]
AsGiven,
Reverse,
AscendingSum,
DescendingSum,
}
pub fn stack(
series: &[&[f64]],
category_count: usize,
order: StackOrder,
offset: StackOffset,
missing: MissingDataPolicy,
) -> Result<Vec<Vec<(f64, f64)>>, TransformError> {
let mut out: Vec<Vec<(f64, f64)>> = vec![Vec::with_capacity(category_count); series.len()];
if series.is_empty() || category_count == 0 {
return Ok(out);
}
if missing == MissingDataPolicy::Error {
for (j, s) in series.iter().enumerate() {
for (i, &v) in s.iter().enumerate() {
if !v.is_finite() {
return Err(TransformError::NonFinite { index: j * category_count + i });
}
}
}
}
let stack_order = resolve_order(series, category_count, order, missing);
for category in 0..category_count {
let mut pos_acc = 0.0_f64;
let mut neg_acc = 0.0_f64;
let mut zero_acc = 0.0_f64;
let mut category_total = 0.0_f64;
let mut raw: Vec<(usize, f64, f64)> = Vec::with_capacity(stack_order.len());
for &series_index in &stack_order {
let raw_value = series[series_index].get(category).copied().unwrap_or(0.0);
let value = resolve_value(raw_value, missing);
category_total += value;
let (bottom, top) = match offset {
StackOffset::Diverging => {
if value >= 0.0 {
let bottom = pos_acc;
pos_acc += value;
(bottom, pos_acc)
} else {
let top = neg_acc;
neg_acc += value;
(neg_acc, top)
}
}
StackOffset::Zero | StackOffset::Expand => {
let bottom = zero_acc;
zero_acc += value;
(bottom, zero_acc)
}
};
raw.push((series_index, bottom, top));
}
for (series_index, bottom, top) in raw {
let (bottom, top) = if offset == StackOffset::Expand {
if category_total == 0.0 {
(f64::NAN, f64::NAN)
} else {
(bottom / category_total, top / category_total)
}
} else {
(bottom, top)
};
out[series_index].push((bottom, top));
}
}
Ok(out)
}
fn resolve_value(value: f64, missing: MissingDataPolicy) -> f64 {
match missing {
MissingDataPolicy::Skip if !value.is_finite() => 0.0,
_ => value,
}
}
fn resolve_order(series: &[&[f64]], category_count: usize, order: StackOrder, missing: MissingDataPolicy) -> Vec<usize> {
match order {
StackOrder::AsGiven => (0..series.len()).collect(),
StackOrder::Reverse => (0..series.len()).rev().collect(),
StackOrder::AscendingSum => sort_index_by_value(&series_totals(series, category_count, missing), false),
StackOrder::DescendingSum => sort_index_by_value(&series_totals(series, category_count, missing), true),
}
}
fn series_totals(series: &[&[f64]], category_count: usize, missing: MissingDataPolicy) -> Vec<f64> {
series
.iter()
.map(|s| (0..category_count).map(|i| resolve_value(s.get(i).copied().unwrap_or(0.0), missing)).sum())
.collect()
}
pub fn sort_index_by_value(values: &[f64], descending: bool) -> Vec<usize> {
let mut indices: Vec<usize> = (0..values.len()).collect();
indices.sort_by(|&a, &b| {
let (va, vb) = (values[a], values[b]);
match (va.is_nan(), vb.is_nan()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => {
let ord = va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal);
if descending {
ord.reverse()
} else {
ord
}
}
}
});
indices
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diverging_default_reproduces_positive_up_negative_down_independently() {
let a: &[f64] = &[10.0, -10.0];
let b: &[f64] = &[20.0, -20.0];
let c: &[f64] = &[5.0, 5.0];
let segments = stack(&[a, b, c], 2, StackOrder::AsGiven, StackOffset::Diverging, MissingDataPolicy::Propagate).unwrap();
assert_eq!(segments[0][0], (0.0, 10.0));
assert_eq!(segments[1][0], (10.0, 30.0));
assert_eq!(segments[2][0], (30.0, 35.0));
assert_eq!(segments[0][1], (-10.0, 0.0));
assert_eq!(segments[1][1], (-30.0, -10.0));
assert_eq!(segments[2][1], (0.0, 5.0));
}
#[test]
fn output_is_always_indexed_by_original_series_order_regardless_of_stack_order() {
let a: &[f64] = &[1.0];
let b: &[f64] = &[100.0];
let as_given = stack(&[a, b], 1, StackOrder::AsGiven, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap();
let reversed = stack(&[a, b], 1, StackOrder::Reverse, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap();
assert_eq!(as_given[0][0], (0.0, 1.0), "AsGiven: a stacks first, [0,1]");
assert_eq!(reversed[0][0], (100.0, 101.0), "Reverse: a stacks SECOND now, but out[0] is still a's own segment");
assert_eq!(reversed[1][0], (0.0, 100.0), "Reverse: b stacks first, [0,100]");
}
#[test]
fn zero_offset_accumulates_sequentially_regardless_of_sign() {
let a: &[f64] = &[10.0];
let b: &[f64] = &[-3.0];
let c: &[f64] = &[5.0];
let segments = stack(&[a, b, c], 1, StackOrder::AsGiven, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap();
assert_eq!(segments[0][0], (0.0, 10.0));
assert_eq!(segments[1][0], (10.0, 7.0), "a negative value SHRINKS the running total under Zero, never starts a new downward stack");
assert_eq!(segments[2][0], (7.0, 12.0));
}
#[test]
fn expand_offset_normalizes_each_category_to_sum_to_one() {
let a: &[f64] = &[10.0, 1.0];
let b: &[f64] = &[30.0, 1.0];
let segments = stack(&[a, b], 2, StackOrder::AsGiven, StackOffset::Expand, MissingDataPolicy::Propagate).unwrap();
assert!((segments[0][0].1 - 0.25).abs() < 1e-9);
assert!((segments[1][0].1 - 1.0).abs() < 1e-9);
assert!((segments[0][1].1 - 0.5).abs() < 1e-9);
assert!((segments[1][1].1 - 1.0).abs() < 1e-9);
}
#[test]
fn expand_offset_with_a_zero_total_category_produces_nan_not_a_panic() {
let a: &[f64] = &[0.0];
let b: &[f64] = &[0.0];
let segments = stack(&[a, b], 1, StackOrder::AsGiven, StackOffset::Expand, MissingDataPolicy::Propagate).unwrap();
assert!(segments[0][0].0.is_nan() && segments[0][0].1.is_nan());
}
#[test]
fn ascending_and_descending_sum_order_the_stack_by_each_series_own_total() {
let small: &[f64] = &[1.0, 1.0];
let big: &[f64] = &[50.0, 50.0];
let medium: &[f64] = &[10.0, 10.0];
let ascending = stack(&[small, big, medium], 2, StackOrder::AscendingSum, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap();
assert_eq!(ascending[0][0], (0.0, 1.0), "small must stack first (closest to baseline) under AscendingSum");
assert_eq!(ascending[2][0], (1.0, 11.0), "medium must stack second");
assert_eq!(ascending[1][0], (11.0, 61.0), "big must stack last (farthest from baseline)");
let descending = stack(&[small, big, medium], 2, StackOrder::DescendingSum, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap();
assert_eq!(descending[1][0], (0.0, 50.0), "big must stack first under DescendingSum");
}
#[test]
fn a_category_index_missing_from_a_shorter_series_contributes_zero() {
let short: &[f64] = &[10.0];
let long: &[f64] = &[5.0, 5.0];
let segments = stack(&[short, long], 2, StackOrder::AsGiven, StackOffset::Diverging, MissingDataPolicy::Propagate).unwrap();
assert_eq!(segments[0][1], (0.0, 0.0), "short's own missing category-1 entry contributes nothing");
assert_eq!(segments[1][1], (0.0, 5.0));
}
#[test]
fn empty_series_or_zero_categories_never_panics() {
let empty_series: &[&[f64]] = &[];
assert_eq!(stack(empty_series, 3, StackOrder::AsGiven, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap(), Vec::<Vec<(f64, f64)>>::new());
let a: &[f64] = &[1.0, 2.0];
let out = stack(&[a], 0, StackOrder::AsGiven, StackOffset::Zero, MissingDataPolicy::Propagate).unwrap();
assert_eq!(out, vec![Vec::new()]);
}
#[test]
fn skip_policy_treats_a_non_finite_value_as_zero_contribution() {
let a: &[f64] = &[f64::NAN];
let b: &[f64] = &[5.0];
let segments = stack(&[a, b], 1, StackOrder::AsGiven, StackOffset::Zero, MissingDataPolicy::Skip).unwrap();
assert_eq!(segments[0][0], (0.0, 0.0));
assert_eq!(segments[1][0], (0.0, 5.0));
}
#[test]
fn propagate_policy_nan_falls_into_the_negative_accumulator_under_diverging_the_documented_pre_existing_quirk() {
let a: &[f64] = &[f64::NAN];
let segments = stack(&[a], 1, StackOrder::AsGiven, StackOffset::Diverging, MissingDataPolicy::Propagate).unwrap();
let (bottom, top) = segments[0][0];
assert!(bottom.is_nan() && top == 0.0, "NaN >= 0.0 is false, so it must land in the NEGATIVE accumulator branch, matching the pre-refactor behavior");
}
#[test]
fn error_policy_reports_the_flattened_series_and_category_index() {
let a: &[f64] = &[1.0, 2.0];
let b: &[f64] = &[3.0, f64::NAN];
let err = stack(&[a, b], 2, StackOrder::AsGiven, StackOffset::Zero, MissingDataPolicy::Error).unwrap_err();
assert_eq!(err, TransformError::NonFinite { index: 3 });
}
#[test]
fn sort_index_by_value_ascending_and_descending() {
let values = [30.0, 10.0, 20.0];
assert_eq!(sort_index_by_value(&values, false), vec![1, 2, 0]);
assert_eq!(sort_index_by_value(&values, true), vec![0, 2, 1]);
}
#[test]
fn sort_index_by_value_pushes_nan_to_the_end_regardless_of_direction() {
let values = [5.0, f64::NAN, 1.0];
assert_eq!(sort_index_by_value(&values, false), vec![2, 0, 1]);
assert_eq!(sort_index_by_value(&values, true), vec![0, 2, 1]);
}
#[test]
fn sort_index_by_value_empty_is_empty() {
assert!(sort_index_by_value(&[], false).is_empty());
}
}