///|
pub(all) enum PlanChangeKind {
ChangeAdded
ChangeRemoved
ChangeIncreased
ChangeDecreased
ChangeModified
ChangeUnchanged
} derive(Eq, Debug)
///|
pub(all) struct PlanChange {
field : String
kind : PlanChangeKind
before : String
after : String
severity : Severity
detail : String
} derive(Eq, Debug)
///|
pub fn PlanChange::PlanChange(
field? : String = "",
kind? : PlanChangeKind = ChangeUnchanged,
before? : String = "",
after? : String = "",
severity? : Severity = Pass,
detail? : String = "",
) -> PlanChange {
{ field, kind, before, after, severity, detail }
}
///|
pub(all) struct PlanDiff {
verdict : String
risk_delta : Int
changes : Array[PlanChange]
} derive(Eq, Debug)
///|
pub fn compare_plans(base : RolloutPlan, candidate : RolloutPlan) -> PlanDiff {
let changes = Array::new(capacity=32)
push_string_change(changes, "name", base.name, candidate.name, severity=Warn)
push_string_change(changes, "salt", base.salt, candidate.salt, severity=Fail)
push_string_change(
changes,
"owner",
base.owner,
candidate.owner,
severity=Fail,
)
push_string_change(
changes,
"repository",
base.repository,
candidate.repository,
severity=Warn,
)
push_string_change(
changes,
"package",
base.package_name,
candidate.package_name,
severity=Fail,
)
push_int_change(
changes,
"phase.count",
base.phases.length(),
candidate.phases.length(),
higher_is_risk=false,
)
push_int_change(
changes,
"segment.count",
base.segments.length(),
candidate.segments.length(),
higher_is_risk=false,
)
push_int_change(
changes,
"duration.total",
plan_duration(base),
plan_duration(candidate),
higher_is_risk=false,
)
push_int_change(
changes,
"phase.max_percent_bp",
max_phase_percent(base),
max_phase_percent(candidate),
higher_is_risk=true,
)
push_int_change(
changes,
"budget.max_error_per_million",
base.budget.max_error_per_million,
candidate.budget.max_error_per_million,
higher_is_risk=true,
)
push_int_change(
changes,
"budget.max_latency_p95_ms",
base.budget.max_latency_p95_ms,
candidate.budget.max_latency_p95_ms,
higher_is_risk=true,
)
push_int_change(
changes,
"budget.min_samples",
base.budget.min_samples,
candidate.budget.min_samples,
higher_is_risk=false,
)
push_int_change(
changes,
"budget.max_saturation_bp",
base.budget.max_saturation_bp,
candidate.budget.max_saturation_bp,
higher_is_risk=true,
)
push_int_change(
changes,
"budget.max_blast_radius_bp",
base.budget.max_blast_radius_bp,
candidate.budget.max_blast_radius_bp,
higher_is_risk=true,
)
push_int_change(
changes,
"budget.max_dependency_failures",
base.budget.max_dependency_failures,
candidate.budget.max_dependency_failures,
higher_is_risk=true,
)
push_bool_change(
changes,
"budget.require_synthetic_pass",
base.budget.require_synthetic_pass,
candidate.budget.require_synthetic_pass,
)
push_phase_name_changes(changes, base, candidate)
push_segment_name_changes(changes, base, candidate)
let delta = plan_diff_risk_delta(changes)
let verdict = if has_failed_change(changes) {
"breaking-change"
} else if delta > 30 {
"needs-release-review"
} else if delta > 0 {
"minor-risk"
} else {
"compatible"
}
{ verdict, risk_delta: delta, changes }
}
///|
fn push_string_change(
changes : Array[PlanChange],
field : String,
before : String,
after : String,
severity? : Severity = Warn,
) -> Unit {
if before != after {
changes.push(
PlanChange(
field~,
kind=ChangeModified,
before~,
after~,
severity~,
detail="string value changed",
),
)
}
}
///|
fn push_int_change(
changes : Array[PlanChange],
field : String,
before : Int,
after : Int,
higher_is_risk? : Bool = true,
) -> Unit {
if before == after {
return
}
let kind = if after > before { ChangeIncreased } else { ChangeDecreased }
let risk = if higher_is_risk { after > before } else { after < before }
changes.push(
PlanChange(
field~,
kind~,
before=before.to_string(),
after=after.to_string(),
severity=if risk { Warn } else { Pass },
detail=if risk {
"change increases release risk"
} else {
"change reduces release risk"
},
),
)
}
///|
fn push_bool_change(
changes : Array[PlanChange],
field : String,
before : Bool,
after : Bool,
) -> Unit {
if before != after {
changes.push(
PlanChange(
field~,
kind=ChangeModified,
before=before.to_string(),
after=after.to_string(),
severity=if before && !after { Fail } else { Pass },
detail=if before && !after {
"candidate disables a safety gate"
} else {
"candidate enables a safety gate"
},
),
)
}
}
///|
fn push_phase_name_changes(
changes : Array[PlanChange],
base : RolloutPlan,
candidate : RolloutPlan,
) -> Unit {
for phase in base.phases {
if !has_phase_named(candidate, phase.name) {
changes.push(
PlanChange(
field="phase." + phase.name,
kind=ChangeRemoved,
before=mode_text(phase.mode),
after="missing",
severity=Warn,
detail="phase removed from candidate",
),
)
}
}
for phase in candidate.phases {
if !has_phase_named(base, phase.name) {
changes.push(
PlanChange(
field="phase." + phase.name,
kind=ChangeAdded,
before="missing",
after=mode_text(phase.mode),
severity=if phase.mode == Live { Warn } else { Pass },
detail="phase added in candidate",
),
)
}
}
}
///|
fn push_segment_name_changes(
changes : Array[PlanChange],
base : RolloutPlan,
candidate : RolloutPlan,
) -> Unit {
for segment in base.segments {
if !has_segment_named(candidate, segment.name) {
changes.push(
PlanChange(
field="segment." + segment.name,
kind=ChangeRemoved,
before=segment_summary(segment),
after="missing",
severity=Warn,
detail="segment removed from candidate",
),
)
}
}
for segment in candidate.segments {
if !has_segment_named(base, segment.name) {
changes.push(
PlanChange(
field="segment." + segment.name,
kind=ChangeAdded,
before="missing",
after=segment_summary(segment),
severity=Pass,
detail="segment added in candidate",
),
)
}
}
}
///|
fn has_phase_named(plan : RolloutPlan, name : String) -> Bool {
for phase in plan.phases {
if phase.name == name {
return true
}
}
false
}
///|
fn has_segment_named(plan : RolloutPlan, name : String) -> Bool {
for segment in plan.segments {
if segment.name == name {
return true
}
}
false
}
///|
pub fn max_phase_percent(plan : RolloutPlan) -> Int {
let mut max_percent = 0
for phase in plan.phases {
if phase.percent_bp > max_percent {
max_percent = phase.percent_bp
}
}
max_percent
}
///|
pub fn plan_diff_risk_delta(changes : Array[PlanChange]) -> Int {
let mut total = 0
for change in changes {
if change.severity == Fail {
total += 20
} else if change.severity == Warn {
total += 8
}
if change.kind == ChangeIncreased && change.severity != Pass {
total += 4
}
if change.kind == ChangeRemoved {
total += 3
}
}
total
}
///|
fn has_failed_change(changes : Array[PlanChange]) -> Bool {
for change in changes {
if change.severity == Fail {
return true
}
}
false
}
///|
pub fn change_kind_text(kind : PlanChangeKind) -> String {
match kind {
ChangeAdded => "added"
ChangeRemoved => "removed"
ChangeIncreased => "increased"
ChangeDecreased => "decreased"
ChangeModified => "modified"
ChangeUnchanged => "unchanged"
}
}
///|
pub fn PlanDiff::to_markdown(self : PlanDiff) -> String {
let out = StringBuilder()
out.write_string("# CanaryRing Plan Diff\n\n")
out.write_string("- Verdict: " + self.verdict + "\n")
out.write_string("- Risk delta: " + self.risk_delta.to_string() + "\n\n")
for change in self.changes {
out.write_string("- [" + severity_text(change.severity) + "] ")
out.write_string(change.field + " " + change_kind_text(change.kind))
out.write_string(" from `" + change.before + "` to `" + change.after + "`")
out.write_string(" -- " + change.detail + "\n")
}
out.to_string()
}
///|
pub fn PlanDiff::to_json(self : PlanDiff) -> String {
let out = StringBuilder()
out.write_string("{")
out.write_string("\"verdict\":\"" + json_escape(self.verdict) + "\",")
out.write_string("\"risk_delta\":" + self.risk_delta.to_string() + ",")
out.write_string("\"changes\":[")
for i in 0.. 0 {
out.write_string(",")
}
out.write_string("{")
out.write_string("\"field\":\"" + json_escape(change.field) + "\",")
out.write_string("\"kind\":\"" + change_kind_text(change.kind) + "\",")
out.write_string("\"severity\":\"" + severity_text(change.severity) + "\",")
out.write_string("\"before\":\"" + json_escape(change.before) + "\",")
out.write_string("\"after\":\"" + json_escape(change.after) + "\"")
out.write_string("}")
}
out.write_string("]}")
out.to_string()
}