///|
/// Policies used when a trace is divided into analysis windows.
pub enum WindowPolicy {
FixedWidth
FixedCount
Overlapping
ChangeDriven
SignalActivity
} derive(Debug, Eq)
///|
/// Return every supported policy. Keeping the catalog public also makes it
/// possible for CLI frontends to present the same choices as the library.
pub fn window_policy_catalog() -> Array[WindowPolicy] {
[FixedWidth, FixedCount, Overlapping, ChangeDriven, SignalActivity]
}
///|
/// A request for deterministic trace segmentation.
pub struct WindowSpec {
policy : WindowPolicy
width : Int64
count : Int
overlap : Int64
minimum_changes : Int
include_empty : Bool
} derive(Debug, Eq)
///|
/// A bounded view of a trace. The source trace remains unchanged.
pub struct TraceWindow {
index : Int
start : Int64
end : Int64
changes : Array[Change]
signal_count : Int
active_signals : Int
transition_count : Int
} derive(Debug)
///|
/// Counters used to compare windows without serializing every change.
pub struct WindowMetric {
index : Int
start : Int64
end : Int64
duration : Int64
changes : Int
active_signals : Int
density : Double
first_change : Int64?
last_change : Int64?
} derive(Debug, Eq)
///|
/// A complete segmentation plan with stable source bounds.
pub struct WindowPlan {
source_start : Int64
source_end : Int64
windows : Array[TraceWindow]
policy : WindowPolicy
dropped_changes : Int
} derive(Debug)
///|
/// Return a conservative default for a trace with a useful duration.
pub fn default_window_spec(trace : Trace) -> WindowSpec {
let duration = Int64::max(1L, trace.duration())
{
policy: FixedWidth,
width: Int64::max(1L, duration / 16L),
count: 16,
overlap: 0L,
minimum_changes: 1,
include_empty: false,
}
}
///|
/// Build a window specification with a fixed width.
pub fn fixed_window_spec(width : Int64) -> WindowSpec {
{
policy: FixedWidth,
width: Int64::max(1L, width),
count: 0,
overlap: 0L,
minimum_changes: 0,
include_empty: true,
}
}
///|
/// Build a window specification that creates exactly the requested count.
pub fn counted_window_spec(count : Int) -> WindowSpec {
{
policy: FixedCount,
width: 0L,
count: Int::max(1, count),
overlap: 0L,
minimum_changes: 0,
include_empty: true,
}
}
///|
/// Build an overlapping fixed-width specification.
pub fn overlapping_window_spec(width : Int64, overlap : Int64) -> WindowSpec {
let bounded_width = Int64::max(1L, width)
{
policy: Overlapping,
width: bounded_width,
count: 0,
overlap: Int64::min(Int64::max(0L, overlap), bounded_width - 1L),
minimum_changes: 0,
include_empty: true,
}
}
///|
/// Return the inclusive duration represented by a window.
pub fn TraceWindow::duration(self : TraceWindow) -> Int64 {
Int64::max(0L, self.end - self.start)
}
///|
/// Return whether a timestamp belongs to the inclusive window range.
pub fn TraceWindow::contains(self : TraceWindow, time : Int64) -> Bool {
time >= self.start && time <= self.end
}
///|
/// Return a compact window label suitable for logs and CSV files.
pub fn TraceWindow::label(self : TraceWindow) -> String {
"w" +
self.index.to_string() +
"[" +
self.start.to_string() +
"," +
self.end.to_string() +
"]"
}
///|
/// Return the window's changes as a defensive copy.
pub fn TraceWindow::copy_changes(self : TraceWindow) -> Array[Change] {
self.changes.copy()
}
///|
/// Return the number of transitions in a window.
pub fn TraceWindow::change_count(self : TraceWindow) -> Int {
self.changes.length()
}
///|
/// Return a stable list of identifiers active in a window.
pub fn TraceWindow::signal_ids(self : TraceWindow) -> Array[String] {
let ids : Array[String] = []
for change in self.changes {
if !ids.contains(change.id) {
ids.push(change.id)
}
}
ids
}
///|
/// Return a one-line metric record for a window.
pub fn window_metric(window : TraceWindow) -> WindowMetric {
let first : Int64? = if window.changes.length() == 0 {
None
} else {
Some(window.changes[0].time)
}
let last : Int64? = if window.changes.length() == 0 {
None
} else {
Some(window.changes[window.changes.length() - 1].time)
}
let duration = window.duration()
let density = if duration == 0L {
window.changes.length().to_double()
} else {
window.changes.length().to_double() / duration.to_double()
}
{
index: window.index,
start: window.start,
end: window.end,
duration,
changes: window.changes.length(),
active_signals: window.active_signals,
density,
first_change: first,
last_change: last,
}
}
///|
/// Render metrics as deterministic CSV.
pub fn window_metrics_csv(metrics : Array[WindowMetric]) -> String {
let rows : Array[String] = [
"index,start,end,duration,changes,active_signals,density,first_change,last_change",
]
for metric in metrics {
rows.push(
metric.index.to_string() +
"," +
metric.start.to_string() +
"," +
metric.end.to_string() +
"," +
metric.duration.to_string() +
"," +
metric.changes.to_string() +
"," +
metric.active_signals.to_string() +
"," +
metric.density.to_string() +
"," +
optional_int64_text(metric.first_change) +
"," +
optional_int64_text(metric.last_change),
)
}
rows.join("\n")
}
///|
/// Render metrics as a small JSON array without external dependencies.
pub fn window_metrics_json(metrics : Array[WindowMetric]) -> String {
metrics
.map(fn(metric) {
"{\"index\":" +
metric.index.to_string() +
",\"start\":" +
metric.start.to_string() +
",\"end\":" +
metric.end.to_string() +
",\"duration\":" +
metric.duration.to_string() +
",\"changes\":" +
metric.changes.to_string() +
",\"active_signals\":" +
metric.active_signals.to_string() +
",\"density\":" +
metric.density.to_string() +
",\"first_change\":" +
optional_int64_json(metric.first_change) +
",\"last_change\":" +
optional_int64_json(metric.last_change) +
"}"
})
.join(",")
|> fn(body) { "[" + body + "]" }
}
///|
/// Divide a trace into fixed-width inclusive windows.
pub fn fixed_windows(trace : Trace, width : Int64) -> Array[TraceWindow] {
let bounded = Int64::max(1L, width)
let end_time = Int64::max(
trace.end_time,
trace.changes.fold(init=0L, fn(maximum, change) {
Int64::max(maximum, change.time)
}),
)
let windows : Array[TraceWindow] = []
let mut start = 0L
let mut index = 0
while start <= end_time {
let end = Int64::min(end_time, start + bounded)
windows.push(make_window(trace, index, start, end))
if end == end_time {
break
}
start = end + 1L
index += 1
}
if windows.length() == 0 {
windows.push(make_window(trace, 0, 0L, bounded))
}
windows
}
///|
/// Divide a trace into a requested number of equally sized windows.
pub fn counted_windows(trace : Trace, count : Int) -> Array[TraceWindow] {
let total = Int::max(1, count)
let end_time = Int64::max(0L, trace.end_time)
let span = Int64::max(
1L,
(end_time + 1L + total.to_int64() - 1L) / total.to_int64(),
)
let windows : Array[TraceWindow] = []
for index in 0.. Array[TraceWindow] {
let bounded_width = Int64::max(1L, width)
let bounded_overlap = Int64::min(Int64::max(0L, overlap), bounded_width - 1L)
let step = Int64::max(1L, bounded_width - bounded_overlap)
let end_time = Int64::max(0L, trace.end_time)
let windows : Array[TraceWindow] = []
let mut start = 0L
let mut index = 0
while start <= end_time {
let end = Int64::min(end_time, start + bounded_width - 1L)
windows.push(make_window(trace, index, start, end))
if end == end_time {
break
}
start += step
index += 1
}
windows
}
///|
/// Build windows from change density. A new window starts after enough changes.
pub fn change_driven_windows(
trace : Trace,
minimum_changes : Int,
) -> Array[TraceWindow] {
let threshold = Int::max(1, minimum_changes)
if trace.changes.length() == 0 {
return [make_window(trace, 0, 0L, trace.end_time)]
}
let windows : Array[TraceWindow] = []
let mut start = trace.changes[0].time
let mut current : Array[Change] = []
let mut index = 0
let mut last_time = start
for change in trace.changes {
if current.length() >= threshold && change.time != last_time {
windows.push(
make_window_from_changes(trace, index, start, last_time, current),
)
index += 1
current = []
start = change.time
}
current.push(change)
last_time = change.time
}
if current.length() > 0 {
windows.push(
make_window_from_changes(trace, index, start, last_time, current),
)
}
windows
}
///|
/// Select the segmentation algorithm requested by a specification.
pub fn segment_trace(trace : Trace, spec : WindowSpec) -> Array[TraceWindow] {
let windows = match spec.policy {
FixedWidth => fixed_windows(trace, spec.width)
FixedCount => counted_windows(trace, spec.count)
Overlapping => overlapping_windows(trace, spec.width, spec.overlap)
ChangeDriven => change_driven_windows(trace, spec.minimum_changes)
SignalActivity => activity_windows(trace, spec.width, spec.minimum_changes)
}
if spec.include_empty {
windows
} else {
windows.filter(fn(window) { window.changes.length() > 0 })
}
}
///|
/// Build and validate a full segmentation plan.
pub fn make_window_plan(trace : Trace, spec : WindowSpec) -> WindowPlan {
let windows = segment_trace(trace, spec)
let retained = windows.fold(init=0, fn(total, window) {
total + window.changes.length()
})
{
source_start: 0L,
source_end: trace.end_time,
windows,
policy: spec.policy,
dropped_changes: Int::max(0, trace.changes.length() - retained),
}
}
///|
/// Return whether the windows cover every source change at least once.
pub fn WindowPlan::covers_changes(self : WindowPlan, trace : Trace) -> Bool {
for change in trace.changes {
if !self.windows.any(fn(window) { window.contains(change.time) }) {
return false
}
}
true
}
///|
/// Return the total number of retained change records.
pub fn WindowPlan::retained_changes(self : WindowPlan) -> Int {
self.windows.fold(init=0, fn(total, window) {
total + window.changes.length()
})
}
///|
/// Return the number of windows that contain no changes.
pub fn WindowPlan::empty_count(self : WindowPlan) -> Int {
self.windows.count_if(fn(window) { window.changes.length() == 0 })
}
///|
/// Return whether window starts are monotonically increasing.
pub fn WindowPlan::is_monotonic(self : WindowPlan) -> Bool {
let mut previous : Int64? = None
for window in self.windows {
match previous {
Some(value) if window.start < value => return false
_ => ()
}
previous = Some(window.start)
}
true
}
///|
/// Find the first window containing a timestamp.
pub fn window_at(plan : WindowPlan, time : Int64) -> TraceWindow? {
for window in plan.windows {
if window.contains(time) {
return Some(window)
}
}
None
}
///|
/// Return all windows that intersect an inclusive range.
pub fn windows_between(
plan : WindowPlan,
start : Int64,
end : Int64,
) -> Array[TraceWindow] {
let low = Int64::min(start, end)
let high = Int64::max(start, end)
plan.windows.filter(fn(window) { window.end >= low && window.start <= high })
}
///|
/// Merge adjacent windows when their gap is below a threshold.
pub fn merge_adjacent_windows(
windows : Array[TraceWindow],
maximum_gap : Int64,
) -> Array[TraceWindow] {
if windows.length() == 0 {
return []
}
let merged : Array[TraceWindow] = []
let mut current = windows[0]
for next in windows[1:] {
let gap = next.start - current.end - 1L
if gap <= Int64::max(0L, maximum_gap) {
let changes = current.changes.copy()
for change in next.changes {
changes.push(change)
}
current = make_window_from_changes(
{
header: { date: None, version: None, timescale: None, comment: None },
signals: [],
changes: [],
end_time: 0L,
},
current.index,
current.start,
Int64::max(current.end, next.end),
changes,
)
current = {
..current,
signal_count: Int::max(current.signal_count, next.signal_count),
}
} else {
merged.push(current)
current = next
}
}
merged.push(current)
merged
}
///|
/// Resample a signal at evenly spaced timestamps inside a window.
pub fn resample_window(
trace : Trace,
signal_id : String,
start : Int64,
end : Int64,
step : Int64,
) -> Array[Sample] {
let bounded_step = Int64::max(1L, step)
let low = Int64::min(start, end)
let high = Int64::max(start, end)
let samples : Array[Sample] = []
let mut time = low
while time <= high {
samples.push({ signal_id, time, value: value_at(trace, signal_id, time) })
if time > high - bounded_step {
break
}
time += bounded_step
}
samples
}
///|
/// Return the last sample at or before the requested point for each signal.
pub fn snapshot_window(trace : Trace, time : Int64) -> Array[Sample] {
trace.signals.map(fn(signal) {
{ signal_id: signal.id, time, value: value_at(trace, signal.id, time) }
})
}
///|
/// Return a dense activity score based on distinct change timestamps.
pub fn window_activity_score(window : TraceWindow) -> Int {
let times : Array[Int64] = []
for change in window.changes {
if !times.contains(change.time) {
times.push(change.time)
}
}
if window.duration() == 0L {
times.length()
} else {
(times.length().to_double() * 1000.0 / window.duration().to_double()).to_int()
}
}
///|
/// Produce one line per window for operational dashboards.
pub fn window_plan_report(plan : WindowPlan) -> String {
let rows : Array[String] = [
"windows=" + plan.windows.length().to_string(),
"retained_changes=" + plan.retained_changes().to_string(),
"dropped_changes=" + plan.dropped_changes.to_string(),
"empty_windows=" + plan.empty_count().to_string(),
"monotonic=" + plan.is_monotonic().to_string(),
]
for window in plan.windows {
let metric = window_metric(window)
rows.push(
window.label() +
" changes=" +
metric.changes.to_string() +
" active_signals=" +
metric.active_signals.to_string() +
" density=" +
metric.density.to_string(),
)
}
rows.join("\n")
}
///|
/// Build a window from a source trace and inclusive time bounds.
fn make_window(
trace : Trace,
index : Int,
start : Int64,
end : Int64,
) -> TraceWindow {
let changes = trace.changes.filter(fn(change) {
change.time >= start && change.time <= end
})
make_window_from_changes(trace, index, start, end, changes)
}
///|
/// Build a window from already selected changes.
fn make_window_from_changes(
trace : Trace,
index : Int,
start : Int64,
end : Int64,
changes : Array[Change],
) -> TraceWindow {
let ids : Array[String] = []
let transition_count = changes.fold(init=0, fn(total, change) {
if ids.contains(change.id) {
total + 1
} else {
ids.push(change.id)
total
}
})
{
index,
start,
end: Int64::max(start, end),
changes: changes.copy(),
signal_count: trace.signals.length(),
active_signals: ids.length(),
transition_count,
}
}
///|
/// Divide a trace using both time width and a minimum activity threshold.
fn activity_windows(
trace : Trace,
width : Int64,
minimum_changes : Int,
) -> Array[TraceWindow] {
let base = fixed_windows(trace, Int64::max(1L, width))
let threshold = Int::max(1, minimum_changes)
base.filter(fn(window) {
window.changes.length() >= threshold || window.start == 0L
})
}
///|
/// Return an optional integer as text without allocating an ad-hoc sentinel.
fn optional_int64_text(value : Int64?) -> String {
match value {
Some(number) => number.to_string()
None => ""
}
}
///|
/// Return an optional integer as JSON.
fn optional_int64_json(value : Int64?) -> String {
match value {
Some(number) => number.to_string()
None => "null"
}
}