///|
/// Deterministic recovery and workload forecasting primitives.
/// Forecasts are deliberately bounded and accompanied by holdout error so
/// consumers can show uncertainty rather than treating a point estimate as fact.
pub(all) enum ForecastMethod {
ForecastLastValue
ForecastMovingAverage
ForecastEwma
ForecastLinearTrend
ForecastEnsemble
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct ForecastConfig {
horizon : Int
window : Int
ewma_alpha : Double
minimum_history : Int
uncertainty_scale : Double
lower_bound : Double
upper_bound : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
pub fn ForecastConfig::default() -> ForecastConfig {
{
horizon: 7,
window: 7,
ewma_alpha: 0.25,
minimum_history: 3,
uncertainty_scale: 1.0,
lower_bound: 0.0,
upper_bound: 100.0,
}
}
///|
pub(all) struct ForecastPoint {
step : Int
value : Double
lower : Double
upper : Double
confidence : Double
algorithm : ForecastMethod
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct OperationalForecastResult {
algorithm : ForecastMethod
history_count : Int
points : Array[ForecastPoint]
baseline : Double
slope : Double
mean_absolute_error : Double
root_mean_squared_error : Double
coverage : Double
usable : Bool
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct ForecastBacktest {
folds : Int
observations : Int
mean_absolute_error : Double
mean_absolute_percentage_error : Double
directional_accuracy : Double
worst_error : Double
stable : Bool
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct ForecastBundle {
recovery : OperationalForecastResult
load : OperationalForecastResult
backtest : ForecastBacktest
recommendation : String
} derive(FromJson, ToJson, Debug, Eq)
///|
fn forecast_bound(value : Double, low : Double, high : Double) -> Double {
if value.is_nan() || value.is_inf() {
low
} else {
value.clamp(min=low, max=high)
}
}
///|
pub fn forecast_method_name(algorithm : ForecastMethod) -> String {
match algorithm {
ForecastLastValue => "last_value"
ForecastMovingAverage => "moving_average"
ForecastEwma => "ewma"
ForecastLinearTrend => "linear_trend"
ForecastEnsemble => "ensemble"
}
}
///|
fn forecast_copy(values : Array[Double]) -> Array[Double] {
let result = []
for value in values {
result.push(value)
}
result
}
///|
fn forecast_last(values : Array[Double]) -> Double {
if values.length() == 0 {
0.0
} else {
values[values.length() - 1]
}
}
///|
fn forecast_window(values : Array[Double], window : Int) -> Array[Double] {
let safe = window.max(1)
let start = (values.length() - safe).max(0)
let result = []
for i in start.. Double {
mean_value(forecast_window(values, window))
}
///|
pub fn forecast_ewma(values : Array[Double], alpha : Double) -> Double {
let weight = forecast_bound(alpha, 0.01, 1.0)
let mut result = 0.0
for value in values {
result = weight * value + (1.0 - weight) * result
}
result
}
///|
pub fn forecast_linear_next(values : Array[Double]) -> Double {
if values.length() == 0 {
0.0
} else {
let trend = fit_linear_trend(values)
trend.intercept + trend.slope * values.length().to_double()
}
}
///|
pub fn forecast_method_value(
values : Array[Double],
algorithm : ForecastMethod,
config : ForecastConfig,
) -> Double {
match algorithm {
ForecastLastValue => forecast_last(values)
ForecastMovingAverage => forecast_moving_average(values, config.window)
ForecastEwma => forecast_ewma(values, config.ewma_alpha)
ForecastLinearTrend => forecast_linear_next(values)
ForecastEnsemble =>
(
forecast_last(values) +
forecast_moving_average(values, config.window) +
forecast_ewma(values, config.ewma_alpha) +
forecast_linear_next(values)
) /
4.0
}
}
///|
fn forecast_residual_scale(values : Array[Double], baseline : Double) -> Double {
if values.length() < 2 {
0.0
} else {
let residuals = values.map(value => value - baseline)
standard_deviation(residuals)
}
}
///|
fn forecast_rmse(values : Array[Double], baseline : Double) -> Double {
if values.length() == 0 {
0.0
} else {
let mut total = 0.0
for value in values {
let error = value - baseline
total += error * error
}
(total / values.length().to_double()).sqrt()
}
}
///|
fn forecast_confidence(
history_count : Int,
error : Double,
scale : Double,
) -> Double {
let history_factor = (history_count.to_double() / 28.0)
.sqrt()
.clamp(min=0.15, max=1.0)
let error_factor = if scale <= 0.000001 {
1.0
} else {
(1.0 - error / (scale * 2.0)).clamp(min=0.0, max=1.0)
}
history_factor * error_factor
}
///|
pub fn forecast_point(
step : Int,
value : Double,
scale : Double,
confidence : Double,
algorithm : ForecastMethod,
config : ForecastConfig,
) -> ForecastPoint {
let margin = scale *
config.uncertainty_scale *
(1.0 + step.to_double() * 0.08)
{
step,
value: forecast_bound(value, config.lower_bound, config.upper_bound),
lower: forecast_bound(
value - margin,
config.lower_bound,
config.upper_bound,
),
upper: forecast_bound(
value + margin,
config.lower_bound,
config.upper_bound,
),
confidence: forecast_bound(confidence, 0.0, 1.0),
algorithm,
}
}
///|
pub fn forecast_values(
history : Array[Double],
algorithm : ForecastMethod,
config : ForecastConfig,
) -> OperationalForecastResult {
let values = forecast_copy(history)
let baseline = forecast_method_value(values, algorithm, config)
let slope = if values.length() < 2 {
0.0
} else {
fit_linear_trend(values).slope
}
let scale = forecast_residual_scale(values, baseline).max(0.5)
let error = if values.length() < 2 {
scale
} else {
mean_value(values.map(value => (value - baseline).abs()))
}
let confidence = forecast_confidence(values.length(), error, scale)
let points = []
let mut previous = baseline
for step in 1..<(config.horizon.max(0) + 1) {
let value = match algorithm {
ForecastLinearTrend => baseline + slope * (step - 1).to_double()
ForecastEnsemble => (baseline + previous) / 2.0
_ => baseline
}
let bounded = forecast_bound(value, config.lower_bound, config.upper_bound)
points.push(
forecast_point(step, bounded, scale, confidence, algorithm, config),
)
previous = bounded
}
{
algorithm,
history_count: values.length(),
points,
baseline,
slope,
mean_absolute_error: error,
root_mean_squared_error: forecast_rmse(values, baseline),
coverage: if values.length() < config.minimum_history {
0.0
} else {
1.0
},
usable: values.length() >= config.minimum_history && config.horizon >= 0,
}
}
///|
pub fn forecast_result_is_usable(result : OperationalForecastResult) -> Bool {
result.usable && result.points.length() > 0 && result.coverage >= 0.0
}
///|
pub fn forecast_result_feature_vector(
result : OperationalForecastResult,
) -> Array[Double] {
[
result.history_count.to_double(),
result.baseline,
result.slope,
result.mean_absolute_error,
result.root_mean_squared_error,
result.coverage,
result.points.length().to_double(),
]
}
///|
pub fn forecast_points_csv(result : OperationalForecastResult) -> String {
let grid = [["step", "value", "lower", "upper", "confidence", "algorithm"]]
for point in result.points {
grid.push([
point.step.to_string(),
point.value.to_string(),
point.lower.to_string(),
point.upper.to_string(),
point.confidence.to_string(),
forecast_method_name(point.algorithm),
])
}
to_csv(grid)
}
///|
pub fn forecast_backtest(
history : Array[Double],
algorithm : ForecastMethod,
config : ForecastConfig,
holdout : Int,
) -> ForecastBacktest {
let safe_holdout = holdout.clamp(min=1, max=history.length().max(1))
let start = (history.length() - safe_holdout).max(config.minimum_history)
let errors = []
let percentage_errors = []
let directions = []
let mut worst = 0.0
let mut folds = 0
for i in start.. 0 {
let previous = history[i - 1]
let predicted_direction = predicted - forecast_last(training)
let actual_direction = actual - previous
directions.push(
(predicted_direction >= 0.0 && actual_direction >= 0.0) ||
(predicted_direction < 0.0 && actual_direction < 0.0),
)
}
if error > worst {
worst = error
}
folds += 1
}
let accuracy = if directions.length() == 0 {
0.0
} else {
directions.filter(value => value).length().to_double() /
directions.length().to_double()
}
{
folds,
observations: errors.length(),
mean_absolute_error: mean_value(errors),
mean_absolute_percentage_error: mean_value(percentage_errors),
directional_accuracy: accuracy,
worst_error: worst,
stable: errors.length() > 0 && mean_value(errors) <= worst.max(1.0) * 0.75,
}
}
///|
pub fn forecast_backtest_csv(backtest : ForecastBacktest) -> String {
to_csv([
[
"folds", "observations", "mae", "mape", "directional_accuracy", "worst_error",
"stable",
],
[
backtest.folds.to_string(),
backtest.observations.to_string(),
backtest.mean_absolute_error.to_string(),
backtest.mean_absolute_percentage_error.to_string(),
backtest.directional_accuracy.to_string(),
backtest.worst_error.to_string(),
backtest.stable.to_string(),
],
])
}
///|
pub fn build_forecast_bundle(
recovery_scores : Array[Double],
load_values : Array[Double],
config : ForecastConfig,
) -> ForecastBundle {
let recovery = forecast_values(recovery_scores, ForecastEnsemble, config)
let load = forecast_values(load_values, ForecastEwma, config)
let backtest = forecast_backtest(
recovery_scores,
ForecastEnsemble,
config,
config.horizon,
)
let recommendation = if !recovery.usable {
"Collect more recovery history before using a forecast."
} else if recovery.slope < -1.0 {
"Forecast indicates a declining recovery trend; lower planned intensity."
} else if load.slope > 10.0 {
"Forecast indicates rising load; add a controlled day."
} else {
"Forecast is stable enough for conservative planning."
}
{ recovery, load, backtest, recommendation }
}
///|
pub fn forecast_bundle_feature_vector(bundle : ForecastBundle) -> Array[Double] {
let result = []
for value in forecast_result_feature_vector(bundle.recovery) {
result.push(value)
}
for value in forecast_result_feature_vector(bundle.load) {
result.push(value)
}
result.push(bundle.backtest.mean_absolute_error)
result.push(bundle.backtest.directional_accuracy)
result
}
///|
pub fn forecast_bundle_csv(bundle : ForecastBundle) -> String {
let grid = [
["series", "method", "baseline", "slope", "mae", "coverage", "usable"],
[
"recovery",
forecast_method_name(bundle.recovery.algorithm),
bundle.recovery.baseline.to_string(),
bundle.recovery.slope.to_string(),
bundle.recovery.mean_absolute_error.to_string(),
bundle.recovery.coverage.to_string(),
bundle.recovery.usable.to_string(),
],
[
"load",
forecast_method_name(bundle.load.algorithm),
bundle.load.baseline.to_string(),
bundle.load.slope.to_string(),
bundle.load.mean_absolute_error.to_string(),
bundle.load.coverage.to_string(),
bundle.load.usable.to_string(),
],
]
to_csv(grid)
}
///|
pub fn forecast_adjusted_intensity(
forecast : OperationalForecastResult,
requested : Double,
) -> Double {
let direction = if forecast.slope < -1.0 {
0.65
} else if forecast.slope > 5.0 {
0.80
} else {
1.0
}
forecast_bound(
requested * direction * (0.75 + forecast.coverage * 0.25),
0.10,
1.0,
)
}
///|
pub fn forecast_interval_width(point : ForecastPoint) -> Double {
point.upper - point.lower
}
///|
pub fn forecast_average_interval(result : OperationalForecastResult) -> Double {
mean_value(result.points.map(point => forecast_interval_width(point)))
}
///|
pub fn forecast_last_point(
result : OperationalForecastResult,
) -> ForecastPoint? {
if result.points.length() == 0 {
None
} else {
Some(result.points[result.points.length() - 1])
}
}
///|
pub fn forecast_point_at(
result : OperationalForecastResult,
step : Int,
) -> ForecastPoint? {
for point in result.points {
if point.step == step {
return Some(point)
}
}
None
}
///|
pub fn forecast_is_declining(result : OperationalForecastResult) -> Bool {
result.slope < -1.0 && result.coverage >= 0.50
}