Skip to main content

nmbrs_runtime/readouts/builtins/
phase_status.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `phase_status` — the live-status readout.
5//!
6//! Renders the inline-progress line that the activity's
7//! refresh thread emits via `\r\x1b[K…` every 0.5 s.
8//! Push 2 byte-equivalence target — the format string the
9//! prior implementation used:
10//!
11//! ```text
12//! {depth_indent}{cyan}{spinner}{reset}{bar} {seq_prefix}{activity_name} \
13//!   {pct:.0}% {rate_str} ok:{ok_pct:.0}% att:{att_pct:.0}% e:{errors} r:{retries} c:{concurrency}\
14//!   {adapter_status}{batch_info}{relevancy_str}{eta}
15//! ```
16//!
17//! `ok:` is the result-level success rate (`result_success /
18//! cycles_completed`); `att:` is the attempt-level success rate
19//! (`attempt_success / (attempt_success + attempt_failure)`,
20//! SRD-91 — resolved attempts only, so in-flight attempts don't
21//! skew it). They coincide when no retry fires and diverge under
22//! retry pressure.
23//!
24//! Width clamping (`truncate_to_width`) is the surface's
25//! job — see the inline-status driver in `nmbrs-runtime::activity`.
26//! Other LODs and the explanation overlay render zero bytes
27//! in Push 2; Push 5 (`Lod::Expanded`) and Push 7
28//! (`ContentMode::Explanation`) fill them in.
29
30use std::fmt::Write as _;
31
32use crate::lifecycle::SubjectKind;
33use crate::readouts::buf::ReadoutBuf;
34use crate::readouts::context::ReadoutContext;
35use crate::readouts::format::{braille_bar, format_eta, format_rate, spinner_frame};
36use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
37
38pub struct PhaseStatus;
39
40impl Readout for PhaseStatus {
41    fn name(&self) -> &'static str {
42        "phase_status"
43    }
44    fn accepts(&self) -> &'static [SubjectKind] {
45        &[SubjectKind::Phase]
46    }
47
48    fn render(
49        &self,
50        ctx: &dyn ReadoutContext,
51        lod: Lod,
52        mode: ContentMode,
53        _opts: &ReadoutOptions,
54        out: &mut dyn ReadoutBuf,
55    ) -> usize {
56        match (lod, mode) {
57            (Lod::Compact, ContentMode::Value) => render_compact(ctx, out),
58            (Lod::Labeled, ContentMode::Value) => render_labeled(ctx, out),
59            (Lod::Expanded, ContentMode::Value) => render_expanded(ctx, out),
60            (Lod::Compact, ContentMode::Explanation) => render_compact_explanation(ctx, out),
61            (Lod::Labeled, ContentMode::Explanation) => render_labeled_explanation(ctx, out),
62            (Lod::Expanded, ContentMode::Explanation) => render_expanded_explanation(ctx, out),
63        }
64    }
65}
66
67/// Compact LOD explanation overlay. Same shape as
68/// `render_compact` (`{spinner} {pct}% {rate}`); each
69/// token replaced with a meaning descriptor.
70fn render_compact_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
71    let s = "spin progress% rate/s";
72    let _ = out.write_str(s);
73    s.len()
74}
75
76/// Labeled LOD explanation overlay. Same shape as the
77/// labeled value form — spinner + bar + name + counters
78/// + ETA.
79fn render_labeled_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
80    let coords = if ctx.subject_labels().is_empty() {
81        ""
82    } else {
83        " (scope-coords)"
84    };
85    let mut tmp = String::with_capacity(160);
86    let _ = write!(
87        &mut tmp,
88        "spin (bar) [phase-name]{coords} \
89progress% throughput ok:result-ok% att:attempt-ok% e:errors r:retries c:concurrency \
90(metrics) ETA remaining",
91    );
92    let len = tmp.len();
93    let _ = out.write_str(&tmp);
94    len
95}
96
97/// Expanded LOD explanation overlay. Multi-line block,
98/// same shape as `render_expanded` — one descriptor per
99/// row.
100fn render_expanded_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
101    let s = "\
102spin [phase-name]\n  \
103progress:   progress% (bar)  ETA remaining\n  \
104throughput: throughput  ok:result-ok%  att:attempt-ok%\n  \
105counters:   e:errors r:retries c:concurrency\n  \
106adapter:    adapter-counters\n  \
107batch:      batch-info\n  \
108metrics:    workload-emphasised metrics";
109    let _ = out.write_str(s);
110    s.len()
111}
112
113/// The METER SLOT: `NN%` when the subject has a completion fraction,
114/// or a latency summary (`p50:1.2ms p99:9.8ms`) for open-ended
115/// subjects (daemon background pollers), which have no "done" to
116/// meter — the space carries the signal an operator actually watches
117/// on a poller. Empty only when open-ended with no samples yet.
118fn meter_slot(_ctx: &dyn ReadoutContext, frac: Option<f64>) -> String {
119    // Open-ended subjects render NOTHING here: the header aligns with
120    // workload-level tracking; their latency display lives in the
121    // detail row's contextual gutter (see the sink's latency_gutter),
122    // the space a progress meter would otherwise own.
123    match frac {
124        Some(f) => format!("{:.0}%", f * 100.0),
125        None => String::new(),
126    }
127}
128
129fn render_labeled(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
130    // Palette per docs/guide/color_style.md:
131    //   spinner → cyan (motion cue)
132    //   activity name → bold + INFO (sky/blue)
133    //   bar / rate / ok% / c: / ETA → MUTED (dim)
134    //   pct → default (the headline number; not styled)
135    //   e:N r:N → WARN (yellow) when >0, MUTED when 0
136    //   memo header → EMPHASIS (bold yellow) — sits above
137    let color = ctx.use_color();
138    let cyan = if color { "\x1b[36m" } else { "" };
139    let dim = if color { "\x1b[2m" } else { "" };
140    let bold = if color { "\x1b[1m" } else { "" };
141    let blue = if color { "\x1b[34m" } else { "" };
142    let yellow = if color { "\x1b[33m" } else { "" };
143    let reset = if color { "\x1b[0m" } else { "" };
144
145    let total_extent = ctx.cycles_total();
146    let started = ctx.ops_started();
147    let finished = ctx.ops_finished();
148    let ops_completed = ctx.cycles_completed();
149    let successes = ctx.ops_ok();
150    let skips = ctx.skips();
151    let errors = ctx.errors();
152    let retries = ctx.retries();
153    let attempt_ok = ctx.attempt_ok();
154    let attempt_failed = ctx.attempt_failed();
155    let elapsed = ctx.elapsed_secs();
156    let concurrency = ctx.concurrency();
157
158    // "Not yet ready" guard: when a phase has just started
159    // and hasn't dispatched its first op, the full counter
160    // block reads as all-zeros, which the operator perceives
161    // as a stale or broken frame. Render a compact "starting…"
162    // placeholder until the phase has either dispatched its
163    // first op or accumulated at least 200ms of elapsed time.
164    // The spinner keeps moving so motion confirms the
165    // renderer is alive.
166    if started == 0 && elapsed < 0.2 {
167        let depth_indent = ctx.depth_indent();
168        let activity_name = ctx.activity_name();
169        let spinner = spinner_frame(ctx.refresh_tick());
170        // No seq prefix: the margin's [n/N] slot owns the phase
171        // counter (single-placement rule — nothing the gutter
172        // carries is repeated in body text).
173        let mut tmp = String::with_capacity(64);
174        let _ = write!(
175            &mut tmp,
176            "{depth_indent}{cyan}{spinner}{reset} {bold}{blue}[{activity_name}]{reset} {dim}starting…{reset}",
177        );
178        let len = tmp.len();
179        let _ = out.write_str(&tmp);
180        return len;
181    }
182
183    // Progress percentage uses *completed* cycles, not
184    // dispatched ones. The previous `ops_started`-based
185    // formula reported 100% the moment the only fiber
186    // dispatched its sole op — for long synchronous calls
187    // (jolokia_compact, schema migrations) the bar pinned at
188    // 100% for the whole wait. `cycles_completed` matches
189    // what `phase_outcome` reports and what rate / ETA derive
190    // from, so the running bar and the final DONE line agree.
191    // A derived-progress override (a producer measuring its own
192    // completion, e.g. `poll.progress`) wins over both — the
193    // cycle basis pins at 0% for a single long measured op.
194    // Single fraction source (override → rows → cycles); `None` for
195    // open-ended subjects, whose meter slot renders latency instead.
196    let frac = ctx.progress_fraction();
197    // ok% excludes SKIPS — a skipped (`if:`-gated) op is neither a
198    // success nor a failure, so the basis is result-producing ops only
199    // (`cycles_completed - skips == result_total`).
200    // `.max(successes)`: cycles_completed and result_success are read
201    // non-atomically and bumped in the reverse order (cycles first), so a
202    // completing op can make this dip below `successes` momentarily —
203    // result_total is never truly < successes. Clamp up so ok% stays <= 100%.
204    let result_total = ops_completed.saturating_sub(skips).max(successes);
205    let ok_pct: f64 = if result_total > 0 {
206        successes as f64 * 100.0 / result_total as f64
207    } else {
208        100.0
209    };
210    // All ops skipped so far → no results to be ok about: `ok:—`
211    // instead of a fabricated 100%, plus an explicit skip counter so
212    // the line reads as "gated off", not "measured clean".
213    let ok_str: String = if result_total > 0 {
214        format!("{ok_pct:.0}%")
215    } else if skips > 0 {
216        "—".to_string()
217    } else {
218        "100%".to_string()
219    };
220    // Attempt success rate (SRD-91): fraction of RESOLVED
221    // adapter invocations that succeeded. Denominator is
222    // `attempt_ok + attempt_failed` (both tallied at attempt
223    // end) so in-flight attempts don't skew it — the same
224    // completed-only basis `ok_pct` uses. Equals `ok_pct` when
225    // no retry ever fired; drops below it under retry pressure,
226    // where results still land green (`ok%`) only after wasted
227    // attempts. Surfaced so an operator sees cluster strain
228    // before it propagates to result-level failures.
229    let attempt_resolved = attempt_ok + attempt_failed;
230    let att_pct: f64 = if attempt_resolved > 0 {
231        attempt_ok as f64 * 100.0 / attempt_resolved as f64
232    } else {
233        100.0
234    };
235    let rate: f64 = if elapsed > 0.0 {
236        finished as f64 / elapsed
237    } else {
238        0.0
239    };
240    let rate_str = format_rate(rate);
241    let skips_chip: String = if skips > 0 {
242        format!(" {dim}skip:{skips}{reset}")
243    } else {
244        String::new()
245    };
246
247    // The spinner moved OUT of this line — it now replaces
248    // the `│` divider on the row-2 margin (built by the sink
249    // renderer) as a subtle animation indicator that the
250    // phase is still ticking. At phase end, the sink reverts
251    // to the standard `│` divider.
252    let spinner = "";
253    let _ = spinner_frame(0);
254    // Bar styling: bright-white braille dots on a dark-grey
255    // truecolor background. The background makes the empty
256    // leading cells visible as a defined region instead of
257    // a gap — so an early-phase 5% bar reads as `▮▯▯▯▯▯▯▯▯▯`
258    // rather than `▮          ` (where the trailing cells
259    // were braille blanks against the terminal default
260    // background).
261    // The 10-glyph progress bar moved OUT of this line. The
262    // outer sink renderer (`log_only_sink`) builds the bar
263    // from live metrics and renders it as the row-2 margin
264    // replacement, so the running-phase header here is just
265    // `<spinner> <name> <pct>%` while the stats row below
266    // carries the bar in its left-margin gutter.
267    let bar = String::new();
268    // Time span: cumulative elapsed / ETA remaining, packed
269    // into a single dim parenthesised pair. The slash reads
270    // as past→future without needing a label. When ETA can't
271    // be computed (no extent / no progress) the span
272    // degenerates to just elapsed.
273    // ETA only: elapsed lives in the margin's leaf slot (single-
274    // placement rule), so the body never re-emits it.
275    // Open-ended phases (daemons, elapsed-bounded cursors) have no
276    // meaningful completion target — the extent-based fallback must
277    // not resurrect an ETA that `eta_secs()` already declined.
278    // The chip carries BOTH the remaining time and the total phase
279    // estimate (`elapsed + remaining`), so the operator reads the
280    // full expected wall time without adding the margin's elapsed
281    // to the countdown in their head.
282    let eta_chip = |secs: f64| {
283        format!(
284            " {dim}(~{} left of ~{}){reset}",
285            format_eta(secs),
286            format_eta(elapsed + secs)
287        )
288    };
289    let eta = match ctx.eta_secs() {
290        Some(secs) => eta_chip(secs),
291        // Cursor phases (`rows_total > 0`) never take this arm: the
292        // extent is row-denominated while `rate`/`finished` are
293        // op-denominated (one op strides N rows), so the quotient
294        // would overstate the ETA by the stride factor.
295        None if !ctx.open_ended() && ctx.rows_total() == 0 && total_extent > 0 && rate > 0.0 => {
296            let remaining = total_extent.saturating_sub(finished) as f64;
297            eta_chip(remaining / rate)
298        }
299        None => String::new(),
300    };
301
302    // Margin owns [n/N]; body omits it (single-placement rule).
303    let seq_prefix: String = String::new();
304    let depth_indent = ctx.depth_indent();
305    let activity_name = ctx.activity_name();
306    let chips = ctx.status_metric_chips();
307    let adapter_status = ctx.adapter_counters_text();
308    let batch_info = ctx.batch_info_text();
309
310    // Counters tone follows the rule from phase_outcome: yellow
311    // when something abnormal (errors/retries > 0), dim when
312    // clean. ok% gets the same treatment so a 100% / 99%
313    // distinction reads at a glance.
314    let err_tone = if errors > 0 || retries > 0 {
315        yellow
316    } else {
317        dim
318    };
319    let ok_tone = if ok_pct >= 100.0 { dim } else { yellow };
320    // Attempt-success chip tone mirrors ok%: dim (quiet) when
321    // every attempt lands, yellow (warn) the moment attempts
322    // are being burned on retries. Kept adjacent to `ok:` so
323    // the result-vs-attempt divergence reads at a glance.
324    let att_tone = if att_pct >= 100.0 { dim } else { yellow };
325
326    // Memo row (if any): operator-visible state string published
327    // by the `memo:` wrapper, in EMPHASIS color. SRD-92: blocks
328    // compose HEADER-FIRST — the memo is a detail row directly
329    // under the header, never a banner above it (which broke the
330    // header/detail gutter alignment surface-side).
331    let memo = ctx.phase_memo();
332    let memo_row = if memo.is_empty() {
333        String::new()
334    } else {
335        let bold_yellow = if color { "\x1b[1;33m" } else { "" };
336        format!("{depth_indent}    {bold_yellow}[[ {memo} ]]{reset}\n")
337    };
338
339    // Two-line layout: break after the progress percentage so
340    // the head line stays narrow (spinner/bar/name/pct) and
341    // the tail line carries the counters and emphasized
342    // metrics. Indentation on the second line aligns roughly
343    // under the activity name. The surface sink
344    // (`LogOnlySink`) handles multi-line region clearing.
345    //
346    // The progress chip sits alongside `c:concurrency`.
347    //
348    // Cursor-driven phase (`rows_total > 0`): the cursor advances in
349    // STRIDES — one op consumes N ordinals — so an op-denominated
350    // `cycles:{ops}/{extent}` reads ~N× low against the row-denominated
351    // extent. Show the authoritative ordinal progress instead,
352    // `rows:{consumed}/{extent}`, plus a rows/s rate (consumed / elapsed,
353    // the same shape as the throughput rate line) so the fraction and
354    // the rate are both row-denominated and agree.
355    //
356    // Non-cursor phase (`rows_total == 0`, plain `cycles:`): keep the
357    // `cycles:N/T` chip — a glance shows both the running cycle count
358    // and the total extent the phase is bounded by. With no extent
359    // (unbounded sources, when those exist), we elide the `/T` half and
360    // show just the running counter.
361    // Open-ended phase: neither a rows target nor a cycle extent is
362    // a real completion bound — show just the running counter.
363    let rows_total = ctx.rows_total();
364    let cycles_chip = if ctx.open_ended() {
365        if ops_completed > 0 {
366            format!(" {dim}cycles:{ops_completed}{reset}")
367        } else {
368            String::new()
369        }
370    } else if rows_total > 0 {
371        let rows_consumed = ctx.rows_consumed();
372        let rows_rate = if elapsed > 0.0 {
373            rows_consumed as f64 / elapsed
374        } else {
375            0.0
376        };
377        format!(
378            " {dim}rows:{rows_consumed}/{rows_total} {}{reset}",
379            format_rate(rows_rate)
380        )
381    } else if total_extent > 0 {
382        format!(" {dim}cycles:{ops_completed}/{total_extent}{reset}")
383    } else if ops_completed > 0 {
384        format!(" {dim}cycles:{ops_completed}{reset}")
385    } else {
386        String::new()
387    };
388    // SRD-? — full coord stack on the ACTIVE phase line
389    // (`summarize_changed_only=false`). The same helper
390    // `phase_outcome` uses for completed phases, called with
391    // the inverse flag. Wrap-aware: when the head + coord
392    // chain exceeds terminal width, strata fold onto
393    // continuation lines indented to match the second-row
394    // counters line (`depth_indent + "    "`).
395    //
396    // Head-consumed accounting for wrap budget:
397    //   depth_indent (variable) + spinner (1) + " " (1)
398    //   + bar (variable visible width)
399    //   + " " (1) + seq_prefix (visible chars when set)
400    //   + activity_name length
401    let labels = ctx.subject_labels();
402    // Bar is no longer inline in line 1 — moved to the
403    // row-2 margin built by the sink renderer.
404    let bar_visible: usize = 0;
405    let seq_visible: usize = match ctx.subject_seq() {
406        Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
407        None => 0,
408    };
409    let head_consumed: usize =
410        depth_indent.chars().count() + bar_visible + seq_visible + activity_name.chars().count();
411    let continuation_indent = format!("{depth_indent}    ");
412    let coords_part = super::phase_outcome::format_coords_block(
413        labels,
414        color,
415        head_consumed,
416        &continuation_indent,
417        /* summarize_changed_only */ false,
418    );
419    // Third row — the KEY-METRICS line. The domain metrics an operator
420    // actually watches (adapter throughput chips like rows/s, the derived
421    // rows/batch chip, and the workload's emphasised `status_metrics:` chips
422    // such as recall) get their OWN indented line below the operational
423    // counters, and ONLY when at least one is present. Packed onto the
424    // counters row, a busy phase (CQL batch load + recall) overran the
425    // terminal width and wrapped mid-chip; a dedicated line keeps each row
426    // scannable. Chips each carry a leading space, so the row is trimmed once
427    // and re-indented to align under the counters row above it.
428    let key_metrics = format!("{adapter_status}{batch_info}{chips}");
429    let key_line = if key_metrics.trim().is_empty() {
430        String::new()
431    } else {
432        // Natural color — `status_metrics:` chips are opt-in EMPHASIS metrics
433        // (recall, …), so this row is NOT dimmed like the counters row.
434        format!("\n{depth_indent}    {}", key_metrics.trim_start())
435    };
436    let mut tmp = String::with_capacity(320);
437    let _ = write!(
438        &mut tmp,
439        "{depth_indent}{cyan}{spinner}{reset}{bar} {seq_prefix}{bold}{blue}{activity_name}{reset}{coords_part} {meter}\n\
440{memo_row}\
441{depth_indent}    {dim}{rate_str}{reset} {ok_tone}ok:{ok_str}{reset} \
442{att_tone}att:{att_pct:.0}%{reset}{skips_chip} \
443{err_tone}e:{errors} r:{retries}{reset} {dim}c:{concurrency}{reset}{cycles_chip}{eta}\
444{key_line}",
445        meter = meter_slot(ctx, frac),
446    );
447    let len = tmp.len();
448    let _ = out.write_str(&tmp);
449    len
450}
451
452/// Expanded LOD: each field on its own line. Block-rendered
453/// (the binder's layout classification picks `Block` for
454/// expanded automatically). SRD-63 §3.3 monotonicity:
455/// every field present at Labeled is present here too;
456/// new fields are the explicit per-aggregate breakdowns.
457fn render_expanded(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
458    let color = ctx.use_color();
459    let dim = if color { "\x1b[2m" } else { "" };
460    let cyan = if color { "\x1b[36m" } else { "" };
461    let reset = if color { "\x1b[0m" } else { "" };
462
463    let total_extent = ctx.cycles_total();
464    let _started = ctx.ops_started();
465    let finished = ctx.ops_finished();
466    let ops_completed = ctx.cycles_completed();
467    let successes = ctx.ops_ok();
468    let skips = ctx.skips();
469    let errors = ctx.errors();
470    let retries = ctx.retries();
471    let attempt_ok = ctx.attempt_ok();
472    let attempt_failed = ctx.attempt_failed();
473    let elapsed = ctx.elapsed_secs();
474    let concurrency = ctx.concurrency();
475
476    // See `render_labeled` — pct must use completed cycles so
477    // dispatched-but-not-yet-returned ops don't pin the bar
478    // at 100% during long synchronous waits; a derived-progress
479    // override (measured completion) wins over both.
480    // Single fraction source (override → rows → cycles); `None` for
481    // open-ended subjects, whose meter slot renders latency instead.
482    let frac = ctx.progress_fraction();
483    let pct: f64 = frac.map(|f| f * 100.0).unwrap_or(0.0);
484    // ok% over RESOLVED ops only (`cycles_completed - skips ==
485    // result_total`) — a skip is neither a success nor a failure,
486    // so it must not dilute the rate. Matches `render_labeled`.
487    // `.max(successes)`: cycles_completed and result_success are read
488    // non-atomically and bumped in the reverse order (cycles first), so a
489    // completing op can make this dip below `successes` momentarily —
490    // result_total is never truly < successes. Clamp up so ok% stays <= 100%.
491    let result_total = ops_completed.saturating_sub(skips).max(successes);
492    let ok_pct: f64 = if result_total > 0 {
493        successes as f64 * 100.0 / result_total as f64
494    } else {
495        100.0
496    };
497    // Attempt success rate over resolved attempts — see
498    // `render_labeled`.
499    let attempt_resolved = attempt_ok + attempt_failed;
500    let att_pct: f64 = if attempt_resolved > 0 {
501        attempt_ok as f64 * 100.0 / attempt_resolved as f64
502    } else {
503        100.0
504    };
505    let rate: f64 = if elapsed > 0.0 {
506        finished as f64 / elapsed
507    } else {
508        0.0
509    };
510    let rate_str = format_rate(rate);
511    let bar = if total_extent > 0 {
512        braille_bar(pct, 20)
513    } else {
514        String::new()
515    };
516    // Push 9f: prefer `ctx.eta_secs()`; fall back to the
517    // inline derivation when the context doesn't supply one.
518    let eta = match ctx.eta_secs() {
519        Some(secs) => format!("ETA {}", format_eta(secs)),
520        None if total_extent > 0 && rate > 0.0 => {
521            let remaining = total_extent.saturating_sub(finished) as f64;
522            format!("ETA {}", format_eta(remaining / rate))
523        }
524        None => String::from("ETA —"),
525    };
526
527    let activity_name = ctx.activity_name();
528    let chips = ctx.status_metric_chips();
529    let adapter_status = ctx.adapter_counters_text();
530    let batch_info = ctx.batch_info_text();
531    // Margin owns [n/N]; body omits it (single-placement rule).
532    let seq_prefix: String = String::new();
533
534    let mut tmp = String::with_capacity(384);
535    let _ = write!(
536        &mut tmp,
537        "{cyan}{spinner}{reset} {seq_prefix}{activity_name}\n  \
538         progress:   {meter} {dim}{bar}{reset}  {eta}\n  \
539         throughput: {rate_str}  ok:{ok_pct:.0}%  att:{att_pct:.0}%\n  \
540         counters:   e:{errors} r:{retries} c:{concurrency}",
541        meter = meter_slot(ctx, frac),
542        spinner = spinner_frame(ctx.refresh_tick()),
543    );
544    if !adapter_status.is_empty() {
545        let _ = write!(&mut tmp, "\n  adapter:   {adapter_status}");
546    }
547    if !batch_info.is_empty() {
548        let _ = write!(&mut tmp, "\n  batch:     {batch_info}");
549    }
550    if !chips.is_empty() {
551        let _ = write!(&mut tmp, "\n  metrics:   {chips}");
552    }
553    let len = tmp.len();
554    let _ = out.write_str(&tmp);
555    len
556}
557
558/// Compact LOD: a stripped-down one-token cluster. Per
559/// SRD-63 §3.3's monotonicity invariant this is a strict
560/// subset of `Labeled`. Used by the TUI tree row at the
561/// default LOD setting (see Push 5).
562fn render_compact(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
563    let finished = ctx.ops_finished();
564    let elapsed = ctx.elapsed_secs();
565    // Pct from completed cycles — see `render_labeled`; a
566    // derived-progress override (measured completion) wins.
567    // Single fraction source (override → rows → cycles); `None` for
568    // open-ended subjects, whose meter slot renders latency instead.
569    let frac = ctx.progress_fraction();
570    let _pct: f64 = frac.map(|f| f * 100.0).unwrap_or(0.0);
571    let rate: f64 = if elapsed > 0.0 {
572        finished as f64 / elapsed
573    } else {
574        0.0
575    };
576    let mut tmp = String::with_capacity(32);
577    let _ = write!(
578        &mut tmp,
579        "{spin} {meter} {rate}",
580        meter = meter_slot(ctx, frac),
581        spin = spinner_frame(ctx.refresh_tick()),
582        rate = format_rate(rate),
583    );
584    let len = tmp.len();
585    let _ = out.write_str(&tmp);
586    len
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::lifecycle::EventType;
593    use crate::readouts::buf::StringBuf;
594
595    #[derive(Default)]
596    struct TestCtx {
597        phase_name: String,
598        activity_name: String,
599        phase_seq: Option<(usize, usize)>,
600        cycles_completed: u64,
601        cycles_total: u64,
602        ops_started: u64,
603        ops_finished: u64,
604        ops_ok: u64,
605        skips: u64,
606        errors: u64,
607        retries: u64,
608        attempt_ok: u64,
609        attempt_failed: u64,
610        concurrency: usize,
611        elapsed_secs: f64,
612        consumed: u64,
613        rows_consumed: u64,
614        rows_total: u64,
615        chips: String,
616        adapter: String,
617        batch: String,
618        depth_indent: String,
619        refresh_tick: u64,
620        use_color: bool,
621    }
622
623    impl ReadoutContext for TestCtx {
624        fn subject_name(&self) -> &str {
625            &self.phase_name
626        }
627        fn activity_name(&self) -> &str {
628            if self.activity_name.is_empty() {
629                &self.phase_name
630            } else {
631                &self.activity_name
632            }
633        }
634        fn subject_seq(&self) -> Option<(usize, usize)> {
635            self.phase_seq
636        }
637        fn subject_labels(&self) -> &str {
638            ""
639        }
640        fn cycles_completed(&self) -> u64 {
641            self.cycles_completed
642        }
643        fn cycles_total(&self) -> u64 {
644            self.cycles_total
645        }
646        fn ops_started(&self) -> u64 {
647            self.ops_started
648        }
649        fn ops_finished(&self) -> u64 {
650            self.ops_finished
651        }
652        fn ops_ok(&self) -> u64 {
653            self.ops_ok
654        }
655        fn skips(&self) -> u64 {
656            self.skips
657        }
658        fn errors(&self) -> u64 {
659            self.errors
660        }
661        fn retries(&self) -> u64 {
662            self.retries
663        }
664        fn attempt_ok(&self) -> u64 {
665            self.attempt_ok
666        }
667        fn attempt_failed(&self) -> u64 {
668            self.attempt_failed
669        }
670        fn concurrency(&self) -> usize {
671            self.concurrency
672        }
673        fn elapsed_secs(&self) -> f64 {
674            self.elapsed_secs
675        }
676        fn consumed(&self) -> u64 {
677            self.consumed
678        }
679        fn rows_consumed(&self) -> u64 {
680            self.rows_consumed
681        }
682        fn rows_total(&self) -> u64 {
683            self.rows_total
684        }
685        fn status_metric_chips(&self) -> String {
686            self.chips.clone()
687        }
688        fn adapter_counters_text(&self) -> String {
689            self.adapter.clone()
690        }
691        fn batch_info_text(&self) -> String {
692            self.batch.clone()
693        }
694        fn depth_indent(&self) -> &str {
695            &self.depth_indent
696        }
697        fn use_color(&self) -> bool {
698            self.use_color
699        }
700        fn event(&self) -> EventType {
701            EventType::Update
702        }
703        fn refresh_tick(&self) -> u64 {
704            self.refresh_tick
705        }
706    }
707
708    fn render(ctx: &TestCtx, lod: Lod) -> String {
709        let mut s = String::new();
710        let mut buf = StringBuf::new(&mut s);
711        PhaseStatus.render(
712            ctx,
713            lod,
714            ContentMode::Value,
715            &ReadoutOptions::new(),
716            &mut buf,
717        );
718        s
719    }
720
721    #[test]
722    fn labeled_no_color_minimal() {
723        let ctx = TestCtx {
724            phase_name: "run".into(),
725            activity_name: "run".into(),
726            phase_seq: Some((1, 1)),
727            cycles_completed: 50,
728            cycles_total: 100,
729            ops_started: 50,
730            ops_finished: 50,
731            ops_ok: 50,
732            errors: 0,
733            retries: 0,
734            attempt_ok: 50,
735            attempt_failed: 0,
736            concurrency: 1,
737            elapsed_secs: 1.0,
738            consumed: 50,
739            refresh_tick: 0,
740            ..Default::default()
741        };
742        let out = render(&ctx, Lod::Labeled);
743        // The spinner and 10-glyph progress bar moved OUT of
744        // this row — the sink renderer builds them as the
745        // row-2 margin replacement so the readout body is
746        // just `<name> <coord> <pct>%` on row 1 and
747        // `<rate> ok:.. e:.. r:.. c:.. cycles:..` on row 2.
748        assert!(
749            !out.contains("⠋"),
750            "spinner MUST NOT appear in phase_status body: {out}"
751        );
752        // Two-line layout: head ends with " 50%\n",
753        // tail begins with the indented counters.
754        assert!(
755            out.contains(" 50%\n"),
756            "two-line break after pct missing: {out:?}"
757        );
758        assert!(
759            out.contains("50/s ok:100% att:100% e:0 r:0 c:1"),
760            "labeled body wrong: {out:?}"
761        );
762        // Time chip is ETA-ONLY (single-placement rule: elapsed is
763        // the margin leaf slot's datum). remaining=cycles_total/rate
764        // = 50/50 = 1s.
765        assert!(
766            out.contains("(~1s left of ~2s)"),
767            "ETA chip missing for finite-rate phase: {out:?}"
768        );
769        assert!(
770            !out.contains("(1s/1s)"),
771            "elapsed must not be re-emitted in the body: {out:?}"
772        );
773    }
774
775    #[test]
776    fn cursor_phase_renders_rows_chip_not_cycles() {
777        // A DATA-DRIVEN phase (rows_total > 0) consumes its cursor in
778        // strides, so the progress chip must be row-denominated:
779        // `rows:{consumed}/{extent}` plus a rows/s rate — NOT the
780        // op-denominated `cycles:` chip which would read N× low.
781        // Here 7 ops @ stride 100 = 700 ordinals of a 1000-row cursor.
782        let ctx = TestCtx {
783            phase_name: "ann".into(),
784            activity_name: "ann".into(),
785            phase_seq: Some((1, 1)),
786            cycles_completed: 7,
787            cycles_total: 1000,
788            ops_started: 7,
789            ops_finished: 7,
790            ops_ok: 7,
791            attempt_ok: 7,
792            concurrency: 1,
793            elapsed_secs: 1.0,
794            consumed: 7,
795            rows_consumed: 700,
796            rows_total: 1000,
797            ..Default::default()
798        };
799        let out = render(&ctx, Lod::Labeled);
800        assert!(
801            out.contains("rows:700/1000"),
802            "cursor phase must show row-denominated progress: {out:?}"
803        );
804        // rows/s = consumed/elapsed = 700/1.0, format_rate → "700/s".
805        assert!(
806            out.contains("rows:700/1000 700/s"),
807            "cursor phase must show a rows/s rate beside the fraction: {out:?}"
808        );
809        // The op-denominated cycles chip must be GONE for cursor phases.
810        assert!(
811            !out.contains("cycles:"),
812            "cursor phase must NOT emit the cycles: chip: {out:?}"
813        );
814    }
815
816    #[test]
817    fn non_cursor_phase_keeps_cycles_chip() {
818        // A plain `cycles:` phase (rows_total == 0) has no declared
819        // cursor — ops advance the cycle counter one-for-one — so it
820        // keeps the op-denominated `cycles:{completed}/{total}` chip
821        // and emits no `rows:` chip. Byte-identical to pre-change output.
822        let ctx = TestCtx {
823            phase_name: "run".into(),
824            activity_name: "run".into(),
825            phase_seq: Some((1, 1)),
826            cycles_completed: 50,
827            cycles_total: 100,
828            ops_started: 50,
829            ops_finished: 50,
830            ops_ok: 50,
831            attempt_ok: 50,
832            concurrency: 1,
833            elapsed_secs: 1.0,
834            consumed: 50,
835            // rows_consumed / rows_total default to 0 → non-cursor.
836            ..Default::default()
837        };
838        let out = render(&ctx, Lod::Labeled);
839        assert!(
840            out.contains("cycles:50/100"),
841            "non-cursor phase must keep the cycles: chip: {out:?}"
842        );
843        assert!(
844            !out.contains("rows:"),
845            "non-cursor phase must NOT emit a rows: chip: {out:?}"
846        );
847    }
848
849    #[test]
850    fn skips_excluded_from_ok_rate() {
851        // 3 ops completed: 2 succeeded, 1 was an `if:`-gated SKIP with
852        // NO error. ok% must read 100% (2 of 2 result-producing ops),
853        // NOT 67% (2 of 3) — a skip is neither a success nor a failure,
854        // so it must not sit in the ok% denominator.
855        let ctx = TestCtx {
856            phase_name: "run".into(),
857            cycles_completed: 3,
858            cycles_total: 3,
859            ops_started: 3,
860            ops_finished: 3,
861            ops_ok: 2,
862            skips: 1,
863            concurrency: 1,
864            elapsed_secs: 1.0,
865            consumed: 3,
866            ..Default::default()
867        };
868        let out = render(&ctx, Lod::Labeled);
869        assert!(
870            out.contains("ok:100%"),
871            "a skip (0 errors) must not drag ok% below 100%: {out:?}"
872        );
873        assert!(
874            !out.contains("ok:67%"),
875            "ok% wrongly counts the skip in its denominator: {out:?}"
876        );
877    }
878
879    #[test]
880    fn attempt_success_rate_diverges_from_ok_under_retry() {
881        // SRD-91: results all land green (ok:100%) but 50 of the
882        // 150 resolved attempts failed and were retried —
883        // attempt success is 100/(100+50) = 67%. The status line
884        // must surface BOTH: `ok:100% att:67%` is the
885        // retry-pressure tell.
886        let ctx = TestCtx {
887            phase_name: "run".into(),
888            activity_name: "run".into(),
889            phase_seq: Some((1, 1)),
890            cycles_completed: 100,
891            cycles_total: 100,
892            ops_started: 100,
893            ops_finished: 100,
894            ops_ok: 100,
895            errors: 50,
896            retries: 50,
897            attempt_ok: 100,
898            attempt_failed: 50,
899            concurrency: 4,
900            elapsed_secs: 1.0,
901            consumed: 100,
902            ..Default::default()
903        };
904        let out = render(&ctx, Lod::Labeled);
905        assert!(
906            out.contains("ok:100% att:67%"),
907            "attempt success rate must sit beside ok%, diverging under retry: {out:?}"
908        );
909        // Same divergence must show at the Expanded LOD
910        // (monotonicity — the field can't vanish when detail
911        // grows).
912        let expanded = render(&ctx, Lod::Expanded);
913        assert!(
914            expanded.contains("att:67%"),
915            "expanded throughput row missing attempt rate: {expanded:?}"
916        );
917    }
918
919    #[test]
920    fn memo_header_renders_above_status_when_non_empty() {
921        // Memo wrapper publishes "compacting tableX"; the
922        // status readout must surface it as
923        // `[[ compacting tableX ]]` on its own line above the
924        // regular two-line body. Empty memo (default) renders
925        // nothing extra (the other tests guard that path).
926        struct MemoCtx;
927        impl ReadoutContext for MemoCtx {
928            fn subject_name(&self) -> &str {
929                "x"
930            }
931            fn activity_name(&self) -> &str {
932                "x"
933            }
934            fn subject_seq(&self) -> Option<(usize, usize)> {
935                None
936            }
937            fn subject_labels(&self) -> &str {
938                ""
939            }
940            fn cycles_completed(&self) -> u64 {
941                1
942            }
943            fn cycles_total(&self) -> u64 {
944                1
945            }
946            fn ops_started(&self) -> u64 {
947                1
948            }
949            fn ops_finished(&self) -> u64 {
950                1
951            }
952            fn ops_ok(&self) -> u64 {
953                1
954            }
955            fn errors(&self) -> u64 {
956                0
957            }
958            fn retries(&self) -> u64 {
959                0
960            }
961            fn concurrency(&self) -> usize {
962                1
963            }
964            fn elapsed_secs(&self) -> f64 {
965                1.0
966            }
967            fn consumed(&self) -> u64 {
968                1
969            }
970            fn status_metric_chips(&self) -> String {
971                String::new()
972            }
973            fn depth_indent(&self) -> &str {
974                ""
975            }
976            fn use_color(&self) -> bool {
977                false
978            }
979            fn event(&self) -> EventType {
980                EventType::Update
981            }
982            fn refresh_tick(&self) -> u64 {
983                0
984            }
985            fn phase_memo(&self) -> &str {
986                "compacting tableX"
987            }
988        }
989        let ctx = MemoCtx;
990        let mut s = String::new();
991        let mut buf = StringBuf::new(&mut s);
992        PhaseStatus.render(
993            &ctx,
994            Lod::Labeled,
995            ContentMode::Value,
996            &ReadoutOptions::new(),
997            &mut buf,
998        );
999        // SRD-92: blocks compose HEADER-FIRST — the memo is a
1000        // detail row directly under the header, not a banner above.
1001        let lines: Vec<&str> = s.lines().collect();
1002        assert!(
1003            lines[0].contains("x") && lines[0].contains("100%"),
1004            "header row must lead the output, got: {s:?}"
1005        );
1006        assert_eq!(
1007            lines[1].trim_start(),
1008            "[[ compacting tableX ]]",
1009            "memo must be the first detail row under the header: {s:?}"
1010        );
1011        // Counters row still present below the memo.
1012        assert!(
1013            lines[2].contains("ok:"),
1014            "regular counters row missing: {s:?}"
1015        );
1016    }
1017
1018    #[test]
1019    fn labeled_no_eta_when_no_extent() {
1020        // When cycles_total=0 there's no `/ETA` half of the
1021        // span; the time pair degenerates to elapsed-only —
1022        // `(0s)` here. The slash MUST NOT appear in this
1023        // branch. Bypass the "not-yet-ready" guard with
1024        // ops_started > 0 + measurable elapsed so the full
1025        // labeled render fires.
1026        let ctx = TestCtx {
1027            phase_name: "x".into(),
1028            activity_name: "x".into(),
1029            cycles_total: 0,
1030            ops_started: 1,
1031            elapsed_secs: 0.5,
1032            ..Default::default()
1033        };
1034        let out = render(&ctx, Lod::Labeled);
1035        // No extent and no override → no ETA is computable, and
1036        // elapsed belongs to the margin — so the body carries NO
1037        // time chip at all (single-placement rule).
1038        assert!(
1039            !out.contains(" left)") && !out.contains("(0s") && !out.contains("(1s"),
1040            "no time chip expected when ETA is not computable: {out}"
1041        );
1042    }
1043
1044    #[test]
1045    fn labeled_chips_and_adapter_and_batch() {
1046        let ctx = TestCtx {
1047            phase_name: "run".into(),
1048            activity_name: "run".into(),
1049            phase_seq: Some((1, 1)),
1050            cycles_completed: 100,
1051            cycles_total: 100,
1052            ops_started: 100,
1053            ops_finished: 100,
1054            ops_ok: 100,
1055            concurrency: 4,
1056            elapsed_secs: 1.0,
1057            consumed: 100,
1058            chips: " recall_at_10:80.00%".into(),
1059            adapter: " rows/s=12.5K".into(),
1060            batch: " r/b=12.5".into(),
1061            ..Default::default()
1062        };
1063        let out = render(&ctx, Lod::Labeled);
1064        // Ordering preserved: adapter → batch → chips.
1065        assert!(
1066            out.contains("rows/s=12.5K r/b=12.5 recall_at_10:80.00%"),
1067            "adapter / batch / chips ordering wrong: {out}"
1068        );
1069        // …but on their OWN indented line below the counters row, not packed
1070        // onto it (which overran the width and wrapped on a busy phase).
1071        assert!(
1072            out.contains("\n    rows/s=12.5K r/b=12.5 recall_at_10:80.00%"),
1073            "key metrics should sit on a dedicated indented line: {out:?}"
1074        );
1075        // The counters row (e:/r:/c:) stays a separate line ABOVE the key row.
1076        let counters_line_idx = out.find("c:4").expect("counters row present");
1077        let key_line_idx = out.find("rows/s=12.5K").expect("key row present");
1078        assert!(
1079            counters_line_idx < key_line_idx,
1080            "counters row must precede the key-metrics row: {out:?}"
1081        );
1082    }
1083
1084    #[test]
1085    fn labeled_omits_key_line_when_no_key_metrics() {
1086        // A plain phase (no adapter chips, no batch info, no status_metrics)
1087        // must NOT grow a third line — the key-metrics row is conditional.
1088        let ctx = TestCtx {
1089            phase_name: "run".into(),
1090            activity_name: "run".into(),
1091            cycles_completed: 10,
1092            cycles_total: 10,
1093            ops_started: 10,
1094            ops_finished: 10,
1095            ops_ok: 10,
1096            concurrency: 4,
1097            elapsed_secs: 1.0,
1098            consumed: 10,
1099            ..Default::default()
1100        };
1101        let out = render(&ctx, Lod::Labeled);
1102        // Exactly two lines: header + counters (one embedded '\n').
1103        assert_eq!(
1104            out.matches('\n').count(),
1105            1,
1106            "plain phase must stay two lines (no empty key row): {out:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn compact_is_short_and_starts_with_spinner() {
1112        // Pct is driven by cycles_completed (completed cycles),
1113        // NOT ops_started — dispatched-but-not-returned ops
1114        // don't count toward the displayed percentage.
1115        let ctx = TestCtx {
1116            phase_name: "x".into(),
1117            cycles_total: 10,
1118            cycles_completed: 5,
1119            ops_started: 5,
1120            ops_finished: 5,
1121            elapsed_secs: 1.0,
1122            ..Default::default()
1123        };
1124        let out = render(&ctx, Lod::Compact);
1125        assert!(out.starts_with("⠋"), "compact missing spinner: {out}");
1126        assert_eq!(out, "⠋ 50% 5/s");
1127    }
1128
1129    #[test]
1130    fn pct_uses_completed_not_started() {
1131        // Regression guard for the off-by-one bug: a single
1132        // long-running op is dispatched (ops_started=1) but
1133        // hasn't returned yet (ops_finished=0,
1134        // cycles_completed=0). Pct must read 0%, not 100%.
1135        let ctx = TestCtx {
1136            phase_name: "x".into(),
1137            cycles_total: 1,
1138            cycles_completed: 0,
1139            ops_started: 1,
1140            ops_finished: 0,
1141            elapsed_secs: 5.0,
1142            ..Default::default()
1143        };
1144        let labeled = render(&ctx, Lod::Labeled);
1145        assert!(
1146            labeled.contains(" 0%\n"),
1147            "in-flight op should read 0%, not 100%: {labeled:?}"
1148        );
1149        let compact = render(&ctx, Lod::Compact);
1150        assert!(
1151            compact.contains(" 0% "),
1152            "compact in-flight should also read 0%: {compact:?}"
1153        );
1154    }
1155
1156    #[test]
1157    fn explanation_mode_renders_descriptors_at_every_lod() {
1158        // SRD-63 §3.2 / Push 7: Explanation overlay has a
1159        // descriptor for every LOD. Width-parity with the
1160        // value render is the author's contract.
1161        let ctx = TestCtx {
1162            phase_name: "x".into(),
1163            cycles_total: 100,
1164            ops_started: 50,
1165            ops_finished: 50,
1166            elapsed_secs: 1.0,
1167            ..Default::default()
1168        };
1169        for lod in [Lod::Compact, Lod::Labeled, Lod::Expanded] {
1170            let mut s = String::new();
1171            let mut buf = StringBuf::new(&mut s);
1172            let n = PhaseStatus.render(
1173                &ctx,
1174                lod,
1175                ContentMode::Explanation,
1176                &ReadoutOptions::new(),
1177                &mut buf,
1178            );
1179            assert!(n > 0, "{lod:?}/Explanation should render");
1180            assert!(
1181                s.contains("progress"),
1182                "{lod:?}/Explanation missing 'progress' descriptor: {s}"
1183            );
1184        }
1185    }
1186
1187    #[test]
1188    fn expanded_renders_multi_line_block() {
1189        let ctx = TestCtx {
1190            phase_name: "run".into(),
1191            activity_name: "run".into(),
1192            phase_seq: Some((1, 1)),
1193            cycles_completed: 100,
1194            cycles_total: 200,
1195            ops_started: 100,
1196            ops_finished: 100,
1197            ops_ok: 100,
1198            concurrency: 4,
1199            elapsed_secs: 1.0,
1200            consumed: 100,
1201            chips: " recall_at_10:80.00%".into(),
1202            adapter: " rows/s=12.5K".into(),
1203            ..Default::default()
1204        };
1205        let out = render(&ctx, Lod::Expanded);
1206        // Expanded renders multi-line: progress, throughput,
1207        // counters at minimum. Adapter / metrics tails when
1208        // present.
1209        assert!(
1210            out.contains("progress:"),
1211            "expanded missing 'progress:': {out}"
1212        );
1213        assert!(
1214            out.contains("throughput:"),
1215            "expanded missing 'throughput:': {out}"
1216        );
1217        assert!(
1218            out.contains("counters:"),
1219            "expanded missing 'counters:': {out}"
1220        );
1221        assert!(
1222            out.contains("adapter:"),
1223            "expanded missing 'adapter:' tail: {out}"
1224        );
1225        assert!(
1226            out.contains("metrics:"),
1227            "expanded missing 'metrics:' tail: {out}"
1228        );
1229        assert!(
1230            out.lines().count() >= 5,
1231            "expanded should be multi-line: {out}"
1232        );
1233    }
1234
1235    #[test]
1236    fn refresh_tick_advances_spinner_frame() {
1237        let mut ctx = TestCtx {
1238            phase_name: "x".into(),
1239            cycles_total: 10,
1240            ops_started: 1,
1241            ops_finished: 1,
1242            elapsed_secs: 1.0,
1243            ..Default::default()
1244        };
1245        let mut frames = std::collections::HashSet::new();
1246        for tick in 0..10 {
1247            ctx.refresh_tick = tick;
1248            let out = render(&ctx, Lod::Compact);
1249            // First non-empty char is the spinner.
1250            let first = out.chars().next().unwrap();
1251            frames.insert(first);
1252        }
1253        assert_eq!(frames.len(), 10, "spinner cycle not 10 distinct frames");
1254    }
1255}