///|
/// Structured evidence for explaining why a detector emitted an alert.
pub struct EvidenceContribution {
name : String
value : Double
weight : Double
contribution : Double
}
///|
pub fn EvidenceContribution::new(
name : String,
value : Double,
weight? : Double = 1.0,
) -> EvidenceContribution {
{ name, value, weight, contribution: value * weight }
}
///|
pub struct DetectionExplanation {
result : DetectionResult
baseline : Double
observed : Double
deviation : Double
relative_change : Double
contributions : Array[EvidenceContribution]
recommendation : String
}
///|
pub fn DetectionExplanation::empty(index? : Int = 0) -> DetectionExplanation {
{
result: DetectionResult::quiet(index~),
baseline: 0.0,
observed: 0.0,
deviation: 0.0,
relative_change: 0.0,
contributions: [],
recommendation: "collect more observations",
}
}
///|
pub fn absolute_change(baseline : Double, observed : Double) -> Double {
absolute(observed - baseline)
}
///|
pub fn relative_change(baseline : Double, observed : Double) -> Double {
if baseline == 0.0 {
absolute_change(baseline, observed)
} else {
absolute_change(baseline, observed) / absolute(baseline)
}
}
///|
pub fn change_direction_from_values(
baseline : Double,
observed : Double,
) -> ChangeDirection {
if observed > baseline {
Increase
} else if observed < baseline {
Decrease
} else {
Unknown
}
}
///|
pub fn evidence_strength(
score : Double,
confidence : Double,
deviation : Double,
) -> Double {
clamp_probability(
(
clamp_probability(score) +
clamp_probability(confidence) +
clamp_probability(deviation)
) /
3.0,
)
}
///|
pub fn make_explanation(
result : DetectionResult,
baseline : Double,
observed : Double,
detector : String,
) -> DetectionExplanation {
let deviation = absolute_change(baseline, observed)
let relative = relative_change(baseline, observed)
let direction = change_direction_from_values(baseline, observed)
let direction_score = if direction_name(direction) == "increase" ||
direction_name(direction) == "decrease" {
relative
} else {
0.0
}
let contributions = [
EvidenceContribution::new("detector-score", result.score, weight=0.6),
EvidenceContribution::new("confidence", result.confidence, weight=0.25),
EvidenceContribution::new("relative-change", direction_score, weight=0.15),
]
let recommendation = if result.changed {
"inspect recent deploys and upstream inputs"
} else {
"continue monitoring"
}
{
result,
baseline,
observed,
deviation,
relative_change: relative,
contributions,
recommendation: detector + ": " + recommendation,
}
}
///|
pub fn explanation_strength(explanation : DetectionExplanation) -> Double {
let mut total = 0.0
for item in explanation.contributions {
total += item.contribution
}
clamp_probability(total)
}
///|
pub fn strongest_contribution(
explanation : DetectionExplanation,
) -> EvidenceContribution? {
if explanation.contributions.length() == 0 {
return None
}
let mut best = explanation.contributions[0]
for item in explanation.contributions {
if item.contribution > best.contribution {
best = item
}
}
Some(best)
}
///|
pub fn explanation_csv_header() -> String {
"index,changed,score,confidence,baseline,observed,deviation,relative_change,recommendation"
}
///|
pub fn explanation_csv(explanation : DetectionExplanation) -> String {
"\{explanation.result.index},\{explanation.result.changed},\{explanation.result.score},\{explanation.result.confidence},\{explanation.baseline},\{explanation.observed},\{explanation.deviation},\{explanation.relative_change},\{explanation.recommendation}"
}
///|
pub fn explanation_markdown(explanation : DetectionExplanation) -> String {
let strongest = match strongest_contribution(explanation) {
None => "none"
Some(item) => item.name
}
"### Detection explanation\n\n- changed: \{explanation.result.changed}\n- score: \{explanation.result.score}\n- baseline: \{explanation.baseline}\n- observed: \{explanation.observed}\n- strongest evidence: \{strongest}\n- recommendation: \{explanation.recommendation}"
}
///|
pub fn explain_batch(
results : Array[DetectionResult],
baseline : Double,
values : Array[Double],
detector : String,
) -> Array[DetectionExplanation] {
let output : Array[DetectionExplanation] = []
let length = if results.length() < values.length() {
results.length()
} else {
values.length()
}
for i in 0.. Int {
let mut count = 0
for explanation in explanations {
if explanation.result.changed {
count += 1
}
}
count
}