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}