///|
/// Comparison policy for regression waveform checks.
pub enum RegressionComparison {
ExactValues
TolerantTimes
ShapeOnly
ActivityOnly
} derive(Debug, Eq)
///|
/// One regression difference with a stable classification.
pub struct RegressionFinding {
signal_id : String
time : Int64
left : Value?
right : Value?
kind : String
distance : Int64
} derive(Debug)
///|
/// Regression comparison result.
pub struct RegressionReportV2 {
comparison : RegressionComparison
findings : Array[RegressionFinding]
compared_signals : Int
compared_changes : Int
passed : Bool
score : Int
} derive(Debug)
///|
/// List comparison policies.
pub fn regression_comparison_catalog() -> Array[RegressionComparison] {
[ExactValues, TolerantTimes, ShapeOnly, ActivityOnly]
}
///|
/// Compare two traces by aligned signal identifiers.
pub fn compare_regression_v2(
left : Trace,
right : Trace,
comparison : RegressionComparison,
tolerance : Int64,
) -> RegressionReportV2 {
let findings : Array[RegressionFinding] = []
let ids = union_signal_ids(left, right)
let limit = Int64::max(0L, tolerance)
let mut compared = 0
for id in ids {
let left_changes = left.changes.filter(fn(item) { item.id == id })
let right_changes = right.changes.filter(fn(item) { item.id == id })
compared += Int::max(left_changes.length(), right_changes.length())
match comparison {
ShapeOnly =>
if left_changes.length() != right_changes.length() {
findings.push({
signal_id: id,
time: 0L,
left: None,
right: None,
kind: "change-count",
distance: (left_changes.length() - right_changes.length()).to_int64(),
})
}
ActivityOnly =>
if (left_changes.length() == 0) != (right_changes.length() == 0) {
findings.push({
signal_id: id,
time: 0L,
left: None,
right: None,
kind: "activity-presence",
distance: 0L,
})
}
ExactValues | TolerantTimes =>
regression_compare_signal(
left_changes, right_changes, id, comparison, limit, findings,
)
}
}
let passed = findings.length() == 0
{
comparison,
findings,
compared_signals: ids.length(),
compared_changes: compared,
passed,
score: if compared == 0 {
100
} else {
Int::max(0, 100 - findings.length() * 100 / compared)
},
}
}
///|
/// Return the first finding for a signal.
pub fn RegressionReportV2::for_signal(
self : RegressionReportV2,
signal_id : String,
) -> Array[RegressionFinding] {
self.findings.filter(fn(item) { item.signal_id == signal_id })
}
///|
/// Return whether a comparison passed.
pub fn RegressionReportV2::is_success(self : RegressionReportV2) -> Bool {
self.passed
}
///|
/// Export regression findings as CSV.
pub fn regression_findings_csv(findings : Array[RegressionFinding]) -> String {
let out = StringBuilder()
out.write_string("signal,time,left,right,kind,distance\n")
for item in findings {
out.write_string(
escape_text(item.signal_id) +
"," +
item.time.to_string() +
"," +
regression_optional_value(item.left) +
"," +
regression_optional_value(item.right) +
"," +
item.kind +
"," +
item.distance.to_string() +
"\n",
)
}
out.to_string()
}
///|
/// Render regression summary.
pub fn RegressionReportV2::to_text(self : RegressionReportV2) -> String {
"passed=" +
self.passed.to_string() +
" score=" +
self.score.to_string() +
" signals=" +
self.compared_signals.to_string() +
" changes=" +
self.compared_changes.to_string() +
" findings=" +
self.findings.length().to_string()
}
///|
/// Render regression summary as JSON.
pub fn RegressionReportV2::to_json(self : RegressionReportV2) -> String {
"{\"comparison\":\"" +
regression_comparison_name(self.comparison) +
"\",\"passed\":" +
self.passed.to_string() +
",\"score\":" +
self.score.to_string() +
",\"signals\":" +
self.compared_signals.to_string() +
",\"changes\":" +
self.compared_changes.to_string() +
",\"findings\":[" +
self.findings.map(regression_finding_json).join(",") +
"]}"
}
///|
/// Compare changes with optional timestamp tolerance.
fn regression_compare_signal(
left : Array[Change],
right : Array[Change],
signal_id : String,
comparison : RegressionComparison,
tolerance : Int64,
findings : Array[RegressionFinding],
) -> Unit {
let count = Int::max(left.length(), right.length())
for index in 0.. {
let time_distance = (a.time - b.time).abs()
let time_bad = (comparison is ExactValues && time_distance != 0L) ||
(comparison is TolerantTimes && time_distance > tolerance)
if time_bad || a.value != b.value {
findings.push({
signal_id,
time: Int64::min(a.time, b.time),
left: Some(a.value),
right: Some(b.value),
kind: if a.value != b.value {
"value"
} else {
"time"
},
distance: time_distance,
})
}
}
(Some(a), None) =>
findings.push({
signal_id,
time: a.time,
left: Some(a.value),
right: None,
kind: "missing-right",
distance: 0L,
})
(None, Some(b)) =>
findings.push({
signal_id,
time: b.time,
left: None,
right: Some(b.value),
kind: "missing-left",
distance: 0L,
})
_ => ()
}
}
}
///|
/// Name a comparison policy.
pub fn regression_comparison_name(value : RegressionComparison) -> String {
match value {
ExactValues => "exact-values"
TolerantTimes => "tolerant-times"
ShapeOnly => "shape-only"
ActivityOnly => "activity-only"
}
}
///|
/// Encode an optional value for CSV.
fn regression_optional_value(value : Value?) -> String {
match value {
Some(item) => escape_text(item.display())
None => ""
}
}
///|
/// Encode one regression finding as JSON.
fn regression_finding_json(item : RegressionFinding) -> String {
"{\"signal\":\"" +
escape_text(item.signal_id) +
"\",\"time\":" +
item.time.to_string() +
",\"left\":" +
regression_optional_json(item.left) +
",\"right\":" +
regression_optional_json(item.right) +
",\"kind\":\"" +
item.kind +
"\",\"distance\":" +
item.distance.to_string() +
"}"
}
///|
/// Encode an optional value as JSON.
fn regression_optional_json(value : Value?) -> String {
match value {
Some(item) => value_json(item)
None => "null"
}
}