///|
pub(all) struct DurationDistribution {
count : Int32
minimum : Int64?
maximum : Int64?
median : Int64?
p95 : Int64?
mean : Double?
}
///|
fn duration_distribution(samples : Array[Int64]) -> DurationDistribution {
if samples.is_empty() {
return {
count: 0,
minimum: None,
maximum: None,
median: None,
p95: None,
mean: None,
}
}
let ordered = samples.copy()
ordered.sort()
let mut mean = 0.0
for i, value in ordered {
mean = mean + (value.to_double() - mean) / (i + 1).to_double()
}
let size = ordered.length()
{
count: size,
minimum: Some(ordered[0]),
maximum: Some(ordered[size - 1]),
median: Some(ordered[(size * 50 + 99) / 100 - 1]),
p95: Some(ordered[(size * 95 + 99) / 100 - 1]),
mean: Some(mean),
}
}
///|
pub(all) struct CasePerformance {
case_id : String
events : Int32
cycle_time : Int64
busy_union : Int64
idle_time : Int64
service_work : Double
overlapping_starts : Int32
}
///|
pub(all) struct ActivityPerformance {
activity : String
service : DurationDistribution
}
///|
pub(all) struct HandoffPerformance {
source : String
target : String
positive_gap : DurationDistribution
overlapping_pairs : Int32
}
///|
pub(all) struct ResourcePerformance {
resource : String
occurrences : Int32
service : DurationDistribution
maximum_concurrency : Int32
}
///|
pub(all) struct PerformanceReport {
cases : Array[CasePerformance]
activities : Array[ActivityPerformance]
handoffs : Array[HandoffPerformance]
resources : Array[ResourcePerformance]
cycle_times : DurationDistribution
}
///|
/// Busy time is interval union, not the sum of concurrent service durations.
pub fn CaseTrace::performance(self : CaseTrace) -> CasePerformance {
let mut cursor = if self.events.is_empty() {
0L
} else {
self.events[0].started
}
let mut busy = 0L
let mut work = 0.0
let mut overlapping = 0
for event in self.events {
work = work + (event.completed - event.started).to_double()
if event.completed > event.started && event.started < cursor {
overlapping = overlapping + 1
}
if event.completed > cursor {
let start = if event.started > cursor { event.started } else { cursor }
busy = busy + event.completed - start
cursor = event.completed
}
}
let cycle = self.cycle_time()
{
case_id: self.id,
events: self.events.length(),
cycle_time: cycle,
busy_union: busy,
idle_time: cycle - busy,
service_work: work,
overlapping_starts: overlapping,
}
}
///|
fn resource_concurrency(events : Array[LogEvent]) -> Int32 {
let points : Array[(Int64, Int32)] = []
for event in events {
if event.completed > event.started {
points.push((event.started, 1))
points.push((event.completed, -1))
}
}
points.sort_by((a, b) => {
let time = a.0.compare(b.0)
if time != 0 {
time
} else {
a.1.compare(b.1)
}
})
let mut current = 0
let mut maximum = 0
for point in points {
current = current + point.1
if current > maximum {
maximum = current
}
}
maximum
}
///|
/// Handoffs are descriptive adjacent-activity gaps. Overlapping pairs contribute
/// zero to the gap distribution and are counted separately. They are not proof
/// of causality. Resource concurrency is observed workload, not utilization.
pub fn EventLog::performance(self : EventLog) -> PerformanceReport {
let activities : Map[String, Array[Int64]] = Map([])
let resources : Map[String, Array[LogEvent]] = Map([])
let gaps : Map[String, Array[Int64]] = Map([])
let handoff_names : Map[String, (String, String)] = Map([])
let overlaps : Map[String, Int32] = Map([])
let cases = self.traces.map(CaseTrace::performance)
for trace in self.traces {
for i, event in trace.events {
let samples = activities.get(event.activity).unwrap_or([])
samples.push(event.completed - event.started)
activities.set(event.activity, samples)
if event.resource is Some(resource) {
let grouped = resources.get(resource).unwrap_or([])
grouped.push(event)
resources.set(resource, grouped)
}
if i > 0 {
let previous = trace.events[i - 1]
let key = path_identity([previous.activity, event.activity])
handoff_names.set(key, (previous.activity, event.activity))
let delay = event.started - previous.completed
let samples = gaps.get(key).unwrap_or([])
samples.push(if delay > 0L { delay } else { 0L })
gaps.set(key, samples)
if delay < 0L {
overlaps.set(key, overlaps.get(key).unwrap_or(0) + 1)
}
}
}
}
let activity_rows = activities
.iter()
.map(item => { activity: item.0, service: duration_distribution(item.1), })
.collect()
activity_rows.sort_by((a, b) => a.activity.lexical_compare(b.activity))
let resource_rows = resources
.iter()
.map(item => {
resource: item.0,
occurrences: item.1.length(),
service: duration_distribution(
item.1.map(event => event.completed - event.started),
),
maximum_concurrency: resource_concurrency(item.1),
})
.collect()
resource_rows.sort_by((a, b) => a.resource.lexical_compare(b.resource))
let handoff_rows = gaps
.iter()
.map(item => {
let names = handoff_names.get(item.0).unwrap()
HandoffPerformance::{
source: names.0,
target: names.1,
positive_gap: duration_distribution(item.1),
overlapping_pairs: overlaps.get(item.0).unwrap_or(0),
}
})
.collect()
handoff_rows.sort_by((a, b) => {
let source = a.source.lexical_compare(b.source)
if source != 0 {
source
} else {
a.target.lexical_compare(b.target)
}
})
{
cases,
activities: activity_rows,
handoffs: handoff_rows,
resources: resource_rows,
cycle_times: duration_distribution(cases.map(row => row.cycle_time)),
}
}
///|
fn optional_duration(value : Int64?) -> Json {
match value {
Some(value) => Json::string(value.to_string())
None => Json::null()
}
}
///|
fn distribution_to_json(value : DurationDistribution) -> Json {
{
"count": Json::number(value.count.to_double()),
"minimum": optional_duration(value.minimum),
"maximum": optional_duration(value.maximum),
"median": optional_duration(value.median),
"p95": optional_duration(value.p95),
"mean": match value.mean {
Some(mean) => Json::number(mean)
None => Json::null()
},
}
}
///|
pub fn performance_to_json(report : PerformanceReport) -> Json {
let cases : Array[Json] = report.cases.map(row => {
"case_id": Json::string(row.case_id),
"events": Json::number(row.events.to_double()),
"cycle_time": Json::string(row.cycle_time.to_string()),
"busy_union": Json::string(row.busy_union.to_string()),
"idle_time": Json::string(row.idle_time.to_string()),
"service_work": Json::number(row.service_work),
"overlapping_starts": Json::number(row.overlapping_starts.to_double()),
})
let activities : Array[Json] = report.activities.map(row => {
"activity": Json::string(row.activity),
"service": distribution_to_json(row.service),
})
let handoffs : Array[Json] = report.handoffs.map(row => {
"source": Json::string(row.source),
"target": Json::string(row.target),
"positive_gap": distribution_to_json(row.positive_gap),
"overlapping_pairs": Json::number(row.overlapping_pairs.to_double()),
})
let resources : Array[Json] = report.resources.map(row => {
"resource": Json::string(row.resource),
"occurrences": Json::number(row.occurrences.to_double()),
"service": distribution_to_json(row.service),
"maximum_concurrency": Json::number(row.maximum_concurrency.to_double()),
})
{
"cases": Json::array(cases),
"activities": Json::array(activities),
"handoffs": Json::array(handoffs),
"resources": Json::array(resources),
"cycle_times": distribution_to_json(report.cycle_times),
}
}