///|
/// Qualitative stability of a history-based storage projection.
pub(all) enum ForecastConfidence {
Insufficient
Low
Medium
High
} derive(Eq, Debug, ToJson, FromJson)
///|
/// One projected storage observation at a future timestamp.
pub(all) struct StorageProjection {
created_at : Int64
projected_bytes : Int64
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Deterministic linear projection derived from snapshot history.
pub(all) struct StorageForecast {
observations : Int
horizon_days : Int
current_bytes : Int64
daily_growth_bytes : Int64
volatility_bytes : Int64
projected_bytes : Int64
confidence : ForecastConfidence
projections : Array[StorageProjection]
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Result of estimating when a configured capacity will be reached.
pub(all) struct CapacityEstimate {
capacity_bytes : Int64
current_bytes : Int64
daily_growth_bytes : Int64
days_remaining : Int?
estimated_at : Int64?
already_exceeded : Bool
growing : Bool
} derive(Eq, Debug, ToJson, FromJson)
///|
fn forecast_abs(value : Int64) -> Int64 {
if value < 0L {
-value
} else {
value
}
}
///|
fn history_daily_rates(history : SnapshotHistory) -> Array[Int64] {
let rates : Array[Int64] = []
if history.snapshots.length() < 2 {
return rates
}
for index = 1; index < history.snapshots.length(); index = index + 1 {
let previous = history.snapshots[index - 1]
let current = history.snapshots[index]
let elapsed = current.created_at - previous.created_at
if elapsed > 0L {
let bytes = current.total_size() - previous.total_size()
rates.push(bytes * 86400L / elapsed)
}
}
rates
}
///|
fn average_int64(values : Array[Int64]) -> Int64 {
if values.is_empty() {
return 0L
}
let mut total = 0L
for value in values {
total += value
}
total / values.length().to_int64()
}
///|
fn rate_volatility(rates : Array[Int64], average : Int64) -> Int64 {
if rates.is_empty() {
return 0L
}
let deviations = rates.map(fn(rate) { forecast_abs(rate - average) })
average_int64(deviations)
}
///|
fn forecast_confidence(
observations : Int,
average : Int64,
volatility : Int64,
) -> ForecastConfidence {
if observations < 2 {
Insufficient
} else if observations < 4 {
Low
} else {
let baseline = forecast_abs(average)
if baseline == 0L {
if volatility == 0L {
High
} else {
Low
}
} else if volatility * 4L <= baseline {
High
} else if volatility <= baseline {
Medium
} else {
Low
}
}
}
///|
pub fn forecast_confidence_name(confidence : ForecastConfidence) -> String {
match confidence {
Insufficient => "insufficient"
Low => "low"
Medium => "medium"
High => "high"
}
}
///|
fn non_negative_bytes(value : Int64) -> Int64 {
if value < 0L {
0L
} else {
value
}
}
///|
/// Project one point per future day using the mean rate between actual
/// observations. No wall-clock access or floating-point rounding is involved.
pub fn forecast_storage(
history : SnapshotHistory,
horizon_days : Int,
) -> StorageForecast raise {
if horizon_days < 0 {
raise Failure("Forecast horizon cannot be negative")
}
let observations = history.snapshots.length()
let current = if observations == 0 {
0L
} else {
history.snapshots[observations - 1].total_size()
}
let latest_at = if observations == 0 {
0L
} else {
history.snapshots[observations - 1].created_at
}
let rates = history_daily_rates(history)
let daily_growth = average_int64(rates)
let volatility = rate_volatility(rates, daily_growth)
let projections : Array[StorageProjection] = []
for day = 1; day <= horizon_days; day = day + 1 {
projections.push(StorageProjection::{
created_at: latest_at + day.to_int64() * 86400L,
projected_bytes: non_negative_bytes(
current + daily_growth * day.to_int64(),
),
})
}
StorageForecast::{
observations,
horizon_days,
current_bytes: current,
daily_growth_bytes: daily_growth,
volatility_bytes: volatility,
projected_bytes: if projections.is_empty() {
current
} else {
projections[projections.length() - 1].projected_bytes
},
confidence: forecast_confidence(observations, daily_growth, volatility),
projections,
}
}
///|
pub fn estimate_capacity(
history : SnapshotHistory,
capacity_bytes : Int64,
) -> CapacityEstimate raise {
if capacity_bytes <= 0L {
raise Failure("Capacity must be greater than zero")
}
let forecast = forecast_storage(history, 0)
let latest_at = if history.snapshots.is_empty() {
0L
} else {
history.snapshots[history.snapshots.length() - 1].created_at
}
if forecast.current_bytes >= capacity_bytes {
CapacityEstimate::{
capacity_bytes,
current_bytes: forecast.current_bytes,
daily_growth_bytes: forecast.daily_growth_bytes,
days_remaining: Some(0),
estimated_at: Some(latest_at),
already_exceeded: true,
growing: forecast.daily_growth_bytes > 0L,
}
} else if forecast.daily_growth_bytes <= 0L {
CapacityEstimate::{
capacity_bytes,
current_bytes: forecast.current_bytes,
daily_growth_bytes: forecast.daily_growth_bytes,
days_remaining: None,
estimated_at: None,
already_exceeded: false,
growing: false,
}
} else {
let remaining = capacity_bytes - forecast.current_bytes
let days = ((remaining + forecast.daily_growth_bytes - 1L) /
forecast.daily_growth_bytes).to_int()
CapacityEstimate::{
capacity_bytes,
current_bytes: forecast.current_bytes,
daily_growth_bytes: forecast.daily_growth_bytes,
days_remaining: Some(days),
estimated_at: Some(latest_at + days.to_int64() * 86400L),
already_exceeded: false,
growing: true,
}
}
}
///|
pub fn render_storage_forecast(
forecast : StorageForecast,
capacity? : CapacityEstimate? = None,
) -> String {
let lines : Array[String] = [
"MoonDisk storage forecast",
"Observations: " + forecast.observations.to_string(),
"Confidence: " + forecast_confidence_name(forecast.confidence),
"Current size: " + format_bytes(forecast.current_bytes),
"Average daily growth: " + format_bytes(forecast.daily_growth_bytes),
"Rate volatility: " + format_bytes(forecast.volatility_bytes),
"Projected size after " +
forecast.horizon_days.to_string() +
" days: " +
format_bytes(forecast.projected_bytes),
]
match capacity {
None => ()
Some(estimate) => {
lines.push("Capacity: " + format_bytes(estimate.capacity_bytes))
if estimate.already_exceeded {
lines.push("Capacity status: already exceeded")
} else {
match estimate.days_remaining {
None => lines.push("Capacity status: no exhaustion projected")
Some(days) =>
lines.push(
"Capacity status: estimated in " + days.to_string() + " days",
)
}
}
}
}
lines.join("\n")
}
///|
pub fn storage_forecast_to_json(forecast : StorageForecast) -> String {
ToJson::to_json(forecast).stringify(indent=2)
}
///|
pub fn capacity_estimate_to_json(estimate : CapacityEstimate) -> String {
ToJson::to_json(estimate).stringify(indent=2)
}