///|
/// Coverage dimensions supported by waveform regression analysis.
pub enum CoverageKind {
Value
Transition
Toggle
Width
Activity
Protocol
} derive(Debug, Eq)
///|
/// One measurable coverage bin.
pub struct CoverageBin {
group : String
key : String
label : String
kind : CoverageKind
hits : Int
target : Int
first_time : Int64?
last_time : Int64?
} derive(Debug)
///|
/// An observed point that can be accumulated into coverage.
pub struct CoveragePoint {
group : String
key : String
time : Int64
weight : Int
} derive(Debug, Eq)
///|
/// Construct an observation point for custom coverage adapters.
pub fn coverage_point(
group : String,
key : String,
time : Int64,
weight : Int,
) -> CoveragePoint {
{ group, key, time, weight: Int::max(1, weight) }
}
///|
/// A complete coverage report for one trace or regression batch.
pub struct CoverageReport {
name : String
bins : Array[CoverageBin]
observations : Int
covered : Int
target : Int
score : Double
} derive(Debug)
///|
/// Return every coverage dimension for report configuration UIs.
pub fn coverage_kind_catalog() -> Array[CoverageKind] {
[Value, Transition, Toggle, Width, Activity, Protocol]
}
///|
/// Return a stable name for a coverage dimension.
pub fn coverage_kind_name(kind : CoverageKind) -> String {
match kind {
Value => "value"
Transition => "transition"
Toggle => "toggle"
Width => "width"
Activity => "activity"
Protocol => "protocol"
}
}
///|
/// Create a coverage bin with a minimum target of one hit.
pub fn coverage_bin(
group : String,
key : String,
label : String,
kind : CoverageKind,
target : Int,
) -> CoverageBin {
{
group,
key,
label,
kind,
hits: 0,
target: Int::max(1, target),
first_time: None,
last_time: None,
}
}
///|
/// Return whether a bin has met its target.
pub fn CoverageBin::is_covered(self : CoverageBin) -> Bool {
self.hits >= self.target
}
///|
/// Return a bounded progress ratio for a bin.
pub fn CoverageBin::progress(self : CoverageBin) -> Double {
Double::min(1.0, self.hits.to_double() / self.target.to_double())
}
///|
/// Return a copy with one observation applied.
pub fn CoverageBin::hit(
self : CoverageBin,
time : Int64,
weight : Int,
) -> CoverageBin {
let increment = Int::max(1, weight)
{
..self,
hits: self.hits + increment,
first_time: match self.first_time {
Some(value) => Some(Int64::min(value, time))
None => Some(time)
},
last_time: match self.last_time {
Some(value) => Some(Int64::max(value, time))
None => Some(time)
},
}
}
///|
/// Build value bins for all declarations in a trace.
pub fn value_coverage_bins(trace : Trace) -> Array[CoverageBin] {
let bins : Array[CoverageBin] = []
for signal in trace.signals {
let values = signal_value_categories(trace, signal.id)
for value in values {
bins.push(
coverage_bin(
signal_path(signal),
value,
signal.name + "=" + value,
Value,
1,
),
)
}
if bins_for_group(bins, signal_path(signal)).length() == 0 {
bins.push(
coverage_bin(
signal_path(signal),
"no-observation",
signal.name + " no observation",
Value,
1,
),
)
}
}
accumulate_value_bins(trace, bins)
}
///|
/// Build transition bins for each scalar signal.
pub fn transition_coverage_bins(trace : Trace) -> Array[CoverageBin] {
let bins : Array[CoverageBin] = []
for signal in trace.signals {
let group = signal_path(signal)
for key in ["0->1", "1->0", "0->x", "1->x", "x->0", "x->1", "to-z"] {
bins.push(
coverage_bin(group, key, signal.name + " " + key, Transition, 1),
)
}
}
accumulate_transition_bins(trace, bins)
}
///|
/// Build toggle bins for declarations with meaningful transitions.
pub fn toggle_coverage_bins(trace : Trace) -> Array[CoverageBin] {
let bins : Array[CoverageBin] = []
for signal in trace.signals {
bins.push(
coverage_bin(
signal_path(signal),
"toggle",
signal.name + " toggled",
Toggle,
1,
),
)
bins.push(
coverage_bin(
signal_path(signal),
"stable",
signal.name + " stable",
Toggle,
1,
),
)
}
for signal in trace.signals {
let group = signal_path(signal)
let changes = trace.changes.filter(fn(change) { change.id == signal.id })
let target_toggle = find_bin_index(bins, group, "toggle")
let target_stable = find_bin_index(bins, group, "stable")
if changes.length() > 1 {
if target_toggle is Some(index) {
bins[index] = bins[index].hit(changes[1].time, changes.length() - 1)
}
} else if target_stable is Some(index) {
bins[index] = bins[index].hit(0L, 1)
}
}
bins
}
///|
/// Build bins that check declaration widths against an expected range.
pub fn width_coverage_bins(
trace : Trace,
minimum : Int,
maximum : Int,
) -> Array[CoverageBin] {
let low = Int::max(0, minimum)
let high = Int::max(low, maximum)
let bins : Array[CoverageBin] = [
coverage_bin("width", "below", "width below range", Width, 1),
coverage_bin("width", "inside", "width inside range", Width, 1),
coverage_bin("width", "above", "width above range", Width, 1),
]
for signal in trace.signals {
let key = if signal.width < low {
"below"
} else if signal.width > high {
"above"
} else {
"inside"
}
match find_bin_index(bins, "width", key) {
Some(index) => bins[index] = bins[index].hit(0L, 1)
None => ()
}
}
bins
}
///|
/// Build activity bins for each signal based on change density.
pub fn activity_coverage_bins(
trace : Trace,
bucket_width : Int64,
threshold : Int,
) -> Array[CoverageBin] {
let width = Int64::max(1L, bucket_width)
let bins : Array[CoverageBin] = []
for signal in trace.signals {
bins.push(
coverage_bin(
signal_path(signal),
"idle",
signal.name + " idle",
Activity,
1,
),
)
bins.push(
coverage_bin(
signal_path(signal),
"active",
signal.name + " active",
Activity,
1,
),
)
bins.push(
coverage_bin(
signal_path(signal),
"burst",
signal.name + " burst",
Activity,
1,
),
)
let changes = trace.changes.filter(fn(change) { change.id == signal.id })
let buckets : Array[Int] = []
for change in changes {
let bucket = (change.time / width).to_int()
if buckets.contains(bucket) {
continue
}
buckets.push(bucket)
}
let key = if changes.length() == 0 {
"idle"
} else if changes.length() >= Int::max(1, threshold) {
"burst"
} else {
"active"
}
match find_bin_index(bins, signal_path(signal), key) {
Some(index) =>
bins[index] = bins[index].hit(0L, Int::max(1, buckets.length()))
None => ()
}
}
bins
}
///|
/// Assemble a report from configured coverage dimensions.
pub fn coverage_report(
name : String,
trace : Trace,
include_values : Bool,
include_transitions : Bool,
include_toggles : Bool,
include_widths : Bool,
include_activity : Bool,
) -> CoverageReport {
let bins : Array[CoverageBin] = []
if include_values {
append_coverage_bins(bins, value_coverage_bins(trace))
}
if include_transitions {
append_coverage_bins(bins, transition_coverage_bins(trace))
}
if include_toggles {
append_coverage_bins(bins, toggle_coverage_bins(trace))
}
if include_widths {
append_coverage_bins(bins, width_coverage_bins(trace, 1, 256))
}
if include_activity {
append_coverage_bins(
bins,
activity_coverage_bins(trace, Int64::max(1L, trace.duration() / 8L), 4),
)
}
coverage_report_from_bins(name, bins)
}
///|
/// Build a report from externally generated or merged bins.
pub fn coverage_report_from_bins(
name : String,
bins : Array[CoverageBin],
) -> CoverageReport {
let covered = bins.count_if(fn(bin) { bin.is_covered() })
let target = bins.fold(init=0, fn(total, bin) { total + bin.target })
let observations = bins.fold(init=0, fn(total, bin) { total + bin.hits })
let score = if bins.length() == 0 {
1.0
} else {
covered.to_double() / bins.length().to_double()
}
{ name, bins, observations, covered, target, score }
}
///|
/// Merge two reports by group and key while preserving first-seen order.
pub fn merge_coverage_reports(
left : CoverageReport,
right : CoverageReport,
) -> CoverageReport {
let bins = left.bins.copy()
for incoming in right.bins {
match find_bin_index(bins, incoming.group, incoming.key) {
Some(index) => bins[index] = merge_coverage_bin(bins[index], incoming)
None => bins.push(incoming)
}
}
coverage_report_from_bins(left.name + "+" + right.name, bins)
}
///|
/// Return all bins that remain uncovered.
pub fn CoverageReport::uncovered(self : CoverageReport) -> Array[CoverageBin] {
self.bins.filter(fn(bin) { !bin.is_covered() })
}
///|
/// Return coverage grouped by dimension.
pub fn CoverageReport::score_by_kind(
self : CoverageReport,
) -> Array[(String, Double)] {
let names : Array[String] = []
let totals : Array[Int] = []
let covered : Array[Int] = []
for bin in self.bins {
let name = coverage_kind_name(bin.kind)
match find_string_index(names, name) {
Some(index) => {
totals[index] += 1
if bin.is_covered() {
covered[index] += 1
}
}
None => {
names.push(name)
totals.push(1)
covered.push(if bin.is_covered() { 1 } else { 0 })
}
}
}
let result : Array[(String, Double)] = []
for index, name in names {
result.push(
(
name,
if totals[index] == 0 {
1.0
} else {
covered[index].to_double() / totals[index].to_double()
},
),
)
}
result
}
///|
/// Render report rows for CI summaries.
pub fn CoverageReport::to_text(self : CoverageReport) -> String {
let rows : Array[String] = [
"coverage=" + self.name,
"score=" + self.score.to_string(),
"covered=" + self.covered.to_string() + "/" + self.bins.length().to_string(),
"observations=" + self.observations.to_string(),
]
for item in self.score_by_kind() {
rows.push("kind=" + item.0 + " score=" + item.1.to_string())
}
for bin in self.uncovered() {
rows.push("uncovered=" + bin.group + ":" + bin.key + " label=" + bin.label)
}
rows.join("\n")
}
///|
/// Render a report in a stable CSV form.
pub fn CoverageReport::to_csv(self : CoverageReport) -> String {
let rows : Array[String] = [
"group,key,label,kind,hits,target,covered,first_time,last_time",
]
for bin in self.bins {
rows.push(
csv_cell(bin.group) +
"," +
csv_cell(bin.key) +
"," +
csv_cell(bin.label) +
"," +
coverage_kind_name(bin.kind) +
"," +
bin.hits.to_string() +
"," +
bin.target.to_string() +
"," +
bin.is_covered().to_string() +
"," +
optional_int64_text(bin.first_time) +
"," +
optional_int64_text(bin.last_time),
)
}
rows.join("\n")
}
///|
/// Render the complete report as JSON.
pub fn CoverageReport::to_json(self : CoverageReport) -> String {
let body = self.bins
.map(fn(bin) {
"{\"group\":\"" +
csv_cell(bin.group) +
"\",\"key\":\"" +
csv_cell(bin.key) +
"\",\"label\":\"" +
csv_cell(bin.label) +
"\",\"kind\":\"" +
coverage_kind_name(bin.kind) +
"\",\"hits\":" +
bin.hits.to_string() +
",\"target\":" +
bin.target.to_string() +
",\"covered\":" +
bin.is_covered().to_string() +
",\"first_time\":" +
optional_int64_json(bin.first_time) +
",\"last_time\":" +
optional_int64_json(bin.last_time) +
"}"
})
.join(",")
"{\"name\":\"" +
csv_cell(self.name) +
"\",\"score\":" +
self.score.to_string() +
",\"covered\":" +
self.covered.to_string() +
",\"target\":" +
self.target.to_string() +
",\"observations\":" +
self.observations.to_string() +
",\"bins\":[" +
body +
"]}"
}
///|
/// Return the canonical categories observed for one signal.
fn signal_value_categories(trace : Trace, signal_id : String) -> Array[String] {
let categories : Array[String] = []
for change in trace.changes {
if change.id != signal_id {
continue
}
let key = match change.value {
Scalar(value) =>
match logic_bit(value) {
Zero => "0"
One => "1"
Unknown => "x"
HighImpedance => "z"
}
Vector(bits) =>
if bits.contains("x") || bits.contains("X") {
"vector-x"
} else if bits.contains("z") || bits.contains("Z") {
"vector-z"
} else {
"vector-known"
}
Real(_) => "real"
}
if !categories.contains(key) {
categories.push(key)
}
}
categories
}
///|
/// Apply value observations to pre-created bins.
fn accumulate_value_bins(
trace : Trace,
bins : Array[CoverageBin],
) -> Array[CoverageBin] {
for change in trace.changes {
let key = match change.value {
Scalar(value) =>
match logic_bit(value) {
Zero => "0"
One => "1"
Unknown => "x"
HighImpedance => "z"
}
Vector(bits) =>
if bits.contains("x") || bits.contains("X") {
"vector-x"
} else if bits.contains("z") || bits.contains("Z") {
"vector-z"
} else {
"vector-known"
}
Real(_) => "real"
}
match find_bin_by_group_and_key_prefix(bins, change.id, key, trace) {
Some(index) => bins[index] = bins[index].hit(change.time, 1)
None => ()
}
}
bins
}
///|
/// Apply scalar transition observations to bins.
fn accumulate_transition_bins(
trace : Trace,
bins : Array[CoverageBin],
) -> Array[CoverageBin] {
for signal in trace.signals {
let changes = trace.changes.filter(fn(change) { change.id == signal.id })
for index in 1.. {
let key = transition_key(before, after)
match find_bin_index(bins, signal_path(signal), key) {
Some(bin_index) =>
bins[bin_index] = bins[bin_index].hit(changes[index].time, 1)
None => ()
}
}
_ => ()
}
}
}
bins
}
///|
/// Classify a scalar transition for coverage.
fn transition_key(before : Char, after : Char) -> String {
let left = match logic_bit(before) {
Zero => "0"
One => "1"
Unknown => "x"
HighImpedance => "z"
}
let right = match logic_bit(after) {
Zero => "0"
One => "1"
Unknown => "x"
HighImpedance => "z"
}
if right == "z" {
"to-z"
} else {
left + "->" + right
}
}
///|
/// Return bins belonging to a group.
fn bins_for_group(
bins : Array[CoverageBin],
group : String,
) -> Array[CoverageBin] {
bins.filter(fn(bin) { bin.group == group })
}
///|
/// Find a bin by its group and key.
fn find_bin_index(
bins : Array[CoverageBin],
group : String,
key : String,
) -> Int? {
for index, bin in bins {
if bin.group == group && bin.key == key {
return Some(index)
}
}
None
}
///|
/// Find a value bin when the group is a full signal path but changes hold IDs.
fn find_bin_by_group_and_key_prefix(
bins : Array[CoverageBin],
signal_id : String,
key : String,
trace : Trace,
) -> Int? {
match find_signal(trace, signal_id) {
Some(signal) => find_bin_index(bins, signal_path(signal), key)
None => None
}
}
///|
/// Merge two bins from separate regression runs.
fn merge_coverage_bin(left : CoverageBin, right : CoverageBin) -> CoverageBin {
{
..left,
hits: left.hits + right.hits,
target: Int::max(left.target, right.target),
first_time: min_optional_int64(left.first_time, right.first_time),
last_time: max_optional_int64(left.last_time, right.last_time),
}
}
///|
/// Return the earlier optional timestamp.
fn min_optional_int64(left : Int64?, right : Int64?) -> Int64? {
match (left, right) {
(Some(a), Some(b)) => Some(Int64::min(a, b))
(Some(a), None) => Some(a)
(None, Some(b)) => Some(b)
(None, None) => None
}
}
///|
/// Return the later optional timestamp.
fn max_optional_int64(left : Int64?, right : Int64?) -> Int64? {
match (left, right) {
(Some(a), Some(b)) => Some(Int64::max(a, b))
(Some(a), None) => Some(a)
(None, Some(b)) => Some(b)
(None, None) => None
}
}
///|
/// Append a collection of bins while preserving insertion order.
fn append_coverage_bins(
target : Array[CoverageBin],
source : Array[CoverageBin],
) -> Unit {
for bin in source {
target.push(bin)
}
}
///|
/// Find a string position without depending on a collection extension.
fn find_string_index(values : Array[String], needle : String) -> Int? {
for index, value in values {
if value == needle {
return Some(index)
}
}
None
}