Skip to main content

converge_analytics/packs/forecasting/
mod.rs

1mod solver;
2mod types;
3
4pub use solver::*;
5pub use types::*;
6
7use converge_optimization::packs::{
8    InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation,
9};
10use converge_pack::gate::GateResult as Result;
11use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
12
13pub struct ForecastingPack;
14
15impl Pack for ForecastingPack {
16    fn name(&self) -> &'static str {
17        "forecasting"
18    }
19
20    fn version(&self) -> &'static str {
21        "1.0.0"
22    }
23
24    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
25        let input: ForecastingInput = serde_json::from_value(inputs.clone())
26            .map_err(|e| converge_pack::GateError::invalid_input(format!("Invalid input: {e}")))?;
27        input.validate()
28    }
29
30    fn invariants(&self) -> &[InvariantDef] {
31        static INVARIANTS: std::sync::LazyLock<Vec<InvariantDef>> =
32            std::sync::LazyLock::new(|| {
33                vec![
34                    InvariantDef::critical(
35                        "finite-predictions",
36                        "All predicted values must be finite",
37                    ),
38                    InvariantDef::advisory(
39                        "wide-intervals",
40                        "Confidence intervals exceed 2x the data range — low predictive power",
41                    ),
42                ]
43            });
44        &INVARIANTS
45    }
46
47    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
48        let input: ForecastingInput = spec.inputs_as()?;
49        input.validate()?;
50
51        let solver = ExponentialSmoothingSolver;
52        let (output, report) = solver.solve(&input, spec)?;
53
54        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
55        let confidence = if output.residual_std > 0.0 {
56            (1.0 / (1.0 + output.residual_std)).clamp(0.3, 0.95)
57        } else {
58            0.95
59        };
60
61        let plan = ProposedPlan::from_payload(
62            format!("plan-{}", spec.problem_id),
63            self.name(),
64            output.summary(),
65            &output,
66            confidence,
67            trace,
68        )?;
69
70        Ok(PackSolveResult::new(plan, report))
71    }
72
73    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
74        let output: ForecastingOutput = serde_json::from_value(plan.plan.clone())
75            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
76
77        let mut results = vec![];
78
79        let all_finite = output
80            .predictions
81            .iter()
82            .all(|p| p.value.is_finite() && p.lower.is_finite() && p.upper.is_finite());
83
84        if all_finite {
85            results.push(InvariantResult::pass("finite-predictions"));
86        } else {
87            results.push(InvariantResult::fail(
88                "finite-predictions",
89                converge_pack::gate::Violation::new(
90                    "finite-predictions",
91                    1.0,
92                    "Non-finite values in predictions",
93                ),
94            ));
95        }
96
97        if let (Some(last), Some(first)) = (output.predictions.last(), output.predictions.first()) {
98            let max_width = last.upper - last.lower;
99            let first_width = first.upper - first.lower;
100            if max_width > first_width * 4.0 && first_width > 0.0 {
101                results.push(InvariantResult::fail(
102                    "wide-intervals",
103                    converge_pack::gate::Violation::new(
104                        "wide-intervals",
105                        max_width,
106                        "Confidence intervals grow too wide over horizon",
107                    ),
108                ));
109            } else {
110                results.push(InvariantResult::pass("wide-intervals"));
111            }
112        } else {
113            results.push(InvariantResult::pass("wide-intervals"));
114        }
115
116        Ok(results)
117    }
118
119    fn evaluate_gate(
120        &self,
121        _plan: &ProposedPlan,
122        invariant_results: &[InvariantResult],
123    ) -> PromotionGate {
124        default_gate_evaluation(invariant_results, self.invariants())
125    }
126}