converge_analytics/packs/forecasting/
mod.rs1mod 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}