///|
pub(all) struct PhaseRecommendation {
next_percent_bp : Int
next_mode : RolloutMode
decision : GateDecision
title : String
rationale : String
action_items : Array[String]
} derive(Eq, Debug)
///|
pub fn recommend_next_phase(
plan : RolloutPlan,
users : Array[UserProfile],
metrics : Array[MetricsSnapshot],
minute : Int,
) -> PhaseRecommendation {
let blast = blast_radius_for_users(plan, users, minute)
let latest = latest_metric(metrics)
let gate = evaluate_gate(plan.budget, latest, blast)
let current_percent = match active_phase(plan, minute) {
Some(phase) => phase.percent_bp
None => 0
}
if gate.decision == Rollback || gate.decision == Abort {
return {
next_percent_bp: 0,
next_mode: KillSwitch,
decision: Rollback,
title: "Rollback or kill switch",
rationale: "Gate returned " +
decision_text(gate.decision) +
" at score " +
gate.score.to_string(),
action_items: rollback_actions(gate),
}
}
if gate.decision == Hold {
return {
next_percent_bp: current_percent,
next_mode: current_mode(plan, minute),
decision: Hold,
title: "Hold current ring",
rationale: "Risk gate needs more evidence before advancing.",
action_items: hold_actions(gate),
}
}
let next_percent = next_percent_step(current_percent)
{
next_percent_bp: next_percent,
next_mode: if next_percent == 0 {
Shadow
} else {
Live
},
decision: Advance,
title: "Advance to " + percent_text(next_percent),
rationale: "All gates passed and blast radius is inside budget.",
action_items: advance_actions(next_percent),
}
}
///|
fn current_mode(plan : RolloutPlan, minute : Int) -> RolloutMode {
match active_phase(plan, minute) {
Some(phase) => phase.mode
None => Shadow
}
}
///|
fn next_percent_step(current_percent : Int) -> Int {
if current_percent < 100 {
100
} else if current_percent < 500 {
500
} else if current_percent < 1000 {
1000
} else if current_percent < 2500 {
2500
} else if current_percent < 5000 {
5000
} else if current_percent < 7500 {
7500
} else {
10000
}
}
///|
fn rollback_actions(gate : GateResult) -> Array[String] {
let actions = Array::new(capacity=gate.reasons.length() + 3)
actions.push("Set rollout mode to KillSwitch or reduce percent_bp to 0.")
actions.push("Record the failing metric window in the runbook.")
actions.push("Open a follow-up work item before re-advancing.")
for reason in gate.reasons {
actions.push("Investigate: " + reason)
}
actions
}
///|
fn hold_actions(gate : GateResult) -> Array[String] {
let actions = Array::new(capacity=gate.reasons.length() + 2)
actions.push("Keep the current stable bucket assignment.")
actions.push("Collect another metric window before changing percent_bp.")
for reason in gate.reasons {
actions.push("Watch: " + reason)
}
actions
}
///|
fn advance_actions(next_percent : Int) -> Array[String] {
let actions = Array::new(capacity=4)
actions.push("Create the next phase at " + percent_text(next_percent) + ".")
actions.push("Keep the same rollout salt to avoid user reshuffle.")
actions.push("Record CI, test and metric evidence before publishing.")
if next_percent >= 5000 {
actions.push("Require manual approval for this large live phase.")
}
actions
}
///|
pub fn recommendation_markdown(recommendation : PhaseRecommendation) -> String {
let out = StringBuilder()
out.write_string("# CanaryRing Recommendation\n\n")
out.write_string(
"- Decision: " + decision_text(recommendation.decision) + "\n",
)
out.write_string("- Next mode: " + mode_text(recommendation.next_mode) + "\n")
out.write_string(
"- Next percent: " + percent_text(recommendation.next_percent_bp) + "\n",
)
out.write_string("- Title: " + recommendation.title + "\n")
out.write_string("- Rationale: " + recommendation.rationale + "\n\n")
out.write_string("## Action Items\n\n")
for item in recommendation.action_items {
out.write_string("- " + item + "\n")
}
out.to_string()
}
///|
pub fn compare_recommendations(
left : PhaseRecommendation,
right : PhaseRecommendation,
) -> PhaseRecommendation {
if decision_rank(left.decision) >= decision_rank(right.decision) {
left
} else {
right
}
}
///|
fn decision_rank(decision : GateDecision) -> Int {
match decision {
Advance => 0
Hold => 1
Rollback => 2
Abort => 3
}
}
///|
pub fn explain_bucket(
user : UserProfile,
plan : RolloutPlan,
minute : Int,
) -> String {
let exposure = exposure_for_user(plan, user, minute)
let out = StringBuilder()
out.write_string(user.id + " bucket=" + exposure.bucket_bp.to_string())
out.write_string(" cohort=" + split_bucket(exposure.bucket_bp))
out.write_string(" phase=" + exposure.phase_name)
out.write_string(" mode=" + mode_text(exposure.mode))
out.write_string(" enabled=" + exposure.enabled.to_string())
out.write_string(" reason=" + exposure.reason)
out.to_string()
}
///|
pub fn cohort_distribution(
users : Array[UserProfile],
salt : String,
ring_count : Int,
) -> Array[String] {
let out = Array::new(capacity=users.length())
for user in users {
out.push(user.id + "=" + cohort_label(user.id, salt, ring_count))
}
out
}