facett-timeline/src/model.rs
//! The **engine-agnostic, normalized timeline model** — the neutral shape a host
//! (knut / nornir viz) maps a Spark **jobs event timeline**, a **per-stage task
//! timeline**, or a **Lakeflow run Gantt** onto, and the
//! [`TimelineView`](crate::TimelineView) renders. Pure + egui-free: inject lanes
//! and bars, assert the laid-out fractions, no painter required.
//!
//! ## Shape (mirrors the `facett-pipeline` adapter seam)
//!
//! ```text
//! TimelineModel { lanes, t0, t1 } // wall-clock window
//! Lane { label, bars } // e.g. "executor-3", "stage 7"
//! Bar { id, label, start, end, // positioned by wall-clock time
//! status, segments } // optional intra-bar phases
//! Segment { kind, fraction } // scheduler-delay / compute / shuffle …
//! ```
//!
//! Nothing here depends on Spark: the model speaks `String` labels, `f64`
//! wall-clock values, a neutral [`BarStatus`], and an open [`Segment`] phase bag.
//! The host adapter is the only place that knows what a "stage" is.
use serde::{Deserialize, Serialize};
/// Neutral run status of a single bar (a job / stage / task / run). The host maps
/// its engine status onto this; the view maps it onto a [`Theme`](facett_core::Theme)
/// colour. Identical in spirit to `facett-pipeline`'s `StageStatus`, kept local so
/// this crate needs no pipeline dependency.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum BarStatus {
/// Not started / queued.
#[default]
Idle,
/// Currently executing.
Running,
/// Completed successfully.
Done,
/// Failed.
Failed,
}
impl BarStatus {
/// A short, stable label for text/JSON.
pub fn label(self) -> &'static str {
match self {
BarStatus::Idle => "idle",
BarStatus::Running => "running",
BarStatus::Done => "done",
BarStatus::Failed => "failed",
}
}
}
/// The canonical Spark **task-phase** order (the `TIMELINE_LEGEND` of
/// `StagePage.scala`): where a task's wall-clock time actually goes. A host fills a
/// [`Bar`]'s [`segments`](Bar::segments) with these `kind`s (or any free string);
/// the view colours known phases from a stable per-phase ramp and hashes the rest.
pub const TASK_PHASES: &[&str] = &[
"scheduler_delay",
"deserialize",
"shuffle_read",
"compute",
"shuffle_write",
"serialize",
"getting_result",
];
/// An intra-bar **phase segment** — a fraction of the bar's width, coloured by its
/// `kind`. The fractions of a bar's segments are interpreted in order; they need
/// not sum to exactly 1 (the view normalises by their sum), so a host can hand raw
/// per-phase millis as fractions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Segment {
/// Phase name (one of [`TASK_PHASES`], or any host string).
pub kind: String,
/// Relative weight of this phase within the bar (≥ 0).
pub fraction: f32,
}
impl Segment {
/// A phase segment with a relative weight.
pub fn new(kind: impl Into<String>, fraction: f32) -> Self {
Self { kind: kind.into(), fraction: fraction.max(0.0) }
}
}
/// One **bar** on a lane: positioned + sized by the wall-clock window `[start,end]`,
/// optionally split into [`Segment`] phases.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Bar {
/// Stable identity (selection is keyed on this, FC-5 — not a list index).
pub id: String,
/// Display label (shown in/over the bar + the hover tooltip).
pub label: String,
/// Wall-clock start.
pub start: f64,
/// Wall-clock end (clamped to `>= start`).
pub end: f64,
/// Run status → bar colour.
pub status: BarStatus,
/// Optional intra-bar phase segments (empty = a solid status-coloured bar).
pub segments: Vec<Segment>,
}
impl Bar {
/// A solid bar `[start,end]` with a status; id defaults to the label.
pub fn new(label: impl Into<String>, start: f64, end: f64, status: BarStatus) -> Self {
let label = label.into();
Self { id: label.clone(), label, start, end: end.max(start), status, segments: Vec::new() }
}
/// Set a distinct stable id (when it differs from the display label).
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = id.into();
self
}
/// Attach intra-bar phase segments.
pub fn with_segments(mut self, segments: Vec<Segment>) -> Self {
self.segments = segments;
self
}
/// Wall-clock duration (`>= 0`).
pub fn duration(&self) -> f64 {
(self.end - self.start).max(0.0)
}
/// The sum of segment fractions (the normaliser; `0` when no segments).
pub fn segment_total(&self) -> f32 {
self.segments.iter().map(|s| s.fraction).sum()
}
}
/// A named **swimlane** holding a row of bars (an executor's lifecycle, a stage's
/// tasks, a job track…).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Lane {
/// Lane label (drawn in the pinned left gutter).
pub label: String,
/// The bars on this lane.
pub bars: Vec<Bar>,
}
impl Lane {
/// An empty lane with a label.
pub fn new(label: impl Into<String>) -> Self {
Self { label: label.into(), bars: Vec::new() }
}
/// Builder: append a bar.
pub fn with_bar(mut self, bar: Bar) -> Self {
self.bars.push(bar);
self
}
/// Builder: set all bars.
pub fn with_bars(mut self, bars: Vec<Bar>) -> Self {
self.bars = bars;
self
}
}
/// How the time axis labels its ticks.
///
/// The timeline's `t0`/`t1` are plain `f64` wall-clock — deliberately unit-free,
/// so a host can mean seconds, millis, or anything monotonic. This says how to
/// *render* a tick, without the widget taking a date dependency: the calendar
/// conversion below is pure integer arithmetic.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AxisFormat {
/// Raw number (`{t:.0}`) — the default, and what every existing host gets.
#[default]
Number,
/// Treat the value as **epoch seconds** and label the tick `"Mon YY"`
/// (e.g. `Jan 25`) — the calendar axis a case/handover timeline wants.
EpochSecondsMonth,
}
impl AxisFormat {
/// Render one tick value under this format.
pub fn label(self, t: f64) -> String {
match self {
AxisFormat::Number => format!("{t:.0}"),
AxisFormat::EpochSecondsMonth => {
const M: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
];
// Floor-divide so pre-epoch instants land on the right day.
let days = (t / 86_400.0).floor() as i64;
let (y, m, _d) = civil_from_days(days);
format!("{} {:02}", M[(m - 1) as usize], y.rem_euclid(100))
}
}
}
}
/// Days-since-epoch → `(year, month, day)`. Howard Hinnant's `civil_from_days`,
/// exact for the whole range and dependency-free — the reason this crate still
/// needs no date library.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// The whole **timeline**: stacked lanes over a shared wall-clock window `[t0,t1]`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineModel {
/// The swimlanes, top to bottom.
pub lanes: Vec<Lane>,
/// Window start (left edge of the time axis).
pub t0: f64,
/// Window end (right edge of the time axis).
pub t1: f64,
/// Optional **now marker**: a wall-clock instant drawn as a vertical rule
/// across the lanes. `None` (the default) draws nothing, so every existing
/// host is unaffected.
///
/// The widget NEVER reads the clock — "now" is host-supplied, which keeps the
/// view a pure function of its state and lets a test pin the marker.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub now: Option<f64>,
/// How the time axis labels its ticks. Defaults to [`AxisFormat::Number`], so
/// existing hosts render exactly as before.
#[serde(default, skip_serializing_if = "is_number_axis")]
pub axis: AxisFormat,
}
fn is_number_axis(a: &AxisFormat) -> bool {
*a == AxisFormat::Number
}
impl TimelineModel {
/// An empty timeline over `[t0,t1]`.
pub fn new(t0: f64, t1: f64) -> Self {
Self { lanes: Vec::new(), t0, t1, now: None, axis: AxisFormat::default() }
}
/// Builder: label the time axis with `axis` (see [`AxisFormat`]).
pub fn with_axis(mut self, axis: AxisFormat) -> Self {
self.axis = axis;
self
}
/// Builder: mark `now` with a vertical rule (host-supplied; see [`now`](Self::now)).
pub fn with_now(mut self, now: f64) -> Self {
self.now = Some(now);
self
}
/// Builder: append a lane.
pub fn with_lane(mut self, lane: Lane) -> Self {
self.lanes.push(lane);
self
}
/// The wall-clock span of the window (never `0`, so fractions are finite).
pub fn span(&self) -> f64 {
(self.t1 - self.t0).max(f64::EPSILON)
}
/// Total bars across all lanes.
pub fn bar_count(&self) -> usize {
self.lanes.iter().map(|l| l.bars.len()).sum()
}
/// `true` when there is nothing to draw.
pub fn is_empty(&self) -> bool {
self.bar_count() == 0
}
/// Recompute `[t0,t1]` to tightly fit the min start / max end across every bar
/// (a host that doesn't know the window up front calls this). No-op when empty.
pub fn auto_window(&mut self) {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for lane in &self.lanes {
for b in &lane.bars {
lo = lo.min(b.start);
hi = hi.max(b.end);
}
}
if lo.is_finite() && hi.is_finite() {
self.t0 = lo;
self.t1 = hi;
}
}
/// The fraction `[0,1]` of the window at which wall-clock `t` sits (clamped).
pub fn frac_for_time(&self, t: f64) -> f32 {
self.frac_for_time_in(t, self.t0, self.t1)
}
/// The fraction `[0,1]` of an **arbitrary** window `[t0,t1]` at which wall-clock
/// `t` sits (clamped). Lets the view render against a **zoom** window narrower
/// than the model's own `[t0,t1]` while reusing one offset formula.
pub fn frac_for_time_in(&self, t: f64, t0: f64, t1: f64) -> f32 {
let span = (t1 - t0).max(f64::EPSILON);
(((t - t0) / span) as f32).clamp(0.0, 1.0)
}
/// A bar's `(start_fraction, width_fraction)` within the model window — the single
/// source of truth shared by the painter, the headless [`layout`](crate::layout)
/// helper, and `state_json`, so what a test asserts IS what the pixels come from.
pub fn bar_fraction(&self, bar: &Bar) -> (f32, f32) {
self.bar_fraction_in(bar, self.t0, self.t1)
}
/// A bar's `(start_fraction, width_fraction)` within an **arbitrary** window
/// `[t0,t1]` — the zoom-aware form of [`bar_fraction`](Self::bar_fraction).
pub fn bar_fraction_in(&self, bar: &Bar, t0: f64, t1: f64) -> (f32, f32) {
let s = self.frac_for_time_in(bar.start, t0, t1);
let e = self.frac_for_time_in(bar.end, t0, t1);
(s, (e - s).max(0.0))
}
/// The observable state of the model over its own `[t0,t1]` (folded into the
/// view's `state_json`): the window + every lane's bars with their laid-out
/// fractions + segments.
pub fn state_json(&self) -> serde_json::Value {
self.state_json_windowed(self.t0, self.t1)
}
/// The observable state computed against an **arbitrary** window `[t0,t1]` — the
/// zoom-aware form the view dumps so a headless driver reads the bar offsets the
/// *current zoom* paints (not the model's full span).
pub fn state_json_windowed(&self, t0: f64, t1: f64) -> serde_json::Value {
let lanes: Vec<serde_json::Value> = self
.lanes
.iter()
.enumerate()
.map(|(li, lane)| {
let bars: Vec<serde_json::Value> = lane
.bars
.iter()
.map(|b| {
let (sf, wf) = self.bar_fraction_in(b, t0, t1);
let segs: Vec<serde_json::Value> = b
.segments
.iter()
.map(|s| serde_json::json!({ "kind": s.kind, "fraction": s.fraction }))
.collect();
serde_json::json!({
"id": b.id,
"label": b.label,
"start": b.start,
"end": b.end,
"start_frac": sf,
"width_frac": wf,
"status": b.status.label(),
"segments": segs,
})
})
.collect();
serde_json::json!({ "lane": li, "label": lane.label, "bars": bars })
})
.collect();
serde_json::json!({
"t0": t0,
"t1": t1,
"span": (t1 - t0).max(f64::EPSILON),
"model_t0": self.t0,
"model_t1": self.t1,
"lane_count": self.lanes.len(),
"bar_count": self.bar_count(),
"lanes": lanes,
})
}
}
#[cfg(test)]
mod tests {
/// The default axis is unchanged for every existing host, and stays out of
/// the serialized state.
#[test]
fn default_axis_is_raw_numbers_and_omitted_from_json() {
let m = TimelineModel::new(0.0, 10.0);
assert_eq!(m.axis, AxisFormat::Number);
assert_eq!(m.axis.label(1_700_000_000.0), "1700000000");
let j = serde_json::to_value(&m).expect("serializes");
assert!(j.get("axis").is_none(), "default axis must not appear: {j}");
}
/// The calendar axis, against dates computed independently of the
/// implementation. Covers the epoch itself, a known modern instant, a
/// month-boundary, a leap day, and a PRE-epoch instant (the floor-division
/// case a naive `as i64` truncation gets wrong).
#[test]
fn epoch_seconds_month_axis_labels_known_dates() {
let f = AxisFormat::EpochSecondsMonth;
assert_eq!(f.label(0.0), "Jan 70", "the epoch itself");
// 2026-07-20T00:00:00Z = 1784505600
assert_eq!(f.label(1_784_505_600.0), "Jul 26");
// 2000-03-01T00:00:00Z = 951868800 (day after the 2000 leap day)
assert_eq!(f.label(951_868_800.0), "Mar 00");
// 2000-02-29T00:00:00Z = 951782400 — the leap day itself
assert_eq!(f.label(951_782_400.0), "Feb 00");
// 1969-12-31T00:00:00Z = -86400 — pre-epoch must floor, not truncate
assert_eq!(f.label(-86_400.0), "Dec 69");
// A fraction of a day past midnight stays on the same day.
assert_eq!(f.label(1_784_505_600.0 + 3_600.0), "Jul 26");
}
/// Year-mod-100 must not go negative for pre-2000 dates (rem_euclid, not %).
#[test]
fn pre_2000_years_render_two_digits() {
let f = AxisFormat::EpochSecondsMonth;
// 1970-01-01 -> "70", not "-30" or similar.
assert_eq!(f.label(0.0), "Jan 70");
assert!(!f.label(-86_400.0).contains('-'), "no negative year digits");
}
/// The now marker is opt-in: absent by default, so every existing host is
/// unaffected and the key stays out of the serialized state entirely.
#[test]
fn now_marker_is_absent_by_default_and_omitted_from_json() {
let m = TimelineModel::new(0.0, 10.0);
assert_eq!(m.now, None);
let j = serde_json::to_value(&m).expect("model serializes");
assert!(
j.get("now").is_none(),
"an unset marker must not appear in the state at all: {j}"
);
}
/// Set via the builder, it round-trips through the state a robot asserts.
#[test]
fn now_marker_round_trips_when_set() {
let m = TimelineModel::new(0.0, 10.0).with_now(4.5);
assert_eq!(m.now, Some(4.5));
let j = serde_json::to_value(&m).expect("model serializes");
assert_eq!(j["now"], 4.5);
let back: TimelineModel = serde_json::from_value(j).expect("model round-trips");
assert_eq!(back.now, Some(4.5));
}
use super::*;
#[test]
fn bar_fractions_map_the_window() {
let m = TimelineModel::new(0.0, 1000.0)
.with_lane(Lane::new("e1").with_bar(Bar::new("a", 0.0, 300.0, BarStatus::Done)))
.with_lane(Lane::new("e2").with_bar(Bar::new("b", 500.0, 1000.0, BarStatus::Running)));
let (s0, w0) = m.bar_fraction(&m.lanes[0].bars[0]);
assert!((s0 - 0.0).abs() < 1e-5 && (w0 - 0.3).abs() < 1e-5, "0..300 → start 0 width .3");
let (s1, w1) = m.bar_fraction(&m.lanes[1].bars[0]);
assert!((s1 - 0.5).abs() < 1e-5 && (w1 - 0.5).abs() < 1e-5, "500..1000 → start .5 width .5");
assert_eq!(m.bar_count(), 2);
}
#[test]
fn auto_window_fits_all_bars() {
let mut m = TimelineModel::new(0.0, 1.0)
.with_lane(Lane::new("e").with_bars(vec![
Bar::new("a", 120.0, 300.0, BarStatus::Done),
Bar::new("b", 50.0, 880.0, BarStatus::Running),
]));
m.auto_window();
assert_eq!((m.t0, m.t1), (50.0, 880.0));
}
#[test]
fn state_json_carries_lanes_bars_and_segments() {
let m = TimelineModel::new(0.0, 100.0).with_lane(
Lane::new("tasks").with_bar(
Bar::new("t0", 0.0, 100.0, BarStatus::Running)
.with_segments(vec![Segment::new("compute", 3.0), Segment::new("shuffle_write", 1.0)]),
),
);
let j = m.state_json();
assert_eq!(j["bar_count"], 1);
assert_eq!(j["lanes"][0]["bars"][0]["segments"].as_array().unwrap().len(), 2);
assert_eq!(j["lanes"][0]["bars"][0]["status"], "running");
}
}