///|
pub fn simulate_rollout(
plan : RolloutPlan,
users : Array[UserProfile],
metrics : Array[MetricsSnapshot],
) -> SimulationReport {
let findings = validate_plan(plan)
let steps = Array::new(capacity=plan.phases.length())
let mut final_gate = {
decision: Advance,
severity: Pass,
score: 100,
reasons: ["simulation has no phases"],
}
let mut final_blast = {
exposed_users: 0,
exposed_traffic_units: 0,
region_count: 0,
tier_count: 0,
percent_bp: 0,
risk_score: 0,
}
for phase in plan.phases {
let minute = phase.start_minute + phase.duration_minutes - 1
let blast = blast_radius_for_users(plan, users, minute)
let snapshot = metrics_for_minute(metrics, minute)
let gate = evaluate_gate(plan.budget, snapshot, blast)
steps.push({
minute,
phase_name: phase.name,
exposed_users: blast.exposed_users,
exposed_traffic_units: blast.exposed_traffic_units,
gate,
})
final_gate = gate
final_blast = blast
}
let score = simulation_score(findings, final_gate, final_blast)
{
plan_name: plan.name,
verdict: simulation_verdict(findings, final_gate, score),
score,
findings,
steps,
final_gate,
blast_radius: final_blast,
}
}
///|
pub fn simulation_markdown(
plan : RolloutPlan,
users : Array[UserProfile],
metrics : Array[MetricsSnapshot],
) -> String {
simulate_rollout(plan, users, metrics).to_markdown()
}
///|
pub fn simulation_json(
plan : RolloutPlan,
users : Array[UserProfile],
metrics : Array[MetricsSnapshot],
) -> String {
simulate_rollout(plan, users, metrics).to_json()
}
///|
fn metrics_for_minute(
metrics : Array[MetricsSnapshot],
minute : Int,
) -> MetricsSnapshot {
let mut chosen = MetricsSnapshot::MetricsSnapshot(minute~)
let mut best_delta = 2147483
for item in metrics {
let delta = abs_int(item.minute - minute)
if delta < best_delta {
best_delta = delta
chosen = item
}
}
chosen
}
///|
fn abs_int(value : Int) -> Int {
if value < 0 {
-value
} else {
value
}
}
///|
fn simulation_score(
findings : Array[RolloutFinding],
gate : GateResult,
blast : BlastRadius,
) -> Int {
let mut score = gate.score
for finding in findings {
score -= finding.penalty
}
score -= blast.risk_score / 8
clamp(score, 0, 100)
}
///|
fn simulation_verdict(
findings : Array[RolloutFinding],
gate : GateResult,
score : Int,
) -> String {
let mut failures = 0
for finding in findings {
if finding.severity == Fail {
failures += 1
}
}
if failures > 0 || gate.decision == Rollback || gate.decision == Abort {
"blocked"
} else if gate.decision == Hold {
"hold"
} else if score >= 90 {
"ready-to-advance"
} else {
"needs-review"
}
}
///|
pub fn SimulationReport::has_blockers(self : SimulationReport) -> Bool {
self.verdict == "blocked"
}
///|
pub fn SimulationReport::warning_count(self : SimulationReport) -> Int {
let mut total = 0
for finding in self.findings {
if finding.severity == Warn {
total += 1
}
}
if self.final_gate.severity == Warn {
total += 1
}
total
}
///|
pub fn SimulationReport::to_markdown(self : SimulationReport) -> String {
let out = StringBuilder()
out.write_string("# CanaryRing Simulation\n\n")
out.write_string("- Plan: " + self.plan_name + "\n")
out.write_string("- Verdict: " + self.verdict + "\n")
out.write_string("- Score: " + self.score.to_string() + "/100\n")
out.write_string(
"- Final gate: " + decision_text(self.final_gate.decision) + "\n",
)
out.write_string(
"- Final gate score: " + self.final_gate.score.to_string() + "/100\n",
)
out.write_string(
"- Final blast radius: " +
percent_text(self.blast_radius.percent_bp) +
"\n\n",
)
out.write_string("## Plan Findings\n\n")
for finding in self.findings {
out.write_string("- [" + severity_text(finding.severity) + "] ")
out.write_string(finding.id + " (" + kind_text(finding.kind) + "): ")
out.write_string(finding.title)
if finding.evidence != "" {
out.write_string(" -- " + finding.evidence)
}
if finding.repair != "" && finding.severity != Pass {
out.write_string(" Fix: " + finding.repair)
}
out.write_string("\n")
}
out.write_string("\n## Steps\n\n")
for step in self.steps {
out.write_string("- minute " + step.minute.to_string() + " ")
out.write_string(step.phase_name + ": ")
out.write_string(step.exposed_users.to_string() + " users, ")
out.write_string(step.exposed_traffic_units.to_string() + " traffic, gate=")
out.write_string(decision_text(step.gate.decision) + " ")
out.write_string(step.gate.score.to_string() + "/100\n")
}
out.write_string("\n## Gate Reasons\n\n")
for reason in self.final_gate.reasons {
out.write_string("- " + reason + "\n")
}
out.write_string("\n")
out.write_string(blast_radius_markdown(self.blast_radius))
out.to_string()
}
///|
pub fn SimulationReport::to_json(self : SimulationReport) -> String {
let out = StringBuilder()
out.write_string("{")
out.write_string("\"plan\":\"" + json_escape(self.plan_name) + "\",")
out.write_string("\"verdict\":\"" + json_escape(self.verdict) + "\",")
out.write_string("\"score\":" + self.score.to_string() + ",")
out.write_string(
"\"final_gate\":\"" + decision_text(self.final_gate.decision) + "\",",
)
out.write_string(
"\"final_gate_score\":" + self.final_gate.score.to_string() + ",",
)
out.write_string(
"\"blast_percent_bp\":" + self.blast_radius.percent_bp.to_string() + ",",
)
out.write_string("\"steps\":[")
for i in 0.. 0 {
out.write_string(",")
}
out.write_string("{")
out.write_string("\"minute\":" + step.minute.to_string() + ",")
out.write_string("\"phase\":\"" + json_escape(step.phase_name) + "\",")
out.write_string("\"users\":" + step.exposed_users.to_string() + ",")
out.write_string(
"\"traffic\":" + step.exposed_traffic_units.to_string() + ",",
)
out.write_string("\"gate\":\"" + decision_text(step.gate.decision) + "\"")
out.write_string("}")
}
out.write_string("]}")
out.to_string()
}