Skip to main content

nmbrs_runtime/readouts/
context.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`ReadoutContext`] — the data facade every readout draws
5//! from. See SRD-63 §2.
6//!
7//! Subject-kind validation is keyed off the firing
8//! [`EventType`](crate::lifecycle::EventType) (via
9//! [`EventType::subject_kind`](crate::lifecycle::EventType::subject_kind)) —
10//! one source of truth, no parallel `ctx.subject_kind()`
11//! that could drift. Builtins declare which kinds they
12//! accept via [`Readout::accepts`](super::Readout::accepts);
13//! the binder rejects mis-matches at bake-time so a
14//! workload mistakenly binding `phase_status` to
15//! `on_session_end` fails loudly rather than rendering
16//! silent zeros.
17//!
18//! The trait is one flat surface (rather than four
19//! per-kind traits) because every renderer takes
20//! `&dyn ReadoutContext` and runtime-downcasting between
21//! traits is hostile to call sites. Accessors that don't
22//! apply to every kind have defaults that return zero /
23//! empty so a context impl only fills the slots its kind
24//! actually owns.
25
26use crate::lifecycle::EventType;
27
28/// Lifecycle state of the subject (phase / iteration /
29/// scope / session) the readout is rendering for. See
30/// SRD-63 §2.
31///
32/// Used by readouts that branch on terminal status —
33/// `phase_summary` picks `[ok]` / `[!!]` / `[..]` / `[  ]`
34/// based on this; the post-run observer's tree walk routes
35/// these into the per-row marker.
36///
37/// Deliberately no `Default` impl: callers must pick a
38/// state explicitly. Defaulting to `Running` would have
39/// lifecycle-end fires silently claim "still in flight."
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum LifecycleState {
42    /// Not yet started (a subject that the scenario tree
43    /// declared but the executor hasn't reached).
44    Pending,
45    /// Currently executing.
46    Running,
47    /// Completed cleanly.
48    Completed,
49    /// Failed with the given error string.
50    Failed(String),
51}
52
53/// The data facade a [`Readout`](super::Readout) reads from.
54/// A single implementation per surface (terminal observer,
55/// TUI, post-run summary, …) covers every readout in the
56/// registry; per-event contexts (the `SessionSummaryContext`
57/// in nmbrs-tui, the per-phase contexts in
58/// `crate::readout_context`) populate the slots that apply
59/// to their [`SubjectKind`].
60///
61/// Method additions over the pushes have default impls
62/// where reasonable so existing context impls don't have
63/// to grow on every push. Methods that don't yet exist on
64/// the trait can't be referenced by readouts, so the
65/// contract stays in sync with what's actually
66/// implemented — there are no panic stubs to forget about.
67pub trait ReadoutContext {
68    // ── Subject identity ──────────────────────────────────
69
70    /// Bare subject name. Phase: `setup` / `run` /
71    /// `ann_query`. Iteration / Scope: the scope keyword
72    /// (`for_each`, `do_while`). Session: the scenario name
73    /// (or empty).
74    fn subject_name(&self) -> &str;
75
76    /// Pre-map sequence number `(idx, total)` matching the
77    /// TUI tree row and post-run summary numbering. `None`
78    /// when no scene tree is available (inline-CLI form,
79    /// pre-map didn't run) or the kind doesn't carry a seq.
80    fn subject_seq(&self) -> Option<(usize, usize)> {
81        None
82    }
83
84    /// Root-first display form of the subject's scope
85    /// coords, already produced by
86    /// `polydat::kernel::format_scope_coordinate_path`
87    /// applied to the reversed
88    /// `parent_kernel.scope_coordinates()`. Empty for root-
89    /// scope subjects.
90    fn subject_labels(&self) -> &str {
91        ""
92    }
93
94    /// The execution this subject belongs to (SRD-88 `exec_id`,
95    /// SRD-100 §9). Part of the snapshot key so concurrent executions
96    /// of the same phase don't upsert-collide (the §2.6 data-loss bug).
97    /// Defaults to the current execution resolved from the task-local
98    /// `ExecutionContext` (`1` for the single-execution case, SRD-88
99    /// A1); an off-task producer overrides with an exec_id captured by
100    /// value before it leaves the task.
101    fn subject_exec_id(&self) -> u64 {
102        crate::execution_context::current_exec_id()
103    }
104
105    /// Stable identifier used as part of the snapshot
106    /// primary key. Default: `subject_name` when no labels,
107    /// `name@labels` otherwise. Surfaces that need a
108    /// different shape (e.g. session uses the literal
109    /// `"session"`) override this method.
110    fn subject_id(&self) -> String {
111        let name = self.subject_name();
112        let labels = self.subject_labels();
113        if labels.is_empty() {
114            name.to_string()
115        } else {
116            format!("{name}@{labels}")
117        }
118    }
119
120    /// Full activity name including the leaf coord, matching
121    /// the value the inline-progress thread prints today
122    /// (e.g. `run (profile=alpha, bucket=1, kind=READ)`).
123    /// Defaults to [`subject_name`](Self::subject_name) —
124    /// override gives the inline form what it expected.
125    fn activity_name(&self) -> &str {
126        self.subject_name()
127    }
128
129    // ── Lifecycle / counters (Phase) ──────────────────────
130
131    /// Cycles completed in this phase (from
132    /// `ActivityMetrics::cycles_completed`). Default 0 —
133    /// non-Phase contexts return 0.
134    fn cycles_completed(&self) -> u64 {
135        0
136    }
137
138    /// Total extent the phase planned to consume — either
139    /// the source-driven extent or the configured
140    /// `cycles=N`. Used for `pct` denominator. Default 0.
141    fn cycles_total(&self) -> u64 {
142        0
143    }
144
145    /// Cumulative success count (counter, not delta).
146    /// Default 0.
147    fn ops_ok(&self) -> u64 {
148        0
149    }
150
151    /// Cumulative count of SKIPPED ops (`skips_total`) — ops whose
152    /// `if:` gate was false, so no adapter call ran. A skip is
153    /// neither a success nor a failure; it must be excluded from the
154    /// `ok%` denominator (`cycles_total == result_total + skips_total`,
155    /// so the success-rate basis is `cycles_completed - skips`).
156    /// Default 0.
157    fn skips(&self) -> u64 {
158        0
159    }
160
161    /// Cumulative error count (includes retries). Default 0.
162    fn errors(&self) -> u64 {
163        0
164    }
165
166    /// Retries — derived as `errors - failed_ops` per the
167    /// existing convention in `nmbrs-runtime::activity`.
168    /// Default 0.
169    fn retries(&self) -> u64 {
170        0
171    }
172
173    /// Cumulative count of successful ATTEMPTS (SRD-91
174    /// `attempt_success`), observed when the attempt returns.
175    /// Every result-success comes from exactly one successful
176    /// final attempt, so this coincides with
177    /// [`ops_ok`](Self::ops_ok) when no retry ever fired.
178    /// Default 0.
179    fn attempt_ok(&self) -> u64 {
180        0
181    }
182
183    /// Cumulative count of FAILED attempts (SRD-91
184    /// `attempt_failure`), observed when the attempt returns.
185    /// Retried failures are counted here too. The attempt
186    /// success rate the status line shows beside the
187    /// result-level `ok%` is `attempt_ok / (attempt_ok +
188    /// attempt_failed)` — RESOLVED attempts only (both counters
189    /// increment at attempt end), so in-flight attempts don't
190    /// skew it the way the dispatch-time `attempt_total` counter
191    /// would. It coincides with `ok%` when no retry fires and
192    /// falls below it under retry pressure (results still
193    /// succeed, but only after wasted attempts). Default 0.
194    fn attempt_failed(&self) -> u64 {
195        0
196    }
197
198    /// Effective fiber count (concurrency). Default 0.
199    fn concurrency(&self) -> usize {
200        0
201    }
202
203    /// Wallclock seconds since the subject started.
204    /// Default 0.0.
205    fn elapsed_secs(&self) -> f64 {
206        0.0
207    }
208
209    /// Items consumed from the source factory — drives the
210    /// throughput rate. Distinct from `cycles_completed`
211    /// because data-driven phases consume one source item
212    /// per op while the cycle counter tracks ops finished;
213    /// for sourceless phases the two are identical.
214    /// Default 0.
215    fn consumed(&self) -> u64 {
216        0
217    }
218
219    /// Cursor ordinals CONSUMED (row-level progress) for a
220    /// data-driven phase — polydat `global_consumed()`. Distinct
221    /// from [`consumed`](Self::consumed) / ops-finished: one op can
222    /// stride N ordinals, so this is the authoritative row count.
223    /// `0` for non-cursor phases. Drives the numerator of the
224    /// `rows:{consumed}/{total}` progress chip. Default 0.
225    fn rows_consumed(&self) -> u64 {
226        0
227    }
228
229    /// Cursor ordinal EXTENT for a data-driven phase
230    /// (`global_extent()`). `0` for non-cursor phases (plain
231    /// `cycles:`) — the phase-status readout uses `rows_total() > 0`
232    /// to pick the row-denominated `rows:` chip over the
233    /// op-denominated `cycles:` chip, so a stride-driven phase's
234    /// progress and its rows/s rate agree. Default 0.
235    fn rows_total(&self) -> u64 {
236        0
237    }
238
239    /// Ops dispatched to the adapter. Distinct from
240    /// `consumed`: ops_started increments at dispatch,
241    /// `consumed` increments at the source pull. The inline
242    /// progress line uses `ops_started` for `pct` so a
243    /// rate-limited phase shows pending vs. dispatched
244    /// vs. finished correctly. Default 0 — context impls
245    /// that don't track this just see "no progress" in the
246    /// inline line, which is correct for them.
247    fn ops_started(&self) -> u64 {
248        0
249    }
250
251    /// Ops returned from the adapter (atomic, not the
252    /// histogram counter). Distinct from
253    /// [`cycles_completed`](Self::cycles_completed) which
254    /// reads the `cycles_total` Counter; the two coincide
255    /// in steady state but the inline-progress line uses
256    /// `ops_finished` for its rate / ETA calculations and
257    /// the `(rate = finished / elapsed)` shape must
258    /// preserve. Default falls through to `cycles_completed`
259    /// so contexts without the atomic split see equivalent
260    /// behaviour.
261    fn ops_finished(&self) -> u64 {
262        self.cycles_completed()
263    }
264
265    /// Estimated remaining seconds until the phase finishes,
266    /// or `None` when not computable (no `cycles_total` or
267    /// `rate` is zero). Used by readouts that show ETA;
268    /// readouts decide whether to render anything when
269    /// `None`.
270    fn eta_secs(&self) -> Option<f64> {
271        None
272    }
273
274    /// True for an OPEN-ENDED subject — a daemon / background poll with
275    /// no meaningful completion total. Displays render a latency summary
276    /// in place of a progress meter (there is no "done" to meter).
277    /// Default false.
278    fn open_ended(&self) -> bool {
279        false
280    }
281
282    /// Live service-time percentiles (nanoseconds) for the subject, 0
283    /// when unavailable. Rendered by open-ended subjects in the space a
284    /// progress meter would otherwise occupy.
285    fn latency_p50_nanos(&self) -> u64 {
286        0
287    }
288    fn latency_p99_nanos(&self) -> u64 {
289        0
290    }
291
292    /// The subject's completion fraction on the CORRECT basis, or
293    /// `None` when progress is not meaningful (open-ended subjects).
294    /// Priority:
295    ///   1. derived-progress override (a producer measuring itself);
296    ///   2. row basis (`rows_consumed / rows_total`) — REQUIRED for
297    ///      batched phases, whose cycle count is denominated in ops
298    ///      while the extent is denominated in rows (the old
299    ///      cycles-basis pct showed 1% for a stride-100 batch load);
300    ///   3. cycle basis for plain per-op phases.
301    fn progress_fraction(&self) -> Option<f64> {
302        if let Some(f) = self.progress_override() {
303            return Some(f.clamp(0.0, 1.0));
304        }
305        if self.open_ended() {
306            return None;
307        }
308        let (rc, rt) = (self.rows_consumed(), self.rows_total());
309        if rt > 0 {
310            return Some((rc as f64 / rt as f64).clamp(0.0, 1.0));
311        }
312        let t = self.cycles_total();
313        if t > 0 {
314            return Some((self.cycles_completed() as f64 / t as f64).clamp(0.0, 1.0));
315        }
316        None
317    }
318
319    /// Derived completion fraction in `[0.0, 1.0]` published by a
320    /// producer that measures its own progress (e.g. a `poll:`
321    /// await's `progress:` template reading `completion_ratio`).
322    /// When `Some`, phase displays render THIS fraction for the
323    /// completion bar / percentage instead of the cycles-based
324    /// `cycles_completed / cycles_total` — which pins at 0% for a
325    /// single long op no matter how far along the measured work is.
326    /// Default `None` (cycle accounting applies).
327    fn progress_override(&self) -> Option<f64> {
328        None
329    }
330
331    // ── Workload-emphasised metrics ───────────────────────
332
333    /// Pre-rendered status-metric chip string (e.g.
334    /// ` recall_at_10:79.62% latency_p99:1.23ms`).
335    /// Matches today's `ActivityMetrics::collect_status_values`
336    /// output concatenated. Default empty.
337    fn status_metric_chips(&self) -> String {
338        String::new()
339    }
340
341    /// Pre-formatted adapter-counter tail. Today's
342    /// inline-status line builds this by iterating
343    /// `progress_metrics.dispensers` and concatenating
344    /// `name=<count>/s` chips. Default empty.
345    fn adapter_counters_text(&self) -> String {
346        String::new()
347    }
348
349    /// Pre-formatted batching tail (`r/b=12.5` style).
350    /// Default empty.
351    fn batch_info_text(&self) -> String {
352        String::new()
353    }
354
355    // ── Surface conveniences ──────────────────────────────
356
357    /// Indent string for the depth this subject sits at in
358    /// the scene tree. Matches the value
359    /// `nmbrs_runtime::scene_tree::running_phase_indent`
360    /// produces today. Default empty.
361    fn depth_indent(&self) -> &str {
362        ""
363    }
364
365    /// True when the surface accepts ANSI styling. Honours
366    /// `NO_COLOR`, TTY presence, and explicit operator
367    /// overrides — the readout queries this once and emits
368    /// styling tokens (or not) on the basis of the
369    /// returned bool. The §5.2 colour / style sub-language
370    /// (Push 4) replaces inline ANSI with typed style tokens.
371    /// Default false.
372    fn use_color(&self) -> bool {
373        false
374    }
375
376    /// Operator-visible phase memo — short string published by
377    /// the `memo` wrapper via `before:` / `after:` templates.
378    /// Default empty (no memo configured / nothing published).
379    /// Surfaced by phase displays as `[[ <memo> ]]` above the
380    /// status line when non-empty.
381    fn phase_memo(&self) -> &str {
382        ""
383    }
384
385    // ── Event / refresh ───────────────────────────────────
386
387    /// Which slot fired this render. Required: every
388    /// context must declare what event it represents so
389    /// readouts that branch on lifecycle (the `trace`
390    /// diagnostic, future wildcard-bound readouts) can't
391    /// misreport. No default — a phase-end fire that
392    /// silently claimed `Update` would be a bug, so the
393    /// type system makes the caller pick.
394    fn event(&self) -> EventType;
395
396    /// Monotonic refresh-tick counter. Advances once per
397    /// refresh fire of the same subject. Used by readouts
398    /// that animate (the spinner glyph in `phase_status`).
399    /// Default 0 — fine for one-shot lifecycle renders.
400    fn refresh_tick(&self) -> u64 {
401        0
402    }
403
404    // ── Lifecycle state ───────────────────────────────────
405
406    /// Lifecycle state of the subject. Default
407    /// [`LifecycleState::Running`] — the most common case at
408    /// `on_update` fire. Lifecycle readouts (`phase_outcome`,
409    /// `phase_summary`) branch on this to pick markers /
410    /// glyphs / coloration.
411    fn subject_state(&self) -> LifecycleState {
412        LifecycleState::Running
413    }
414
415    // ── SRD-76 structured outcome ─────────────────────────
416
417    /// SRD-76 — the terminal disposition of the phase. Drives
418    /// the [`phase_outcome`](crate::readouts::builtins::phase_outcome)
419    /// readout's status glyph and rendering branch. Defaults
420    /// to a Completed+Succeeded outcome
421    /// for `on_update` fires (which never terminate the
422    /// phase) and for any context that doesn't carry a
423    /// distinct outcome.
424    fn outcome(&self) -> crate::phase_outcome::Outcome {
425        crate::phase_outcome::Outcome::completed()
426    }
427
428    /// SRD-76 — the error list collected during the phase.
429    /// Empty for `Completed`/`Skipped`; non-empty for
430    /// `Failed`. Ordered chronologically by `at_nanos`.
431    /// Defaults to an empty slice; only fire-time contexts
432    /// that own the outcome populate this.
433    fn outcome_errors(&self) -> &[crate::phase_outcome::PhaseErrorDetail] {
434        &[]
435    }
436
437    /// SRD-76 — resume-state for the next session, if the
438    /// phase supports cursor-resume. `None` for
439    /// non-resumable phases or contexts without an outcome.
440    fn outcome_resume_cursor(&self) -> Option<&crate::phase_outcome::ResumeCursor> {
441        None
442    }
443
444    // ── Session-scope identity ────────────────────────────
445
446    /// Scenario name for the current run. Used by
447    /// `session_banner`. Default empty — only session-scoped
448    /// contexts populate it.
449    fn session_scenario_name(&self) -> &str {
450        ""
451    }
452
453    /// Workload file path for the current run. Used by
454    /// `session_banner`. Default empty.
455    fn session_workload_file(&self) -> &str {
456        ""
457    }
458
459    /// SRD-106 — the session id the `stick_session` rung
460    /// re-attached to; empty when stick did not engage. Used by
461    /// `session_notice` (which renders nothing when empty).
462    fn stick_reattached_session(&self) -> &str {
463        ""
464    }
465
466    // ── Session-scope totals ──────────────────────────────
467
468    /// Total phases that completed cleanly across the run.
469    /// Default 0 — only session-scoped readouts use this.
470    fn session_phases_completed(&self) -> usize {
471        0
472    }
473
474    /// Total phases that failed across the run.
475    fn session_phases_failed(&self) -> usize {
476        0
477    }
478
479    /// Total phases that didn't run (pre-mapped but skipped).
480    fn session_phases_pending(&self) -> usize {
481        0
482    }
483
484    /// Total phases the scenario tree planned.
485    fn session_phases_total(&self) -> usize {
486        0
487    }
488
489    /// Number of phases that were truncated from the
490    /// post-run summary tail because they followed the last
491    /// failure. Used by the `truncated_phases` readout to
492    /// render the `(… and N more phases not listed)` rollup
493    /// without scaling display to thousands of pending
494    /// rows on a long-running scenario that failed early.
495    /// Default 0 — no truncation.
496    fn session_phases_truncated(&self) -> usize {
497        0
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::lifecycle::SubjectKind;
505
506    struct PhaseLikeCtx {
507        name: String,
508        labels: String,
509    }
510    impl ReadoutContext for PhaseLikeCtx {
511        fn subject_name(&self) -> &str {
512            &self.name
513        }
514        fn subject_labels(&self) -> &str {
515            &self.labels
516        }
517        fn event(&self) -> EventType {
518            EventType::PhaseEnd
519        }
520    }
521
522    #[test]
523    fn default_subject_id_collapses_to_name_when_no_labels() {
524        let ctx = PhaseLikeCtx {
525            name: "setup".into(),
526            labels: String::new(),
527        };
528        assert_eq!(ctx.subject_id(), "setup");
529    }
530
531    #[test]
532    fn default_subject_id_appends_labels_with_at_sign() {
533        let ctx = PhaseLikeCtx {
534            name: "ann_query".into(),
535            labels: "(profile=alpha), (k=10)".into(),
536        };
537        assert_eq!(ctx.subject_id(), "ann_query@(profile=alpha), (k=10)");
538    }
539
540    struct SessionLikeCtx;
541    impl ReadoutContext for SessionLikeCtx {
542        fn subject_name(&self) -> &str {
543            "session"
544        }
545        fn subject_id(&self) -> String {
546            "session".to_string()
547        }
548        fn event(&self) -> EventType {
549            EventType::SessionEnd
550        }
551    }
552
553    #[test]
554    fn session_context_overrides_subject_id_to_literal() {
555        let ctx = SessionLikeCtx;
556        assert_eq!(ctx.subject_id(), "session");
557        // SubjectKind comes from the event, not the ctx.
558        assert_eq!(ctx.event().subject_kind(), SubjectKind::Session);
559    }
560
561    #[test]
562    fn subject_kind_as_str_round_trips_via_table() {
563        assert_eq!(SubjectKind::Phase.as_str(), "phase");
564        assert_eq!(SubjectKind::Session.as_str(), "session");
565        assert_eq!(SubjectKind::Iteration.as_str(), "iteration");
566        assert_eq!(SubjectKind::Scope.as_str(), "scope");
567    }
568}