///|
/// A bounded integer forecast for a future period.
pub struct ForecastPoint {
period : Int
prediction : Int
lower_bound : Int
upper_bound : Int
}
///|
pub struct ForecastConfig {
window : Int
horizon : Int
safety_percent : Int
}
///|
pub fn ForecastConfig::new(
window : Int,
horizon : Int,
safety_percent : Int,
) -> ForecastConfig {
{ window, horizon, safety_percent }
}
///|
pub struct ForecastScore {
mean_absolute_error : Int
bias : Int
observations : Int
}
///|
pub fn ForecastConfig::default() -> ForecastConfig {
{ window: 7, horizon: 4, safety_percent: 20 }
}
///|
fn average_window(values : Array[Int], window : Int) -> Int {
if values.is_empty() {
return 0
}
let start = if values.length() <= window {
0
} else {
values.length() - window
}
let selected = values[start:]
selected.fold(init=0, (sum, value) => sum + value) / selected.length()
}
///|
/// Produce a conservative moving-average forecast with explicit bounds.
pub fn forecast(
values : Array[Int],
config : ForecastConfig,
) -> Array[ForecastPoint] {
if config.window <= 0 || config.horizon <= 0 || values.is_empty() {
return []
}
let base = average_window(values, config.window)
let safety = base *
(if config.safety_percent < 0 { 0 } else { config.safety_percent }) /
100
let points : Array[ForecastPoint] = []
for period in 1..<=config.horizon {
points.push({
period,
prediction: base,
lower_bound: if base < safety {
0
} else {
base - safety
},
upper_bound: base + safety,
})
}
points
}
///|
/// Score forecasts without floating point or hidden division by zero.
pub fn score_forecast(
actual : Array[Int],
predicted : Array[Int],
) -> ForecastScore {
let count = if actual.length() < predicted.length() {
actual.length()
} else {
predicted.length()
}
if count == 0 {
{ mean_absolute_error: 0, bias: 0, observations: 0 }
} else {
let mut absolute = 0
let mut bias = 0
for index in 0..