///|
/// A contiguous run of one waveform token.
pub struct SignalRun {
signal_id : String
start : Int64
end : Int64
token : String
duration : Int64
change_count : Int
} derive(Debug)
///|
/// A burst or idle segment derived from aggregate activity.
pub struct ActivitySegment {
start : Int64
end : Int64
changes : Int
active : Bool
density : Double
} derive(Debug)
///|
/// Distribution statistics for one signal.
pub struct TraceDistribution {
signal_id : String
sample_count : Int
distinct_tokens : Int
minimum_run : Int64?
maximum_run : Int64?
mean_run : Double?
median_run : Int64?
p95_run : Int64?
entropy : Double
transition_rate : Double
} derive(Debug)
///|
/// Options controlling activity segmentation.
pub struct ActivitySegmentOptions {
bucket_width : Int64
active_threshold : Int
include_empty : Bool
} derive(Debug)
///|
/// Create an activity segmentation policy.
pub fn activity_segment_options(bucket_width : Int64) -> ActivitySegmentOptions {
{
bucket_width: Int64::max(1L, bucket_width),
active_threshold: 1,
include_empty: true,
}
}
///|
/// Set the minimum changes required for an active bucket.
pub fn ActivitySegmentOptions::with_threshold(
self : ActivitySegmentOptions,
threshold : Int,
) -> ActivitySegmentOptions {
{ ..self, active_threshold: Int::max(1, threshold) }
}
///|
/// Include or omit zero-change buckets.
pub fn ActivitySegmentOptions::with_empty(
self : ActivitySegmentOptions,
include_empty : Bool,
) -> ActivitySegmentOptions {
{ ..self, include_empty, }
}
///|
/// Extract stable runs for one signal, including the final run.
pub fn signal_runs(trace : Trace, signal_id : String) -> Array[SignalRun] {
let result : Array[SignalRun] = []
let mut active : SignalRun? = None
for change in trace.changes {
if change.id != signal_id {
continue
}
let token = change.value.display()
match active {
Some(run) if run.token == token =>
active = Some({
..run,
end: change.time,
duration: Int64::max(0L, change.time - run.start),
change_count: run.change_count + 1,
})
Some(run) => {
result.push(run)
active = Some({
signal_id,
start: change.time,
end: trace.end_time,
token,
duration: Int64::max(0L, trace.end_time - change.time),
change_count: 1,
})
}
None =>
active = Some({
signal_id,
start: change.time,
end: trace.end_time,
token,
duration: Int64::max(0L, trace.end_time - change.time),
change_count: 1,
})
}
}
match active {
Some(run) => result.push(run)
None => ()
}
result
}
///|
/// Calculate a distribution report for one signal.
pub fn signal_distribution(
trace : Trace,
signal_id : String,
) -> TraceDistribution {
let runs = signal_runs(trace, signal_id)
let durations = runs.map(fn(item) { item.duration })
durations.sort()
let distinct = runs
.map(fn(item) { item.token })
.fold(init=[], fn(values, token) {
if !values.contains(token) {
values.push(token)
}
values
})
let total = durations.fold(init=0L, fn(sum, item) { sum + item })
let mean = if durations.length() == 0 {
None
} else {
Some(total.to_double() / durations.length().to_double())
}
let median = trace_percentile(durations, 0.50)
let p95 = trace_percentile(durations, 0.95)
let min = trace_minimum(durations)
let max = trace_maximum(durations)
let transitions = Int64::max(0L, runs.length().to_int64() - 1L)
let rate = if trace.end_time > 0L {
transitions.to_double() / trace.end_time.to_double()
} else {
0.0
}
{
signal_id,
sample_count: trace.changes
.filter(fn(item) { item.id == signal_id })
.length(),
distinct_tokens: distinct.length(),
minimum_run: min,
maximum_run: max,
mean_run: mean,
median_run: median,
p95_run: p95,
entropy: value_entropy(trace, signal_id),
transition_rate: rate,
}
}
///|
/// Calculate distributions for every declared signal.
pub fn all_signal_distributions(trace : Trace) -> Array[TraceDistribution] {
trace.signals.map(fn(signal) { signal_distribution(trace, signal.id) })
}
///|
/// Divide a trace into fixed activity segments.
pub fn activity_segments(
trace : Trace,
options : ActivitySegmentOptions,
) -> Array[ActivitySegment] {
let result : Array[ActivitySegment] = []
let width = Int64::max(1L, options.bucket_width)
let bucket_count = if trace.end_time <= 0L {
0
} else {
((trace.end_time + width - 1L) / width).to_int()
}
for index in 0..= start && change.time < end })
.length()
if options.include_empty || count > 0 {
let density = if end > start {
count.to_double() / (end - start).to_double()
} else {
0.0
}
result.push({
start,
end,
changes: count,
active: count >= options.active_threshold,
density,
})
}
}
result
}
///|
/// Merge adjacent segments with the same activity state.
pub fn merge_activity_segments(
segments : Array[ActivitySegment],
) -> Array[ActivitySegment] {
let result : Array[ActivitySegment] = []
for segment in segments {
match result.last() {
Some(previous) if previous.active == segment.active &&
previous.end == segment.start =>
result[result.length() - 1] = {
start: previous.start,
end: segment.end,
changes: previous.changes + segment.changes,
active: previous.active,
density: if segment.end > previous.start {
(previous.changes + segment.changes).to_double() /
(segment.end - previous.start).to_double()
} else {
0.0
},
}
_ => result.push(segment)
}
}
result
}
///|
/// Return the busiest segment, if the trace contains one.
pub fn busiest_activity_segment(
segments : Array[ActivitySegment],
) -> ActivitySegment? {
segments.fold(init=None, fn(current, item) {
match current {
Some(previous) if item.density > previous.density => Some(item)
Some(previous) => Some(previous)
None => Some(item)
}
})
}
///|
/// Compute a histogram of run durations using a fixed bucket size.
pub fn run_duration_histogram(
trace : Trace,
signal_id : String,
bucket : Int64,
) -> Array[(Int64, Int)] {
let width = Int64::max(1L, bucket)
let result : Array[(Int64, Int)] = []
for run in signal_runs(trace, signal_id) {
let key = run.duration / width * width
let mut found = false
for index, item in result {
if item.0 == key {
result[index] = (key, item.1 + 1)
found = true
break
}
}
if !found {
result.push((key, 1))
}
}
result.sort_by(fn(left, right) {
if left.0 < right.0 {
-1
} else if left.0 > right.0 {
1
} else {
0
}
})
result
}
///|
/// Export distribution data as CSV.
pub fn trace_distributions_csv(items : Array[TraceDistribution]) -> String {
let out = StringBuilder()
out.write_string(
"signal,samples,distinct,min,max,mean,median,p95,entropy,transition_rate\n",
)
for item in items {
out.write_string(
escape_text(item.signal_id) +
"," +
item.sample_count.to_string() +
"," +
item.distinct_tokens.to_string() +
",",
)
out.write_string(
trace_optional_i64(item.minimum_run) +
"," +
trace_optional_i64(item.maximum_run) +
",",
)
out.write_string(
trace_optional_double(item.mean_run) +
"," +
trace_optional_i64(item.median_run) +
"," +
trace_optional_i64(item.p95_run) +
",",
)
out.write_string(
item.entropy.to_string() + "," + item.transition_rate.to_string() + "\n",
)
}
out.to_string()
}
///|
/// Export activity segments as CSV.
pub fn activity_segments_csv(segments : Array[ActivitySegment]) -> String {
let out = StringBuilder()
out.write_string("start,end,changes,active,density\n")
for item in segments {
out.write_string(
item.start.to_string() +
"," +
item.end.to_string() +
"," +
item.changes.to_string() +
"," +
item.active.to_string() +
"," +
item.density.to_string() +
"\n",
)
}
out.to_string()
}
///|
/// Render one distribution in a compact text line.
pub fn TraceDistribution::to_text(self : TraceDistribution) -> String {
self.signal_id +
" samples=" +
self.sample_count.to_string() +
" distinct=" +
self.distinct_tokens.to_string() +
" median=" +
trace_optional_i64(self.median_run) +
" p95=" +
trace_optional_i64(self.p95_run) +
" entropy=" +
self.entropy.to_string()
}
///|
/// Return an inclusive percentile from sorted values.
fn trace_percentile(values : Array[Int64], fraction : Double) -> Int64? {
if values.length() == 0 {
return None
}
let normalized = if fraction < 0.0 {
0.0
} else if fraction > 1.0 {
1.0
} else {
fraction
}
let position = (values.length() - 1).to_double() * normalized
Some(values[position.to_int()])
}
///|
/// Minimum of a sorted non-empty array.
fn trace_minimum(values : Array[Int64]) -> Int64? {
if values.length() == 0 {
None
} else {
Some(values[0])
}
}
///|
/// Maximum of a sorted non-empty array.
fn trace_maximum(values : Array[Int64]) -> Int64? {
if values.length() == 0 {
None
} else {
Some(values[values.length() - 1])
}
}
///|
/// Encode an optional integer.
fn trace_optional_i64(value : Int64?) -> String {
match value {
Some(item) => item.to_string()
None => ""
}
}
///|
/// Encode an optional floating point value.
fn trace_optional_double(value : Double?) -> String {
match value {
Some(item) => item.to_string()
None => ""
}
}