use std::fmt;
use super::pipeline::PipelineResult;
#[derive(Debug, Clone)]
pub struct ReportSection {
pub heading: String,
pub content: ReportContent,
}
#[derive(Debug, Clone)]
pub enum ReportContent {
Text(String),
KeyValue(Vec<(String, String)>),
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
}
#[derive(Debug, Clone)]
pub struct PipelineReport {
pub title: String,
pub sections: Vec<ReportSection>,
}
impl PipelineReport {
pub fn from_result(result: &PipelineResult) -> Self {
let mut sections = Vec::new();
sections.push(ReportSection {
heading: "Summary".into(),
content: ReportContent::KeyValue(vec![
("Model".into(), result.model_name.clone()),
(
"Horizon".into(),
format!("{} steps", result.forecast.primary().len()),
),
]),
});
if let Some(ref profile) = result.profile {
sections.push(ReportSection {
heading: "Data Profile".into(),
content: ReportContent::KeyValue(vec![
("Observations".into(), format!("{}", profile.n_observations)),
("Mean".into(), format!("{:.4}", profile.mean)),
("Std Dev".into(), format!("{:.4}", profile.std_dev)),
("Trend".into(), format!("{}", profile.trend_direction)),
("Stationary".into(), format!("{}", profile.is_stationary())),
(
"Intermittent".into(),
format!("{}", profile.is_intermittent),
),
(
"Quality Score".into(),
format!("{:.2}", profile.quality_score),
),
]),
});
}
if let Some(ref pp) = result.preprocess {
if !pp.steps_applied.is_empty() {
let mut kv = Vec::new();
if let Some(lambda) = pp.boxcox_lambda {
kv.push(("Box-Cox Lambda".into(), format!("{:.4}", lambda)));
}
if pp.outliers_replaced > 0 {
kv.push((
"Outliers Replaced".into(),
format!("{}", pp.outliers_replaced),
));
}
sections.push(ReportSection {
heading: "Preprocessing".into(),
content: ReportContent::KeyValue(kv),
});
}
}
{
let mut kv = Vec::new();
if let Some(ref qf) = result.quality_floor {
kv.push((
"Quality Floor".into(),
format!(
"{} (SPA p={:.4})",
if qf.is_outperformed {
"PASSED"
} else {
"FAILED"
},
qf.spa_p_value
),
));
}
if let Some(ref mcs) = result.model_confidence_set {
kv.push((
"Model Confidence Set".into(),
format!("{:?} (p={:.4})", mcs.included, mcs.mcs_p_value),
));
}
if let Some(ref conf) = result.selection_confidence {
kv.push((
"Selection Confidence".into(),
format!("{} (DM p={:.4})", conf.verdict, conf.dm_p_value),
));
}
if let Some(ref scores) = result.metric_scores {
for (name, ms) in scores {
kv.push((format!("Score: {}", name), format!("{}", ms)));
}
}
if !kv.is_empty() {
sections.push(ReportSection {
heading: "Model Selection".into(),
content: ReportContent::KeyValue(kv),
});
}
}
if let Some(ref weights) = result.ensemble_weights {
let rows: Vec<Vec<String>> = weights
.iter()
.map(|(name, w)| vec![name.clone(), format!("{:.4}", w)])
.collect();
sections.push(ReportSection {
heading: "Ensemble".into(),
content: ReportContent::Table {
headers: vec!["Model".into(), "Weight".into()],
rows,
},
});
}
{
let values = result.forecast.primary();
let rows: Vec<Vec<String>> = values
.iter()
.enumerate()
.map(|(i, v)| vec![format!("h={}", i + 1), format!("{:.4}", v)])
.collect();
sections.push(ReportSection {
heading: "Forecast".into(),
content: ReportContent::Table {
headers: vec!["Step".into(), "Value".into()],
rows,
},
});
}
if let Some(ref ha) = result.horizon_analysis {
let rows: Vec<Vec<String>> = ha
.steps
.iter()
.map(|step| {
vec![
format!("h={}", step.horizon),
format!("{:.4}", step.rmse),
format!("{:.4}", step.mae),
format!("{:.4}", step.bias),
]
})
.collect();
sections.push(ReportSection {
heading: "Horizon Analysis".into(),
content: ReportContent::Table {
headers: vec!["Step".into(), "RMSE".into(), "MAE".into(), "Bias".into()],
rows,
},
});
}
if !result.log.is_empty() {
let rows: Vec<Vec<String>> = result
.log
.decisions()
.iter()
.map(|d| {
vec![
format!("{}", d.step),
format!("{}", d.category),
d.action.clone(),
format!("{}", d.outcome),
d.detail.clone().unwrap_or_default(),
]
})
.collect();
sections.push(ReportSection {
heading: "Decision Log".into(),
content: ReportContent::Table {
headers: vec![
"#".into(),
"Category".into(),
"Action".into(),
"Outcome".into(),
"Detail".into(),
],
rows,
},
});
}
if !result.model_metadata.is_empty() {
let rows: Vec<Vec<String>> = result
.model_metadata
.iter()
.map(|m| {
vec![
m.model_name.clone(),
m.fit_duration
.map(|d| format!("{:.3}s", d.as_secs_f64()))
.unwrap_or_else(|| "-".into()),
if m.converged {
"yes".into()
} else {
"no".into()
},
m.error_message.clone().unwrap_or_default(),
]
})
.collect();
sections.push(ReportSection {
heading: "Execution Metadata".into(),
content: ReportContent::Table {
headers: vec![
"Model".into(),
"Fit Time".into(),
"Converged".into(),
"Error".into(),
],
rows,
},
});
}
PipelineReport {
title: format!("Pipeline Report: {}", result.model_name),
sections,
}
}
}
impl fmt::Display for PipelineReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}", self.title)?;
writeln!(f, "{}", "=".repeat(self.title.len()))?;
writeln!(f)?;
for section in &self.sections {
writeln!(f, "--- {} ---", section.heading)?;
match §ion.content {
ReportContent::Text(text) => {
writeln!(f, "{}", text)?;
}
ReportContent::KeyValue(pairs) => {
let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (k, v) in pairs {
writeln!(f, " {:width$} {}", k, v, width = max_key)?;
}
}
ReportContent::Table { headers, rows } => {
let n_cols = headers.len();
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < n_cols {
widths[i] = widths[i].max(cell.len());
}
}
}
let header_line: String = headers
.iter()
.zip(widths.iter())
.map(|(h, w)| format!(" {:width$}", h, width = w))
.collect();
writeln!(f, "{}", header_line)?;
let sep: String = widths
.iter()
.map(|w| format!(" {}", "-".repeat(*w)))
.collect();
writeln!(f, "{}", sep)?;
for row in rows {
let line: String = row
.iter()
.zip(widths.iter())
.map(|(cell, w)| format!(" {:width$}", cell, width = w))
.collect();
writeln!(f, "{}", line)?;
}
}
}
writeln!(f)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Forecast;
use crate::orchestration::decision_log::{DecisionCategory, DecisionLog, DecisionOutcome};
use crate::orchestration::metadata::ExecutionMetadata;
use std::time::Duration as StdDuration;
fn make_result() -> PipelineResult {
let forecast = Forecast::from_values(vec![1.0, 2.0, 3.0]);
let mut log = DecisionLog::new();
log.record(
DecisionCategory::ModelSelection,
"Selected Naive",
DecisionOutcome::Success,
);
PipelineResult {
forecast,
model_name: "Naive".into(),
profile: None,
log,
model_metadata: vec![ExecutionMetadata::new("Naive")
.with_fit(StdDuration::from_millis(10))
.with_convergence(true)],
horizon_analysis: None,
selection_confidence: None,
model_confidence_set: None,
quality_floor: None,
preprocess: None,
ensemble_weights: None,
metric_scores: None,
}
}
#[test]
fn report_from_result() {
let result = make_result();
let report = PipelineReport::from_result(&result);
assert!(report.title.contains("Naive"));
assert!(!report.sections.is_empty());
}
#[test]
fn report_display() {
let result = make_result();
let report = PipelineReport::from_result(&result);
let text = format!("{}", report);
assert!(text.contains("Pipeline Report"));
assert!(text.contains("Summary"));
assert!(text.contains("Forecast"));
assert!(text.contains("Decision Log"));
}
#[test]
fn report_with_ensemble_weights() {
let mut result = make_result();
result.ensemble_weights = Some(vec![("SES".into(), 0.6), ("Naive".into(), 0.4)]);
let report = PipelineReport::from_result(&result);
let text = format!("{}", report);
assert!(text.contains("Ensemble"));
assert!(text.contains("SES"));
}
}