///|
pub(all) struct TrafficForecast {
base_users : Int
base_traffic_units : Int
growth_bp_per_hour : Int
peak_multiplier_bp : Int
safety_margin_bp : Int
} derive(Eq, Debug)
///|
pub fn TrafficForecast::TrafficForecast(
base_users? : Int = 0,
base_traffic_units? : Int = 0,
growth_bp_per_hour? : Int = 0,
peak_multiplier_bp? : Int = 10000,
safety_margin_bp? : Int = 0,
) -> TrafficForecast {
{
base_users,
base_traffic_units,
growth_bp_per_hour,
peak_multiplier_bp,
safety_margin_bp,
}
}
///|
pub(all) struct ForecastStep {
phase_name : String
minute : Int
projected_users : Int
projected_traffic_units : Int
exposure_bp : Int
risk_score : Int
budget_status : Severity
} derive(Eq, Debug)
///|
pub(all) struct ForecastReport {
verdict : String
peak_users : Int
peak_traffic_units : Int
peak_risk_score : Int
steps : Array[ForecastStep]
} derive(Eq, Debug)
///|
pub fn forecast_rollout(
plan : RolloutPlan,
forecast : TrafficForecast,
) -> ForecastReport {
let steps = Array::new(capacity=plan.phases.length())
let mut peak_users = 0
let mut peak_traffic = 0
let mut peak_risk = 0
let mut failed = false
let mut warned = false
for phase in plan.phases {
let minute = phase.start_minute + phase.duration_minutes
let users = projected_count(
forecast.base_users,
minute,
forecast.growth_bp_per_hour,
forecast.peak_multiplier_bp,
forecast.safety_margin_bp,
)
let traffic = projected_count(
forecast.base_traffic_units,
minute,
forecast.growth_bp_per_hour,
forecast.peak_multiplier_bp,
forecast.safety_margin_bp,
)
let exposed_users = users * clamp(phase.percent_bp, 0, 10000) / 10000
let exposed_traffic = traffic * clamp(phase.percent_bp, 0, 10000) / 10000
let risk = forecast_risk_score(exposed_users, exposed_traffic, phase)
let status = forecast_status(plan.budget, phase.percent_bp, risk)
if status == Fail {
failed = true
} else if status == Warn {
warned = true
}
if exposed_users > peak_users {
peak_users = exposed_users
}
if exposed_traffic > peak_traffic {
peak_traffic = exposed_traffic
}
if risk > peak_risk {
peak_risk = risk
}
steps.push({
phase_name: phase.name,
minute,
projected_users: exposed_users,
projected_traffic_units: exposed_traffic,
exposure_bp: phase.percent_bp,
risk_score: risk,
budget_status: status,
})
}
let verdict = if failed {
"forecast-blocked"
} else if warned {
"forecast-review"
} else {
"forecast-ready"
}
{
verdict,
peak_users,
peak_traffic_units: peak_traffic,
peak_risk_score: peak_risk,
steps,
}
}
///|
fn projected_count(
base : Int,
minute : Int,
growth_bp_per_hour : Int,
peak_multiplier_bp : Int,
safety_margin_bp : Int,
) -> Int {
let hours = if minute <= 0 { 0 } else { (minute + 59) / 60 }
let grown = base + base * growth_bp_per_hour * hours / 10000
let peaked = if peak_multiplier_bp >= 10000 {
grown + grown * (peak_multiplier_bp - 10000) / 10000
} else {
grown * peak_multiplier_bp / 10000
}
if safety_margin_bp >= 0 {
peaked + peaked * safety_margin_bp / 10000
} else {
peaked * (10000 + safety_margin_bp) / 10000
}
}
///|
fn forecast_risk_score(users : Int, traffic : Int, phase : RolloutPhase) -> Int {
let user_part = users / 100
let traffic_part = traffic / 250
let mode_part = if phase.mode == Live {
20
} else if phase.mode == DryRun {
10
} else if phase.mode == KillSwitch {
5
} else {
2
}
clamp(user_part + traffic_part + mode_part + phase.percent_bp / 500, 0, 100)
}
///|
fn forecast_status(
budget : RiskBudget,
exposure_bp : Int,
risk_score : Int,
) -> Severity {
if exposure_bp > budget.max_blast_radius_bp || risk_score >= 85 {
Fail
} else if exposure_bp > budget.max_blast_radius_bp * 8 / 10 ||
risk_score >= 65 {
Warn
} else {
Pass
}
}
///|
pub fn default_traffic_forecast() -> TrafficForecast {
TrafficForecast(
base_users=20000,
base_traffic_units=125000,
growth_bp_per_hour=250,
peak_multiplier_bp=14000,
safety_margin_bp=1200,
)
}
///|
pub fn ForecastReport::warning_count(self : ForecastReport) -> Int {
let mut total = 0
for step in self.steps {
if step.budget_status == Warn {
total += 1
}
}
total
}
///|
pub fn ForecastReport::failure_count(self : ForecastReport) -> Int {
let mut total = 0
for step in self.steps {
if step.budget_status == Fail {
total += 1
}
}
total
}
///|
pub fn forecast_markdown(report : ForecastReport) -> String {
let out = StringBuilder()
out.write_string("# CanaryRing Traffic Forecast\n\n")
out.write_string("- Verdict: " + report.verdict + "\n")
out.write_string("- Peak users: " + report.peak_users.to_string() + "\n")
out.write_string(
"- Peak traffic units: " + report.peak_traffic_units.to_string() + "\n",
)
out.write_string(
"- Peak risk score: " + report.peak_risk_score.to_string() + "/100\n\n",
)
for step in report.steps {
out.write_string("- [" + severity_text(step.budget_status) + "] ")
out.write_string(step.phase_name + " minute " + step.minute.to_string())
out.write_string(" users=" + step.projected_users.to_string())
out.write_string(" traffic=" + step.projected_traffic_units.to_string())
out.write_string(" exposure=" + percent_text(step.exposure_bp))
out.write_string(" risk=" + step.risk_score.to_string() + "/100\n")
}
out.to_string()
}
///|
pub fn forecast_json(report : ForecastReport) -> String {
let out = StringBuilder()
out.write_string("{")
out.write_string("\"verdict\":\"" + json_escape(report.verdict) + "\",")
out.write_string("\"peak_users\":" + report.peak_users.to_string() + ",")
out.write_string(
"\"peak_traffic_units\":" + report.peak_traffic_units.to_string() + ",",
)
out.write_string(
"\"peak_risk_score\":" + report.peak_risk_score.to_string() + ",",
)
out.write_string("\"steps\":[")
for i in 0.. 0 {
out.write_string(",")
}
out.write_string("{")
out.write_string("\"phase\":\"" + json_escape(step.phase_name) + "\",")
out.write_string("\"minute\":" + step.minute.to_string() + ",")
out.write_string("\"users\":" + step.projected_users.to_string() + ",")
out.write_string(
"\"traffic\":" + step.projected_traffic_units.to_string() + ",",
)
out.write_string("\"exposure_bp\":" + step.exposure_bp.to_string() + ",")
out.write_string("\"risk_score\":" + step.risk_score.to_string() + ",")
out.write_string("\"status\":\"" + severity_text(step.budget_status) + "\"")
out.write_string("}")
}
out.write_string("]}")
out.to_string()
}