///|
/// Compare two retention plans by capsule id.
///
/// This is useful when an application changes retention rules or imports a new
/// inventory snapshot and wants to know whether operator work increased,
/// decreased, or stayed stable.
pub(all) enum PlanShiftKind {
CapsuleAdded
CapsuleRemoved
ActionChanged
SeverityChanged
DecisionStable
} derive(Eq, Debug)
///|
pub(all) enum PlanShiftImpact {
RiskIncreased
RiskReduced
ReviewNeeded
NoImpact
} derive(Eq, Debug)
///|
pub(all) struct PlanShift {
capsule_id : String
kind : PlanShiftKind
impact : PlanShiftImpact
before_action : String
after_action : String
before_severity : String
after_severity : String
message : String
} derive(Eq, Debug)
///|
pub(all) struct PlanDelta {
before_day : Int
after_day : Int
shifts : Array[PlanShift]
increased_count : Int
reduced_count : Int
review_count : Int
stable_count : Int
} derive(Eq, Debug)
///|
pub fn PlanShiftKind::label(self : PlanShiftKind) -> String {
match self {
CapsuleAdded => "capsule-added"
CapsuleRemoved => "capsule-removed"
ActionChanged => "action-changed"
SeverityChanged => "severity-changed"
DecisionStable => "stable"
}
}
///|
pub fn PlanShiftImpact::label(self : PlanShiftImpact) -> String {
match self {
RiskIncreased => "risk-increased"
RiskReduced => "risk-reduced"
ReviewNeeded => "review-needed"
NoImpact => "no-impact"
}
}
///|
pub fn compare_retention_plans(
before : RetentionPlan,
after : RetentionPlan,
) -> PlanDelta {
let shifts : Array[PlanShift] = []
for old in before.decisions {
match after.decision(old.capsule.id) {
Some(current) => shifts.push(compare_decision_pair(old, current))
None =>
shifts.push({
capsule_id: old.capsule.id,
kind: CapsuleRemoved,
impact: if old.needs_operator_action() {
RiskReduced
} else {
ReviewNeeded
},
before_action: old.action.label(),
after_action: "-",
before_severity: old.severity.label(),
after_severity: "-",
message: "capsule disappeared from the after plan",
})
}
}
for current in after.decisions {
if before.decision(current.capsule.id) is None {
shifts.push({
capsule_id: current.capsule.id,
kind: CapsuleAdded,
impact: if current.needs_operator_action() {
RiskIncreased
} else {
ReviewNeeded
},
before_action: "-",
after_action: current.action.label(),
before_severity: "-",
after_severity: current.severity.label(),
message: "capsule is new in the after plan",
})
}
}
let shifts = sort_plan_shifts(shifts)
{
before_day: before.generated_day,
after_day: after.generated_day,
increased_count: shifts.count_if(shift => shift.impact == RiskIncreased),
reduced_count: shifts.count_if(shift => shift.impact == RiskReduced),
review_count: shifts.count_if(shift => shift.impact == ReviewNeeded),
stable_count: shifts.count_if(shift => shift.impact == NoImpact),
shifts,
}
}
///|
pub fn PlanDelta::is_empty(self : PlanDelta) -> Bool {
self.shifts.all(shift => shift.kind == DecisionStable)
}
///|
pub fn PlanDelta::has_risk_increase(self : PlanDelta) -> Bool {
self.increased_count > 0
}
///|
pub fn PlanDelta::changed_shifts(self : PlanDelta) -> Array[PlanShift] {
self.shifts.filter(shift => shift.kind != DecisionStable)
}
///|
pub fn PlanDelta::to_markdown(self : PlanDelta) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Plan Delta")
lines.push("")
lines.push("- Before day: " + self.before_day.to_string())
lines.push("- After day: " + self.after_day.to_string())
lines.push("- Risk increased: " + self.increased_count.to_string())
lines.push("- Risk reduced: " + self.reduced_count.to_string())
lines.push("- Review needed: " + self.review_count.to_string())
lines.push("- Stable: " + self.stable_count.to_string())
lines.push("")
lines.push("| Capsule | Change | Impact | Before | After | Message |")
lines.push("| --- | --- | --- | --- | --- | --- |")
if self.shifts.is_empty() {
lines.push("| - | stable | no-impact | - | - | no decisions |")
} else {
for shift in self.shifts {
lines.push(
"| " +
markdown_cell(shift.capsule_id) +
" | " +
shift.kind.label() +
" | " +
shift.impact.label() +
" | " +
markdown_cell(shift.before_action + "/" + shift.before_severity) +
" | " +
markdown_cell(shift.after_action + "/" + shift.after_severity) +
" | " +
markdown_cell(shift.message) +
" |",
)
}
}
lines.join("\n")
}
///|
pub fn PlanDelta::to_json_string(self : PlanDelta) -> String {
Json::object(
Map([
("before_day", json_int(self.before_day)),
("after_day", json_int(self.after_day)),
("risk_increased", Json::boolean(self.has_risk_increase())),
("increased_count", json_int(self.increased_count)),
("reduced_count", json_int(self.reduced_count)),
("review_count", json_int(self.review_count)),
("stable_count", json_int(self.stable_count)),
("shifts", plan_shifts_to_json(self.shifts)),
]),
).stringify(indent=2)
}
///|
pub fn sample_plan_delta() -> PlanDelta {
let before = plan_retention(sample_capsules(), sample_rules(), 220)
let after_capsules = sample_capsules()
after_capsules.push(
data_capsule(
"cap.new-public-export",
"visitor",
"analytics",
"behavior",
219,
219,
ConsentGranted,
RestrictedData,
"cn",
true,
PublicShared,
"new export that should be quarantined",
),
)
let after = plan_retention(after_capsules, sample_rules(), 230)
compare_retention_plans(before, after)
}
///|
fn compare_decision_pair(
before : CapsuleDecision,
after : CapsuleDecision,
) -> PlanShift {
if before.action != after.action {
{
capsule_id: before.capsule.id,
kind: ActionChanged,
impact: impact_for_actions(before, after),
before_action: before.action.label(),
after_action: after.action.label(),
before_severity: before.severity.label(),
after_severity: after.severity.label(),
message: "action changed from " +
before.action.label() +
" to " +
after.action.label(),
}
} else if before.severity != after.severity {
{
capsule_id: before.capsule.id,
kind: SeverityChanged,
impact: impact_for_severity(before.severity, after.severity),
before_action: before.action.label(),
after_action: after.action.label(),
before_severity: before.severity.label(),
after_severity: after.severity.label(),
message: "severity changed from " +
before.severity.label() +
" to " +
after.severity.label(),
}
} else {
{
capsule_id: before.capsule.id,
kind: DecisionStable,
impact: NoImpact,
before_action: before.action.label(),
after_action: after.action.label(),
before_severity: before.severity.label(),
after_severity: after.severity.label(),
message: "decision stayed stable",
}
}
}
///|
fn impact_for_actions(
before : CapsuleDecision,
after : CapsuleDecision,
) -> PlanShiftImpact {
if after.action.priority() > before.action.priority() ||
after.severity.weight() > before.severity.weight() {
RiskIncreased
} else if after.action.priority() < before.action.priority() ||
after.severity.weight() < before.severity.weight() {
RiskReduced
} else {
ReviewNeeded
}
}
///|
fn impact_for_severity(
before : RetentionSeverity,
after : RetentionSeverity,
) -> PlanShiftImpact {
if after.weight() > before.weight() {
RiskIncreased
} else if after.weight() < before.weight() {
RiskReduced
} else {
NoImpact
}
}
///|
fn sort_plan_shifts(values : Array[PlanShift]) -> Array[PlanShift] {
let mut sorted : Array[PlanShift] = []
for value in values {
let next : Array[PlanShift] = []
let mut inserted = false
for existing in sorted {
if !inserted && compare_plan_shift(value, existing) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn compare_plan_shift(a : PlanShift, b : PlanShift) -> Int {
let impact_delta = shift_impact_weight(b.impact) -
shift_impact_weight(a.impact)
if impact_delta != 0 {
impact_delta
} else {
a.capsule_id.lexical_compare(b.capsule_id)
}
}
///|
fn shift_impact_weight(impact : PlanShiftImpact) -> Int {
match impact {
RiskIncreased => 4
ReviewNeeded => 3
RiskReduced => 2
NoImpact => 1
}
}
///|
fn plan_shifts_to_json(items : Array[PlanShift]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("capsule_id", Json::string(item.capsule_id)),
("kind", Json::string(item.kind.label())),
("impact", Json::string(item.impact.label())),
("before_action", Json::string(item.before_action)),
("after_action", Json::string(item.after_action)),
("before_severity", Json::string(item.before_severity)),
("after_severity", Json::string(item.after_severity)),
("message", Json::string(item.message)),
]),
),
)
}
Json::array(values)
}