Skip to main content

nmbrs_runtime/readouts/builtins/
phase_outcome.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `phase_outcome` — the phase-end summary line.
5//!
6//! SRD-76 — single canonical phase-end readout. Renders
7//! status / duration / error list / resume cursor from the
8//! structured outcome the executor installs on the scene
9//! tree. Replaces the legacy `phase_outcome` readout that
10//! always rendered as success.
11//!
12//! Push 1 byte-equivalence target: the line emitted by
13//! `nmbrs-runtime::activity`'s end-of-activity block prior
14//! to this push. The format string was:
15//!
16//! ```text
17//! {depth_indent}{green}✓{reset} {seq_prefix}{bold}{blue}[{phase_name}]{reset}{coords_part} \
18//!  {pct:.0}% {rate_str} ok:{ok_pct:.0}% \
19//!  {err_color}e:{errors} r:{retries}{reset} c:{concurrency}{relevancy_str} \
20//!  {dim}({elapsed:.2}s){reset}
21//! ```
22//!
23//! Where:
24//! - `seq_prefix` = `{dim}[{idx}/{total}]{reset} ` if seq is
25//!   `Some`, else `""`.
26//! - `coords_part` = ` {bold}{yellow}{labels}{reset}` if
27//!   labels are non-empty, else `""`.
28//! - `err_color` = yellow when errors or retries > 0, else dim.
29//! - `rate_str` = auto-scaled (M/s | K/s | /s) per the
30//!   helper logic in the prior implementation.
31//!
32//! That string is the canonical render at
33//! `Lod::Labeled, ContentMode::Value`. Compact and Expanded
34//! ship in Push 9g (closes G17) per SRD-63 §3.3
35//! monotonicity:
36//!
37//! - **Compact** — `{depth}✓ [name] {pct}% ({elapsed:.2}s)`
38//!   — trained-operator scan form: status glyph + identity
39//!   + completion percentage + wallclock. Drops the seq
40//!     prefix, rate, ok-pct, error / retry / concurrency
41//!     counts, scope coords, and chip tail. Every retained
42//!     field also appears in Labeled (monotonicity).
43//! - **Expanded** — multi-line labelled block. Same data
44//!   as Labeled but split across lines with a label per
45//!   field, and the chip stream broken into one chip per
46//!   line. Adds nothing new (monotonicity flows the
47//!   other way too: every Labeled field is in Expanded).
48//!
49//! Explanation overlay is per SRD-63 §3.2: same shape as
50//! the value at the same LOD, with field labels swapped
51//! for descriptors.
52
53use std::fmt::Write as _;
54
55use crate::lifecycle::SubjectKind;
56use crate::readouts::buf::ReadoutBuf;
57use crate::readouts::context::ReadoutContext;
58use crate::readouts::format::ballot_bar;
59use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
60
61/// Process-global record of the last-rendered phase-coords
62/// string. Read at render time to diff against the current
63/// phase's coords (changed values render highlighted), then
64/// overwritten with the new string. Lock contention is
65/// negligible because realtime status emission is serial:
66/// the executor's per-phase done line fires once per phase
67/// activation, not concurrently across fibers.
68///
69/// Thread-safety is via Mutex (not RwLock) because the
70/// contention pattern is exactly one writer per render —
71/// no concurrent readers without writers. A Mutex is
72/// simpler and matches the once-per-phase write cadence.
73static LAST_RENDERED_COORDS: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
74
75/// One stratum of scope coordinates — a single
76/// `(k1=v1, k2=v2, ...)` group as produced by
77/// [`polydat::kernel::format_scope_coordinate_path`].
78/// Pairs are owned `String`s so the diff can operate on
79/// stable references without lifetime gymnastics.
80struct Stratum {
81    pairs: Vec<(String, String)>,
82}
83
84/// Parse the canonical striated-parens form back into the
85/// structured per-stratum + per-pair shape. The formatter at
86/// [`polydat::kernel::format_scope_coordinate_path`]
87/// is the inverse — the round trip is exact for the values
88/// that fit the formatter's grammar (no embedded `, ` or
89/// `)`, ` (` substrings inside a value).
90///
91/// Returns an empty Vec when the input is empty. Malformed
92/// input degrades gracefully: each unparseable stratum is
93/// recorded as a single `(_=raw)` pair so the wrap-aware
94/// renderer still has something to print.
95fn parse_strata(labels: &str) -> Vec<Stratum> {
96    if labels.is_empty() {
97        return Vec::new();
98    }
99    // Split on the canonical `), (` separator. Trim the
100    // leading `(` and trailing `)` from the first / last
101    // chunks respectively.
102    let mut strata = Vec::new();
103    for raw in labels.split("), (") {
104        let inner = raw.trim_start_matches('(').trim_end_matches(')');
105        let mut pairs = Vec::new();
106        for kv in inner.split(", ") {
107            match kv.split_once('=') {
108                Some((k, v)) => pairs.push((k.to_string(), v.to_string())),
109                None => pairs.push(("_".into(), kv.to_string())),
110            }
111        }
112        strata.push(Stratum { pairs });
113    }
114    strata
115}
116
117/// Render the parsed strata back into ANSI-styled text with:
118/// 1. **Wrap-aware folding**: emit one stratum per line if
119///    the joined single-line form would exceed
120///    `available_width`, with continuation lines indented by
121///    `continuation_indent`.
122/// 2. **Change highlight**: any `key=value` pair whose value
123///    differs from the same `(stratum_idx, key)` in
124///    `prev_strata` renders the value in a different colour
125///    so the changed axis pops out visually at a glance.
126///
127/// Coloration palette:
128/// - Surrounding `(`/`)` and `key=` text: bold yellow (the
129///   prior render's base style for the whole coords block).
130/// - Unchanged values: bold yellow (same as the key,
131///   blends in).
132/// - Changed values: bold magenta (distinct from the
133///   yellow base, doesn't clash with the green ✓ or blue
134///   `[name]` of the surrounding line).
135/// - `, ` separators within a stratum: bold yellow.
136/// - `, ` separator between strata: dim (less visually
137///   noisy when there are many strata).
138fn render_strata(
139    strata: &[Stratum],
140    prev_strata: &[Stratum],
141    color: bool,
142    head_consumed: usize,
143    available_width: usize,
144    continuation_indent: &str,
145) -> String {
146    if strata.is_empty() {
147        return String::new();
148    }
149    let bold = if color { "\x1b[1m" } else { "" };
150    let dim = if color { "\x1b[2m" } else { "" };
151    let yellow = if color { "\x1b[33m" } else { "" };
152    let magenta = if color { "\x1b[35m" } else { "" };
153    let reset = if color { "\x1b[0m" } else { "" };
154
155    // Render each stratum twice: once styled (for emission)
156    // and once plain (for visible-width measurement). The
157    // visible widths drive wrap decisions; the styled forms
158    // drive what reaches the terminal.
159    let mut rendered: Vec<(String, usize)> = Vec::with_capacity(strata.len());
160    for (idx, s) in strata.iter().enumerate() {
161        let prior_pairs: Option<&Vec<(String, String)>> = prev_strata.get(idx).map(|p| &p.pairs);
162        let mut styled = String::with_capacity(64);
163        let mut plain = String::with_capacity(64);
164        styled.push_str(bold);
165        styled.push_str(yellow);
166        styled.push('(');
167        plain.push('(');
168        for (pi, (k, v)) in s.pairs.iter().enumerate() {
169            if pi > 0 {
170                styled.push_str(", ");
171                plain.push_str(", ");
172            }
173            // Compare against the prior render's value at the
174            // same (stratum, key) coordinate. A missing prior
175            // (first phase of the run, or a deeper stratum
176            // that didn't exist last time) is treated as
177            // "changed" so the operator sees the new context.
178            let changed = match prior_pairs {
179                Some(prior) => prior
180                    .iter()
181                    .find(|(pk, _)| pk == k)
182                    .map(|(_, pv)| pv != v)
183                    .unwrap_or(true),
184                None => true,
185            };
186            write!(styled, "{k}=").ok();
187            plain.push_str(k);
188            plain.push('=');
189            if changed {
190                // Drop yellow → magenta for the value, then
191                // restore so subsequent text stays styled.
192                write!(styled, "{reset}{bold}{magenta}{v}{reset}{bold}{yellow}").ok();
193            } else {
194                styled.push_str(v);
195            }
196            plain.push_str(v);
197        }
198        styled.push(')');
199        styled.push_str(reset);
200        plain.push(')');
201        rendered.push((styled, plain.chars().count()));
202    }
203
204    // Greedy line-fill: place strata on the current line as
205    // long as each addition fits within `available_width`;
206    // when it would overflow, break to a new continuation
207    // line. Two wrap branches:
208    //
209    //   1. *Before* the first stratum, when even with no
210    //      separator the head + this stratum's plain width
211    //      would overflow → start the coord block on a fresh
212    //      continuation line ("block mode" for the coords).
213    //      This is the case the active-phase status renderer
214    //      hits when the per-cell coord chain has wide
215    //      strata (source_model + every CREATE INDEX option
216    //      value spelled out) — without this branch the
217    //      first stratum would go inline and then get
218    //      per-row clamped to `…` by the surface's terminal-
219    //      width clamp.
220    //   2. *Between* strata, when adding the next stratum's
221    //      width to the running line would overflow → fold
222    //      the next stratum onto its own continuation line.
223    //
224    // Strata are NEVER themselves split — a single stratum
225    // wider than `available_width` lands on its own line and
226    // is allowed to overflow (better than mangling its
227    // `(k=v, ...)` shape mid-pair). The status renderer's
228    // per-row clamp still applies as a last-resort safety
229    // net for the truly-pathological "one stratum is wider
230    // than the terminal" case.
231    let mut out = String::new();
232    let sep_plain_len = 2; // visible chars of `, `
233    let cont_indent_width = continuation_indent.chars().count();
234    let mut current_visible = head_consumed;
235    let mut wrote_any = false;
236    for (styled, plain_len) in rendered.iter() {
237        let needs_sep = wrote_any;
238        let next_width = if needs_sep {
239            current_visible + sep_plain_len + plain_len
240        } else {
241            current_visible + plain_len
242        };
243        // First-stratum "block-mode" wrap: the head already
244        // consumed some columns; if even the first stratum
245        // doesn't fit alongside, kick it to a fresh line.
246        let first_stratum_overflows = !wrote_any
247            && current_visible + plain_len > available_width
248            // Don't gratuitously break to a continuation
249            // line when the stratum itself is already too
250            // wide for the continuation line — the wrap
251            // wouldn't help, just produce two truncated
252            // lines instead of one.
253            && cont_indent_width + plain_len < available_width;
254        if first_stratum_overflows {
255            out.push('\n');
256            out.push_str(continuation_indent);
257            out.push_str(styled);
258            current_visible = cont_indent_width + plain_len;
259        } else if wrote_any && next_width > available_width {
260            // Inter-stratum wrap. Comma at line-end signals
261            // continuation to the reader; no trailing space
262            // (the indent covers visual separation).
263            out.push(',');
264            out.push('\n');
265            out.push_str(continuation_indent);
266            out.push_str(styled);
267            current_visible = cont_indent_width + plain_len;
268        } else {
269            if needs_sep {
270                out.push_str(dim);
271                out.push_str(", ");
272                out.push_str(reset);
273                current_visible += sep_plain_len;
274            }
275            out.push_str(styled);
276            current_visible += plain_len;
277        }
278        wrote_any = true;
279    }
280    out
281}
282
283/// Best-effort terminal width with a sensible floor. Reads
284/// `terminal_cols()` (TIOCGWINSZ on stderr) and falls back
285/// to 120 when stderr isn't a TTY (piped output, CI). The
286/// 120-column fallback matches the prior unwrapped-width
287/// assumption — wider terminals get a longer single line
288/// before wrap fires.
289fn current_terminal_cols() -> usize {
290    crate::activity::terminal_cols().unwrap_or(120).max(40)
291}
292
293/// Filter strata to only the pairs whose values changed
294/// against the prior render. SRD-? "summarise completed
295/// phases to only the coords that took a NEW value at this
296/// phase" — the user said the scope-open lines above the
297/// completion line already establish the unchanged context;
298/// duplicating it on the `✓` line is just noise.
299///
300/// Returns an empty Vec when nothing changed (the caller
301/// can omit the coords block entirely for the no-op-axis
302/// case — every cell in the sweep with literally the same
303/// coords as the prior one — common at session start).
304fn strata_diff(strata: &[Stratum], prev_strata: &[Stratum]) -> Vec<Stratum> {
305    if prev_strata.is_empty() {
306        // First-phase render: every coord is "new" relative
307        // to the empty prior. Returning the full set
308        // preserves the operator's first-look context.
309        return strata.to_vec();
310    }
311    let mut out: Vec<Stratum> = Vec::with_capacity(strata.len());
312    for (idx, s) in strata.iter().enumerate() {
313        let prior = prev_strata.get(idx);
314        let mut changed_pairs: Vec<(String, String)> = Vec::new();
315        for (k, v) in &s.pairs {
316            let unchanged = match prior {
317                Some(p) => p
318                    .pairs
319                    .iter()
320                    .find(|(pk, _)| pk == k)
321                    .map(|(_, pv)| pv == v)
322                    .unwrap_or(false),
323                None => false,
324            };
325            if !unchanged {
326                changed_pairs.push((k.clone(), v.clone()));
327            }
328        }
329        if !changed_pairs.is_empty() {
330            out.push(Stratum {
331                pairs: changed_pairs,
332            });
333        }
334    }
335    out
336}
337
338impl Clone for Stratum {
339    fn clone(&self) -> Self {
340        Self {
341            pairs: self.pairs.clone(),
342        }
343    }
344}
345
346/// Format the coords block with wrap-folding + per-pair
347/// change highlight. Always returns a string that begins
348/// with a leading space (so the caller's format string can
349/// concatenate it after `[name]` without conditional logic).
350/// Empty labels collapse to "" — caller renders nothing.
351///
352/// `summarize_changed_only` controls which pairs are
353/// rendered:
354/// - `false` — emit every stratum / every pair (the
355///   "active phase" display where the operator wants the
356///   full context).
357/// - `true`  — emit ONLY the pairs whose values changed
358///   relative to the previous-rendered coords (the
359///   "completed phase" summary; the surrounding scope-open
360///   lines already establish the unchanged context).
361///
362/// Visible across the readouts module so the `phase_status`
363/// (active-phase) renderer and `phase_outcome` (completed-
364/// phase) renderer share one implementation — both
365/// surfaces format the SAME canonical coord-path string;
366/// only the summarize flag and head-consumed accounting
367/// differ.
368pub(crate) fn format_coords_block(
369    labels: &str,
370    color: bool,
371    head_consumed: usize,
372    continuation_indent: &str,
373    summarize_changed_only: bool,
374) -> String {
375    if labels.is_empty() {
376        return String::new();
377    }
378    let strata = parse_strata(labels);
379    let prev = LAST_RENDERED_COORDS
380        .lock()
381        .ok()
382        .map(|g| g.clone())
383        .unwrap_or_default();
384    let prev_strata = parse_strata(&prev);
385    let display_strata: Vec<Stratum> = if summarize_changed_only {
386        strata_diff(&strata, &prev_strata)
387    } else {
388        strata.clone()
389    };
390    // Only advance the tracker when this is the COMPLETED-
391    // phase summary render (`summarize_changed_only ==
392    // true`). The ACTIVE-phase status line (`false`) fires
393    // at every live-status tick during one phase
394    // activation; if it advanced the tracker each tick, the
395    // subsequent `phase_outcome` call would diff against the
396    // active phase's own labels and the summary would
397    // collapse to nothing. The tracker advances at phase
398    // boundaries only — `phase_outcome` is the canonical
399    // phase-end event, and its single call per phase is
400    // where the "what's changed since the last
401    // *completed* phase" lens gets fixed.
402    if summarize_changed_only && let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
403        *g = labels.to_string();
404    }
405    if display_strata.is_empty() {
406        // Nothing changed — completion line elides the
407        // coords block entirely. The leading space is
408        // suppressed too: callers concatenating
409        // `{coords}` get a single-space-separated layout
410        // when there ARE coords, and a clean joined layout
411        // when there aren't.
412        return String::new();
413    }
414    let width = current_terminal_cols();
415    let available = width.saturating_sub(1);
416    let body = render_strata(
417        &display_strata,
418        &prev_strata,
419        color,
420        head_consumed.saturating_add(1),
421        available,
422        continuation_indent,
423    );
424    let mut out = String::with_capacity(body.len() + 1);
425    out.push(' ');
426    out.push_str(&body);
427    out
428}
429
430/// SRD-76 — single phase-end readout. Reads the structured
431/// outcome (status, duration, error list, resume cursor)
432/// from the [`ReadoutContext`] and renders LOD-appropriate
433/// detail. Replaces the legacy `phase_outcome` readout that
434/// always assumed success.
435///
436/// Naming: the readout struct is `PhaseOutcomeReadout`
437/// rather than `PhaseOutcome` to disambiguate from
438/// [`crate::phase_outcome::PhaseOutcome`] (the data type).
439/// The string registry key is `"phase_outcome"`.
440pub struct PhaseOutcomeReadout;
441
442impl Readout for PhaseOutcomeReadout {
443    fn name(&self) -> &'static str {
444        "phase_outcome"
445    }
446    fn accepts(&self) -> &'static [SubjectKind] {
447        &[SubjectKind::Phase]
448    }
449
450    fn render(
451        &self,
452        ctx: &dyn ReadoutContext,
453        lod: Lod,
454        mode: ContentMode,
455        _opts: &ReadoutOptions,
456        out: &mut dyn ReadoutBuf,
457    ) -> usize {
458        match (lod, mode) {
459            (Lod::Compact, ContentMode::Value) => render_compact_value(ctx, out),
460            (Lod::Compact, ContentMode::Explanation) => render_compact_explanation(ctx, out),
461            (Lod::Labeled, ContentMode::Value) => render_labeled_value(ctx, out),
462            (Lod::Labeled, ContentMode::Explanation) => render_labeled_explanation(ctx, out),
463            (Lod::Expanded, ContentMode::Value) => render_expanded_value(ctx, out),
464            (Lod::Expanded, ContentMode::Explanation) => render_expanded_explanation(ctx, out),
465        }
466    }
467}
468
469// ── SRD-76 status helpers ─────────────────────────────────
470
471/// ANSI colour token for a phase status:
472/// - `Completed` → green
473/// - `Failed` → red
474/// - `Skipped` → dim (subdued, not a problem)
475/// - `CursorSuspended` → yellow (partial — worth noticing)
476///
477/// Returns empty strings when colour is off so the same
478/// format template covers both colour and no-colour modes.
479fn status_color(outcome: &crate::phase_outcome::Outcome, color: bool) -> &'static str {
480    if !color {
481        return "";
482    }
483    use crate::phase_outcome::{Disposition, Validity};
484    match (outcome.disposition, outcome.validity) {
485        (_, Validity::Failed) => "\x1b[31m",    // red
486        (Disposition::Skipped, _) => "\x1b[2m", // dim
487        (Disposition::Interrupted, Validity::Succeeded) => "\x1b[33m", // yellow (re-usable partial)
488        _ => "\x1b[32m",                        // green
489    }
490}
491
492// ── Compact LOD ───────────────────────────────────────────
493
494/// Compact LOD: `{depth}<glyph> [name] {pct}% ({elapsed:.2}s)`.
495/// Glyph + color come from the SRD-76 [`PhaseStatus`]:
496/// Completed → ✓ (green), Failed → ✗ (red),
497/// Skipped → ~ (dim), CursorSuspended → … (yellow).
498/// Trained-operator scan form: status glyph + identity +
499/// completion percentage + wallclock. Per §3.3
500/// monotonicity, every field is present in Labeled.
501/// The completion METER SLOT (SRD-92): ` NN%` from the single
502/// fraction source, ` 100%` for a bounded subject with no computable
503/// fraction (it finished), and EMPTY for an open-ended subject —
504/// there is no "done" to meter on a stopped daemon, same rule the
505/// live meter slot follows (the cycles basis printed a meaningless
506/// percentage against the daemon's wall-clock ceiling).
507fn completion_meter(ctx: &dyn ReadoutContext) -> String {
508    if ctx.open_ended() {
509        return String::new();
510    }
511    format!(
512        " {:.0}%",
513        ctx.progress_fraction().map(|f| f * 100.0).unwrap_or(100.0)
514    )
515}
516
517fn render_compact_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
518    let color = ctx.use_color();
519    let bold = if color { "\x1b[1m" } else { "" };
520    let dim = if color { "\x1b[2m" } else { "" };
521    let blue = if color { "\x1b[34m" } else { "" };
522    let reset = if color { "\x1b[0m" } else { "" };
523
524    let outcome = ctx.outcome();
525    let glyph_color = status_color(&outcome, color);
526    let glyph = outcome.glyph();
527
528    // Fraction basis via ctx.progress_fraction(): rows for batched
529    // phases (cycles basis showed "1%" for a completed stride-100
530    // batch load), cycles otherwise, override first; empty for
531    // open-ended subjects.
532    let meter = completion_meter(ctx);
533    let elapsed = ctx.elapsed_secs();
534    let depth_indent = ctx.depth_indent();
535    let name = ctx.subject_name();
536
537    let mut tmp = String::with_capacity(64);
538    let _ = write!(
539        &mut tmp,
540        "{depth_indent}{glyph_color}{glyph}{reset} {bold}{blue}[{name}]{reset}\
541{meter} {dim}({elapsed:.2}s){reset}",
542    );
543    let len = tmp.len();
544    let _ = out.write_str(&tmp);
545    len
546}
547
548/// Compact LOD explanation overlay. Same skeleton as the
549/// value form, with field tokens swapped for descriptors.
550fn render_compact_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
551    let color = ctx.use_color();
552    let bold = if color { "\x1b[1m" } else { "" };
553    let dim = if color { "\x1b[2m" } else { "" };
554    let blue = if color { "\x1b[34m" } else { "" };
555    let green = if color { "\x1b[32m" } else { "" };
556    let reset = if color { "\x1b[0m" } else { "" };
557
558    let depth_indent = ctx.depth_indent();
559    let mut tmp = String::with_capacity(96);
560    let _ = write!(
561        &mut tmp,
562        "{depth_indent}{green}done{reset} {bold}{blue}[phase-name]{reset} \
563progress% {dim}(elapsed){reset}",
564    );
565    let len = tmp.len();
566    let _ = out.write_str(&tmp);
567    len
568}
569
570// ── Expanded LOD ──────────────────────────────────────────
571
572/// Expanded LOD: multi-line labelled block. Same data as
573/// Labeled, organised one field per line. Per §3.3
574/// monotonicity, every Labeled field is here too.
575fn render_expanded_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
576    let color = ctx.use_color();
577    let bold = if color { "\x1b[1m" } else { "" };
578    let dim = if color { "\x1b[2m" } else { "" };
579    let yellow = if color { "\x1b[33m" } else { "" };
580    let blue = if color { "\x1b[34m" } else { "" };
581    let red = if color { "\x1b[31m" } else { "" };
582    let reset = if color { "\x1b[0m" } else { "" };
583    let outcome = ctx.outcome();
584    let glyph_color = status_color(&outcome, color);
585    let glyph = outcome.glyph();
586    // SRD-76 — when the phase has recorded errors, append a
587    // per-error block at the bottom of the Expanded LOD. This
588    // gives operators full per-error detail without leaving
589    // the realtime status surface, and matches what `nmbrs
590    // replay --errors` shows for the same phase.
591    let outcome_errors = ctx.outcome_errors();
592
593    let cycles = ctx.cycles_completed();
594    let errors = ctx.errors();
595    let retries = ctx.retries();
596    let ok = ctx.ops_ok();
597    let skips = ctx.skips();
598    let concurrency = ctx.concurrency();
599    let elapsed = ctx.elapsed_secs();
600    let consumed = ctx.consumed();
601    let total_extent = ctx.cycles_total();
602
603    // ok% excludes SKIPS — a skipped (`if:`-gated) op is neither a
604    // success nor a failure (cycles == result_total + skips).
605    // `.max(ok)`: cycles and result_success are read non-atomically and
606    // bumped cycles-first, so this can momentarily dip below `ok` — it is
607    // never truly < ok. Clamp up so ok% stays <= 100%.
608    let result_total = cycles.saturating_sub(skips).max(ok);
609    let ok_pct: f64 = if result_total > 0 {
610        ok as f64 * 100.0 / result_total as f64
611    } else {
612        100.0
613    };
614    // Open-ended subjects have no completion fraction — the progress
615    // row shows the bare cycle count instead of a fabricated percent.
616    let progress_cell: String = if ctx.open_ended() {
617        format!("— (open-ended, {cycles} cycles)")
618    } else {
619        let pct: f64 = ctx.progress_fraction().map(|f| f * 100.0).unwrap_or(100.0);
620        format!("{pct:.0}% ({cycles} of {total_extent})")
621    };
622    let rate: f64 = if elapsed > 0.0 {
623        consumed as f64 / elapsed
624    } else {
625        0.0
626    };
627    let rate_str = format_rate(rate);
628
629    let err_color = if errors > 0 || retries > 0 {
630        yellow
631    } else {
632        dim
633    };
634    let labels = ctx.subject_labels();
635    let depth_indent = ctx.depth_indent();
636    // Margin owns [n/N] (single-placement rule) — body omits it.
637    let seq_part: String = String::new();
638    // SRD-? — Expanded LOD: each row already labelled
639    // (`coords:`), so the wrap-aware folder gets generous
640    // available-width by passing `head_consumed == coords_label_width`.
641    // Use the completed-phase summary lens (only-changed
642    // strata) — matches the Labeled LOD's contract; the
643    // multi-line block here is the operator's deep-dive form
644    // for ONE phase, not a cross-phase diff surface.
645    // Continuation indent aligns under the value column
646    // (matches the other rows' `  <field>: ` prefix).
647    let coords_continuation_indent = format!("{depth_indent}               ");
648    let coords_head_consumed = depth_indent.chars().count() + 14; // "  coords:      "
649    let coords_payload = format_coords_block(
650        labels,
651        color,
652        coords_head_consumed,
653        &coords_continuation_indent,
654        /* summarize_changed_only */ true,
655    );
656    let coords_line = if coords_payload.is_empty() {
657        String::new()
658    } else {
659        // `format_coords_block` returned a leading-space
660        // payload; place it after the `coords:` label.
661        format!("\n{depth_indent}  coords:{coords_payload}")
662    };
663    let _ = (bold, yellow); // formerly used to wrap the raw labels; helper now owns styling
664    // Chip stream: convert `name:value` chips into one per
665    // line so the Expanded block reads vertically. Empty
666    // chip strings render no metrics block.
667    let chips_block = render_chips_block(&ctx.status_metric_chips(), depth_indent, dim, reset);
668
669    let errors_block =
670        render_outcome_errors_block(outcome_errors, errors, depth_indent, red, dim, reset);
671    let mut tmp = String::with_capacity(384);
672    let _ = write!(
673        &mut tmp,
674        "{depth_indent}{glyph_color}{glyph}{reset} {bold}{blue}[{name}]{reset}{seq}{coords}\n\
675{depth_indent}  status:      {glyph_color}{status_label}{reset}\n\
676{depth_indent}  progress:    {progress_cell}\n\
677{depth_indent}  throughput:  {rate_str}\n\
678{depth_indent}  ok:          {ok_pct:.0}%  ({ok} of {result_total})\n\
679{depth_indent}  reliability: {err_color}e:{errors} r:{retries}{reset}\n\
680{depth_indent}  concurrency: {concurrency}\n\
681{chips}\
682{depth_indent}  elapsed:     {dim}{elapsed:.2}s{reset}\
683{errors_block}",
684        name = ctx.subject_name(),
685        seq = seq_part,
686        coords = coords_line,
687        chips = chips_block,
688        status_label = outcome.label(),
689        errors_block = errors_block,
690    );
691    let len = tmp.len();
692    let _ = out.write_str(&tmp);
693    len
694}
695
696/// SRD-76 Expanded-LOD per-error block. Empty when the error
697/// list is empty. Each error renders as a labelled multi-line
698/// stanza:
699///   `  errors:`
700///   `    [<class>] <message>`
701///   `      cycle: <n>`             (when populated)
702///   `      op-template: <text>`    (when populated)
703///   `      op-resolved: <text>`    (when populated)
704fn render_outcome_errors_block(
705    errors: &[crate::phase_outcome::PhaseErrorDetail],
706    total: u64,
707    indent: &str,
708    red: &str,
709    dim: &str,
710    reset: &str,
711) -> String {
712    use std::fmt::Write as _;
713    if errors.is_empty() {
714        return String::new();
715    }
716    // Driver-supplied messages (CQL statement text, etc.) carry
717    // embedded newlines; re-indent each continuation line to
718    // nest under the surrounding block so the output doesn't
719    // break out to column 0.
720    let msg_continuation = format!("{indent}      ");
721    let detail_continuation = format!("{indent}        ");
722    let reindent = |s: &str, prefix: &str| -> String {
723        let mut out = String::with_capacity(s.len() + prefix.len() * 2);
724        let mut first = true;
725        for line in s.split('\n') {
726            if first {
727                first = false;
728            } else {
729                out.push('\n');
730                out.push_str(prefix);
731            }
732            out.push_str(line);
733        }
734        out
735    };
736    let mut out = String::with_capacity(128);
737    let _ = write!(&mut out, "\n{indent}  errors:");
738    for e in errors {
739        let msg = reindent(&e.message, &msg_continuation);
740        let _ = write!(
741            &mut out,
742            "\n{indent}    {red}[{class}]{reset} {msg}",
743            class = e.class
744        );
745        if let Some(c) = e.cycle {
746            let _ = write!(&mut out, "\n{indent}      {dim}cycle:{reset} {c}");
747        }
748        if let Some(t) = &e.op_template {
749            let t = reindent(t, &detail_continuation);
750            let _ = write!(&mut out, "\n{indent}      {dim}op-template:{reset} {t}");
751        }
752        if let Some(r) = &e.op_resolved {
753            let r = reindent(r, &detail_continuation);
754            let _ = write!(&mut out, "\n{indent}      {dim}op-resolved:{reset} {r}");
755        }
756    }
757    // The capture buffer (PHASE_ERROR_CAPTURE_CAP) can fill before
758    // all errors arrive; note the shortfall so the listed set isn't
759    // mistaken for the complete record.
760    let captured = errors.len() as u64;
761    if captured < total {
762        let _ = write!(
763            &mut out,
764            "\n{indent}    {dim}(+{} more occurred — not captured (buffer cap)){reset}",
765            total - captured
766        );
767    }
768    out
769}
770
771/// Format the chip stream as one chip per line under a
772/// `metrics:` header. `chips` follows the convention from
773/// `ActivityMetrics::collect_status_values`: leading-space-
774/// separated `name:value` tokens. Returns empty when
775/// `chips` is empty (the metrics block is skipped).
776fn render_chips_block(chips: &str, indent: &str, dim: &str, reset: &str) -> String {
777    let entries: Vec<&str> = chips.split_whitespace().collect();
778    if entries.is_empty() {
779        return String::new();
780    }
781    let mut out = String::with_capacity(64 + entries.len() * 24);
782    let _ = writeln!(&mut out, "{indent}  metrics:");
783    for chip in &entries {
784        // Each chip is `name:value`; align the name in a
785        // 16-char field so values columnise.
786        let (name, value) = chip.split_once(':').unwrap_or((chip, ""));
787        let _ = writeln!(&mut out, "{indent}    {name:<16} {dim}{value}{reset}");
788    }
789    out
790}
791
792/// Expanded LOD explanation overlay. Same multi-line shape
793/// as the value form; field labels stay (they're already
794/// descriptors), values are replaced with token names.
795fn render_expanded_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
796    let color = ctx.use_color();
797    let bold = if color { "\x1b[1m" } else { "" };
798    let dim = if color { "\x1b[2m" } else { "" };
799    let blue = if color { "\x1b[34m" } else { "" };
800    let green = if color { "\x1b[32m" } else { "" };
801    let reset = if color { "\x1b[0m" } else { "" };
802
803    let depth_indent = ctx.depth_indent();
804    let mut tmp = String::with_capacity(384);
805    let _ = write!(
806        &mut tmp,
807        "{depth_indent}{green}done{reset} {bold}{blue}[phase-name]{reset}\n\
808{depth_indent}  progress:    progress% (cycles_completed of cycles_total)\n\
809{depth_indent}  throughput:  rate (auto-scaled K/s, M/s)\n\
810{depth_indent}  ok:          ok-pct% (ops_ok of cycles_completed)\n\
811{depth_indent}  reliability: e:errors r:retries\n\
812{depth_indent}  concurrency: fiber count\n\
813{depth_indent}  elapsed:     {dim}wallclock seconds{reset}",
814    );
815    let len = tmp.len();
816    let _ = out.write_str(&tmp);
817    len
818}
819
820/// Explanation overlay (SRD-63 §3.2 / Push 7). Same
821/// shape as the value render, with each glyph / cluster
822/// replaced by text describing what it means. Width
823/// parity is the readout author's contract — we keep
824/// the structural skeleton (`✓`, `[name]`, percentages,
825/// `e:`/`r:`/`c:` tail) and rewrite each token's *text*
826/// to its meaning.
827fn render_labeled_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
828    let color = ctx.use_color();
829    let bold = if color { "\x1b[1m" } else { "" };
830    let dim = if color { "\x1b[2m" } else { "" };
831    let yellow = if color { "\x1b[33m" } else { "" };
832    let blue = if color { "\x1b[34m" } else { "" };
833    let green = if color { "\x1b[32m" } else { "" };
834    let reset = if color { "\x1b[0m" } else { "" };
835
836    let depth_indent = ctx.depth_indent();
837    // Margin owns [n/N] (single-placement rule) — body omits it.
838    let seq_part: String = String::new();
839    let coords_part: String = if ctx.subject_labels().is_empty() {
840        String::new()
841    } else {
842        format!(" {bold}{yellow}(scope-coords){reset}")
843    };
844
845    // Width-parity bars: pct → "100%", rate → "rate/s",
846    // ok_pct → "ok-pct%", and so on. Each replacement is
847    // *short* enough to overlay without wrapping; the
848    // user's expectation is "what does this glyph mean?"
849    // not "every detail spelled out."
850    let mut tmp = String::with_capacity(160);
851    let _ = write!(
852        &mut tmp,
853        "{depth_indent}{green}done{reset} {seq}{bold}{blue}[phase-name]{reset}{coords} \
854progress% throughput ok:ok% \
855errors retries concurrency \
856{dim}(elapsed){reset}",
857        seq = seq_part,
858        coords = coords_part,
859    );
860    let len = tmp.len();
861    let _ = out.write_str(&tmp);
862    len
863}
864
865fn render_labeled_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
866    // SRD-76 — branch on terminal status. Failed phases
867    // render the error-flavoured line (glyph + class + first
868    // message + elapsed) instead of the success summary.
869    // Skipped / interrupted-but-usable fall through the same
870    // success-flavoured layout because the throughput /
871    // ok-pct / counters telemetry is still meaningful (an
872    // interrupted phase ran SOMETHING) and the glyph alone
873    // communicates the non-Completed disposition. Only an
874    // untrustworthy result gets the failure layout.
875    let outcome = ctx.outcome();
876    if outcome.is_failure() {
877        return render_labeled_value_failed(ctx, out);
878    }
879    let color = ctx.use_color();
880    let bold = if color { "\x1b[1m" } else { "" };
881    let dim = if color { "\x1b[2m" } else { "" };
882    let yellow = if color { "\x1b[33m" } else { "" };
883    let blue = if color { "\x1b[34m" } else { "" };
884    let reset = if color { "\x1b[0m" } else { "" };
885    let glyph_color = status_color(&outcome, color);
886    let glyph = outcome.glyph();
887
888    let cycles = ctx.cycles_completed();
889    let errors = ctx.errors();
890    let retries = ctx.retries();
891    let ok = ctx.ops_ok();
892    let skips = ctx.skips();
893    let concurrency = ctx.concurrency();
894    let elapsed = ctx.elapsed_secs();
895    let consumed = ctx.consumed();
896    let total_extent = ctx.cycles_total();
897
898    // ok% excludes SKIPS — a skipped (`if:`-gated) op is neither a
899    // success nor a failure (cycles == result_total + skips).
900    // `.max(ok)`: cycles and result_success are read non-atomically and
901    // bumped cycles-first, so this can momentarily dip below `ok` — it is
902    // never truly < ok. Clamp up so ok% stays <= 100%.
903    let result_total = cycles.saturating_sub(skips).max(ok);
904    // A phase where every op skipped has NO results to be ok about —
905    // `ok:—` instead of a fabricated 100%, and the skip count shown
906    // so the line reads as "gated off", not "measured clean".
907    let ok_str: String = if result_total > 0 {
908        format!("{:.0}%", ok as f64 * 100.0 / result_total as f64)
909    } else if skips > 0 {
910        "—".to_string()
911    } else {
912        "100%".to_string()
913    };
914    let skips_chip: String = if skips > 0 {
915        format!(" {dim}skip:{skips}{reset}")
916    } else {
917        String::new()
918    };
919    // Fraction basis via ctx.progress_fraction(): rows for batched
920    // phases (cycles basis showed "1%" for a completed stride-100
921    // batch load), cycles otherwise, override first; empty for
922    // open-ended subjects.
923    let meter = completion_meter(ctx);
924    let rate: f64 = if elapsed > 0.0 {
925        consumed as f64 / elapsed
926    } else {
927        0.0
928    };
929    let rate_str = format_rate(rate);
930
931    let err_color = if errors > 0 || retries > 0 {
932        yellow
933    } else {
934        dim
935    };
936
937    let labels = ctx.subject_labels();
938    let depth_indent = ctx.depth_indent();
939    // Margin owns [n/N] (single-placement rule) — body omits it.
940    let seq_part: String = String::new();
941    // Per-cell completion bar for small phases — preserves
942    // the per-op success/failure visibility from `phase_status`
943    // through to the completion line so the operator can see
944    // WHICH ops succeeded or failed, not just an aggregate
945    // percentage. Width matches phase_status's variant
946    // (1 leading space + N glyphs).
947    let bar = if total_extent > 0 && total_extent <= 10 {
948        let bg = if color { "\x1b[48;2;50;50;50m" } else { "" };
949        let fg = if color { "\x1b[97m" } else { "" };
950        format!(" {bg}{fg}{}{reset}", ballot_bar(total_extent, ok, errors))
951    } else {
952        String::new()
953    };
954    let bar_visible: usize = if total_extent > 0 && total_extent <= 10 {
955        1 + total_extent as usize
956    } else {
957        0
958    };
959
960    // Estimate visible columns consumed by the head of the
961    // line prior to the coords block — used to drive wrap
962    // decisions inside `format_coords_block`. Composition:
963    //   depth_indent (variable) + "✓ " (2) + ballot bar
964    //   (when ≤10) + seq prefix visible chars ("[N/M] " when
965    //   seq is Some) + "[<name>]" length.
966    let name = ctx.subject_name();
967    let seq_visible: usize = match ctx.subject_seq() {
968        Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
969        None => 0,
970    };
971    let head_consumed: usize = depth_indent.chars().count()
972        + 2  // ✓ + space
973        + bar_visible
974        + seq_visible
975        + 2  // [ and ]
976        + name.chars().count();
977    // Wrap continuation lands at the same depth indent +
978    // 2 spaces (alignment with the inner-block / chips line
979    // already used in this layout).
980    let continuation_indent = format!("{depth_indent}  ");
981    // SRD-? "completed phase ✓ line shows only the coords
982    // that took a new value here" — the scope-open lines
983    // above the completion already establish the
984    // unchanged context.
985    let coords_part = format_coords_block(
986        labels,
987        color,
988        head_consumed,
989        &continuation_indent,
990        /* summarize_changed_only */ true,
991    );
992    let chips = ctx.status_metric_chips();
993
994    // Memo row (if any) — see phase_status for rationale.
995    // The memo carries the latest published state at phase
996    // end; useful when a phase's last activity (e.g. "compacted
997    // table_X") is the takeaway the operator needs. SRD-92:
998    // header-first composition — the memo renders as a detail
999    // row under the ✓/⊘ head line, never as a banner above it.
1000    let memo = ctx.phase_memo();
1001    let memo_row = if memo.is_empty() {
1002        String::new()
1003    } else {
1004        let bold_yellow = if color { "\x1b[1;33m" } else { "" };
1005        format!("{depth_indent}    {bold_yellow}[[ {memo} ]]{reset}\n")
1006    };
1007
1008    // FULLY-SKIPPED phase (`skipped_phases=mark`, the default): every
1009    // cycle was `if:`-gated off — the completion must read as "gated
1010    // off", not as a measurement. One unmistakable line pair: the ⊘
1011    // glyph plus the skip count; no rate, no ok%, no pct (nothing was
1012    // measured). `elide`/`prune` never reach this renderer — the fire
1013    // site suppresses the readout entirely for those modes.
1014    let fully_skipped = skips > 0 && cycles > 0 && skips >= cycles && ok == 0 && errors == 0;
1015    if fully_skipped
1016        && crate::observer::skipped_phase_display() == crate::observer::SkippedPhaseDisplay::Mark
1017    {
1018        let mut tmp = String::with_capacity(160);
1019        let _ = write!(
1020            &mut tmp,
1021            "{depth_indent}{dim}⊘{reset} {seq}{bold}{blue}[{name}]{reset}{coords} {dim}gated off{reset}\n\
1022{memo_row}\
1023{depth_indent}    {dim}skip:{skips} c:{concurrency} ({elapsed:.2}s){reset}",
1024            memo_row = memo_row,
1025            depth_indent = depth_indent,
1026            dim = dim,
1027            reset = reset,
1028            seq = seq_part,
1029            bold = bold,
1030            blue = blue,
1031            name = ctx.subject_name(),
1032            coords = coords_part,
1033            skips = skips,
1034            concurrency = concurrency,
1035            elapsed = elapsed,
1036        );
1037        let len = tmp.len();
1038        let _ = out.write_str(&tmp);
1039        return len;
1040    }
1041
1042    // Two-line layout mirroring `phase_status` Labeled:
1043    //   line 1: {depth}✓ {seq}[{name}]{coords} {pct}%
1044    //   line 2: {depth}    {rate} ok:{ok}% e:{e} r:{r} c:{c}{chips} (elapsed)
1045    // Break after the progress percentage keeps the head row
1046    // narrow (status glyph + identity + completion) and lets
1047    // the tail carry all the throughput/counter detail
1048    // without exceeding terminal width.
1049    let mut tmp = String::with_capacity(256);
1050    let _ = write!(
1051        &mut tmp,
1052        "{depth_indent}{glyph_color}{glyph}{reset}{bar} {seq}{bold}{blue}[{name}]{reset}{coords}{meter}\n\
1053{memo_row}\
1054{depth_indent}    {rate_str} ok:{ok_str} \
1055{err_color}e:{errors} r:{retries}{reset}{skips_chip} c:{concurrency}{chips} \
1056{dim}({elapsed:.2}s){reset}",
1057        depth_indent = depth_indent,
1058        glyph_color = glyph_color,
1059        glyph = glyph,
1060        bar = bar,
1061        reset = reset,
1062        seq = seq_part,
1063        bold = bold,
1064        blue = blue,
1065        name = ctx.subject_name(),
1066        coords = coords_part,
1067        meter = meter,
1068        rate_str = rate_str,
1069        ok_str = ok_str,
1070        skips_chip = skips_chip,
1071        err_color = err_color,
1072        errors = errors,
1073        retries = retries,
1074        concurrency = concurrency,
1075        chips = chips,
1076        dim = dim,
1077        elapsed = elapsed,
1078    );
1079    let len = tmp.len();
1080    let _ = out.write_str(&tmp);
1081    len
1082}
1083
1084/// SRD-76 — Labeled rendering for `PhaseStatus::Failed`.
1085/// Two-line layout matching the success variant's shape so
1086/// surrounding context (depth indent, coord folding, seq
1087/// prefix) stays consistent; line 1 carries the status glyph
1088/// + name + coords + first-error class summary, line 2 the
1089///   first-error message + elapsed.
1090///
1091/// Expanded LOD picks up every error in the list; this
1092/// Labeled form intentionally surfaces only the FIRST error
1093/// (the one most likely to be the proximate cause) so the
1094/// status surface stays line-bounded. Operators wanting the
1095/// full chronology pass `lod=expanded` or use `nmbrs replay
1096/// --errors`.
1097fn render_labeled_value_failed(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
1098    let color = ctx.use_color();
1099    let bold = if color { "\x1b[1m" } else { "" };
1100    let dim = if color { "\x1b[2m" } else { "" };
1101    let blue = if color { "\x1b[34m" } else { "" };
1102    let red = if color { "\x1b[31m" } else { "" };
1103    let reset = if color { "\x1b[0m" } else { "" };
1104
1105    let labels = ctx.subject_labels();
1106    let depth_indent = ctx.depth_indent();
1107    let name = ctx.subject_name();
1108    // Margin owns [n/N] (single-placement rule) — body omits it.
1109    let seq_part: String = String::new();
1110    let seq_visible: usize = match ctx.subject_seq() {
1111        Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
1112        None => 0,
1113    };
1114    // Ballot bar for small phases — same shape as the success
1115    // path. A failed ≤10-op phase still shows the per-op
1116    // success/failure breakdown so the operator sees how far
1117    // the phase got before the failure landed.
1118    let total_extent = ctx.cycles_total();
1119    let ok = ctx.ops_ok();
1120    let err_count = ctx.errors();
1121    let bar = if total_extent > 0 && total_extent <= 10 {
1122        let bg = if color { "\x1b[48;2;50;50;50m" } else { "" };
1123        let fg = if color { "\x1b[97m" } else { "" };
1124        format!(
1125            " {bg}{fg}{}{reset}",
1126            ballot_bar(total_extent, ok, err_count)
1127        )
1128    } else {
1129        String::new()
1130    };
1131    let bar_visible: usize = if total_extent > 0 && total_extent <= 10 {
1132        1 + total_extent as usize
1133    } else {
1134        0
1135    };
1136    let head_consumed: usize = depth_indent.chars().count()
1137        + 2  // ✗ + space
1138        + bar_visible
1139        + seq_visible
1140        + 2  // [ and ]
1141        + name.chars().count();
1142    let continuation_indent = format!("{depth_indent}  ");
1143    let coords_part = format_coords_block(
1144        labels,
1145        color,
1146        head_consumed,
1147        &continuation_indent,
1148        /* summarize_changed_only */ true,
1149    );
1150
1151    let errors = ctx.outcome_errors();
1152    let first = errors.first();
1153    let class_label = first.map(|e| e.class.as_str()).unwrap_or("phase_failed");
1154    // First line only: the footer is a status row, and the full
1155    // enriched text renders once in the `errors:` block (SRD-82
1156    // §one full render).
1157    let message = first
1158        .map(|e| e.message.lines().next().unwrap_or(e.message.as_str()))
1159        .unwrap_or("unknown error");
1160    let elapsed = ctx.elapsed_secs();
1161    // `+N more` is relative to the TRUE error count (`err_count =
1162    // ctx.errors()`, the uncapped `errors_total`), not the capped
1163    // `outcome_errors` buffer length — otherwise a 200-error phase
1164    // reads "+63 more" when 199 more actually occurred.
1165    let extra_count = err_count.saturating_sub(1);
1166    let extra_suffix = if extra_count > 0 {
1167        format!(" {dim}(+{extra_count} more){reset}")
1168    } else {
1169        String::new()
1170    };
1171
1172    let mut tmp = String::with_capacity(192);
1173    let _ = write!(
1174        &mut tmp,
1175        "{depth_indent}{red}✗{reset}{bar} {seq_part}{bold}{blue}[{name}]{reset}{coords_part} \
1176{red}{class_label}{reset}\n\
1177{depth_indent}    {red}{message}{reset}{extra_suffix} {dim}({elapsed:.2}s){reset}",
1178    );
1179    let len = tmp.len();
1180    let _ = out.write_str(&tmp);
1181    len
1182}
1183
1184fn format_rate(rate: f64) -> String {
1185    if rate >= 1_000_000.0 {
1186        format!("{:.1}M/s", rate / 1_000_000.0)
1187    } else if rate >= 1_000.0 {
1188        format!("{:.1}K/s", rate / 1_000.0)
1189    } else {
1190        format!("{:.0}/s", rate)
1191    }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use crate::readouts::buf::StringBuf;
1198
1199    // ── coord-stack rendering tests (SRD-? coords stack
1200    //    folding + change highlight + completed-phase
1201    //    summarisation) ──────────────────────────────────────
1202
1203    /// Tests that touch `LAST_RENDERED_COORDS` share a
1204    /// serial guard so cargo-test's parallel runner doesn't
1205    /// interleave their state mutations. The production
1206    /// code path doesn't need this lock — realtime readout
1207    /// dispatch is single-threaded — but the test
1208    /// harness's per-#[test] task pool is multi-threaded.
1209    static SERIAL_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
1210
1211    /// `parse_strata` round-trips the canonical striated-
1212    /// parens form into per-stratum / per-pair structure.
1213    #[test]
1214    fn parse_strata_round_trips_two_strata() {
1215        let s = parse_strata("(profile=default), (sm=OTHER, mnc=8)");
1216        assert_eq!(s.len(), 2);
1217        assert_eq!(
1218            s[0].pairs,
1219            vec![("profile".to_string(), "default".to_string())]
1220        );
1221        assert_eq!(
1222            s[1].pairs,
1223            vec![
1224                ("sm".to_string(), "OTHER".to_string()),
1225                ("mnc".to_string(), "8".to_string()),
1226            ]
1227        );
1228    }
1229
1230    /// `parse_strata` returns an empty Vec for the empty
1231    /// input — callers can treat "no coords" as the absent
1232    /// case without an Option.
1233    #[test]
1234    fn parse_strata_empty_input_yields_empty() {
1235        assert_eq!(parse_strata("").len(), 0);
1236    }
1237
1238    /// `strata_diff` keeps only the (key, value) pairs whose
1239    /// value changed against the prior render, dropping
1240    /// entire strata that became empty after filtering.
1241    #[test]
1242    fn strata_diff_keeps_only_changed_pairs() {
1243        let prev = parse_strata("(profile=default), (sm=OTHER, mnc=8)");
1244        let curr = parse_strata("(profile=default), (sm=ADA002, mnc=8)");
1245        let d = strata_diff(&curr, &prev);
1246        // profile stratum unchanged → dropped entirely.
1247        // sm changed, mnc unchanged → only sm survives.
1248        assert_eq!(d.len(), 1);
1249        assert_eq!(d[0].pairs, vec![("sm".to_string(), "ADA002".to_string())]);
1250    }
1251
1252    /// First-phase render (empty prior) is treated as
1253    /// "everything is new" — preserves the operator's
1254    /// initial-look context.
1255    #[test]
1256    fn strata_diff_empty_prior_treats_all_as_changed() {
1257        let curr = parse_strata("(profile=default), (sm=OTHER)");
1258        let d = strata_diff(&curr, &[]);
1259        assert_eq!(d.len(), 2);
1260        assert_eq!(d[0].pairs.len(), 1);
1261        assert_eq!(d[1].pairs.len(), 1);
1262    }
1263
1264    /// `format_coords_block` with `summarize_changed_only`
1265    /// drops unchanged strata between sequential renders.
1266    /// The internal LAST_RENDERED_COORDS tracker advances
1267    /// after each call so each subsequent render diffs
1268    /// against the previous one.
1269    #[test]
1270    fn format_coords_block_completed_phase_summary_elides_unchanged() {
1271        let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1272        // Reset the global tracker so this test is
1273        // order-independent (the global persists between
1274        // tests if `cargo test` runs them in the same
1275        // process). Acquiring the lock + clearing matches
1276        // the production tracker's reset semantics.
1277        if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1278            *g = String::new();
1279        }
1280        let labels_a = "(profile=default), (sm=OTHER, mnc=8)";
1281        let labels_b = "(profile=default), (sm=ADA002, mnc=8)";
1282
1283        let first = format_coords_block(
1284            labels_a, /* color */ false, 0, "  ", /* summarize_changed_only */ true,
1285        );
1286        // First call diffs against empty prior → every coord
1287        // appears.
1288        assert!(first.contains("profile=default"));
1289        assert!(first.contains("sm=OTHER"));
1290        assert!(first.contains("mnc=8"));
1291
1292        let second = format_coords_block(
1293            labels_b, /* color */ false, 0, "  ", /* summarize_changed_only */ true,
1294        );
1295        // Only `sm=ADA002` changed → the second render
1296        // elides the unchanged ones.
1297        assert!(
1298            second.contains("sm=ADA002"),
1299            "changed pair missing in second render: {second:?}"
1300        );
1301        assert!(
1302            !second.contains("profile=default"),
1303            "unchanged profile stratum should be elided: {second:?}"
1304        );
1305        assert!(
1306            !second.contains("mnc=8"),
1307            "unchanged mnc should be elided: {second:?}"
1308        );
1309    }
1310
1311    /// Active-phase render (summarize_changed_only=false)
1312    /// shows the full coord stack even when nothing has
1313    /// changed since the prior render.
1314    #[test]
1315    fn format_coords_block_active_phase_shows_full_stack() {
1316        let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1317        if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1318            *g = "(profile=default)".into();
1319        }
1320        let labels = "(profile=default)";
1321        let body = format_coords_block(
1322            labels, /* color */ false, 0, "  ", /* summarize_changed_only */ false,
1323        );
1324        assert!(
1325            body.contains("profile=default"),
1326            "active-phase render should show unchanged coords too: {body:?}"
1327        );
1328    }
1329
1330    /// When the head + first stratum would overflow the
1331    /// available width, the renderer breaks the FIRST
1332    /// stratum to a continuation line ("block mode" for
1333    /// coords). Without this, the active-phase status
1334    /// renderer's per-row terminal-width clamp would
1335    /// truncate the coord chain to `…` instead of folding.
1336    #[test]
1337    fn first_stratum_wraps_to_continuation_when_head_plus_first_overflows() {
1338        // Direct render_strata call (not through the global
1339        // tracker) so the test is deterministic regardless
1340        // of test ordering.
1341        let strata = parse_strata(
1342            "(source_model=OTHER, maximum_node_connections=8, construction_beam_width=50)",
1343        );
1344        let prev: Vec<Stratum> = Vec::new();
1345        // head_consumed=40 simulates spinner + bar + seq +
1346        // `[ensure_compacted]` width. available_width=80
1347        // (short terminal for the test). The first stratum
1348        // is ~73 chars; 40 + 73 = 113 > 80 → must wrap.
1349        let out = render_strata(
1350            &strata, &prev, /* color */ false, /* head_consumed */ 40,
1351            /* available_width */ 80, /* continuation_indent */ "  ",
1352        );
1353        assert!(
1354            out.starts_with('\n'),
1355            "first-stratum overflow should start with a newline; \
1356             got: {out:?}"
1357        );
1358        assert!(
1359            out.contains("source_model=OTHER"),
1360            "first stratum content should still appear: {out:?}"
1361        );
1362        // No comma-at-end-of-line wrapping marker on the
1363        // first-stratum-overflow path (the comma is only
1364        // inserted between strata to signal continuation;
1365        // when the very first stratum is the overflow, the
1366        // newline is the only delimiter).
1367        assert!(
1368            !out.starts_with(',') && !out.contains(",\n  ("),
1369            "first-stratum overflow path should not produce a comma-wrap; \
1370             got: {out:?}"
1371        );
1372    }
1373
1374    /// Repeated active-phase renders during ONE phase
1375    /// activation must NOT advance the
1376    /// `LAST_RENDERED_COORDS` tracker. Pinning this
1377    /// invariant ensures the subsequent completed-phase
1378    /// summary still diffs against the prior COMPLETED
1379    /// phase's coords — not against the active phase's
1380    /// own labels (which would collapse the summary to
1381    /// nothing).
1382    #[test]
1383    fn format_coords_block_active_render_does_not_advance_tracker() {
1384        let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1385        // Seed the tracker with the previous completed
1386        // phase's coords.
1387        if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1388            *g = "(profile=default), (sm=OTHER)".into();
1389        }
1390        let active_labels = "(profile=default), (sm=ADA002)";
1391        // Multiple active-phase ticks (tick rate ~1Hz in
1392        // production) — should all see the same prior
1393        // and render the same diff highlight without
1394        // advancing the tracker.
1395        for _ in 0..5 {
1396            let _ = format_coords_block(
1397                active_labels,
1398                /* color */ false,
1399                0,
1400                "  ",
1401                /* summarize_changed_only */ false,
1402            );
1403            let g = LAST_RENDERED_COORDS.lock().expect("lock");
1404            assert_eq!(
1405                g.as_str(),
1406                "(profile=default), (sm=OTHER)",
1407                "active-phase render must NOT advance the tracker"
1408            );
1409        }
1410        // The completed-phase render with the same labels
1411        // advances the tracker.
1412        let _ = format_coords_block(
1413            active_labels,
1414            /* color */ false,
1415            0,
1416            "  ",
1417            /* summarize_changed_only */ true,
1418        );
1419        let g = LAST_RENDERED_COORDS.lock().expect("lock");
1420        assert_eq!(
1421            g.as_str(),
1422            active_labels,
1423            "completed-phase render must advance the tracker"
1424        );
1425    }
1426
1427    /// When NO coords changed between two completed phases,
1428    /// the rendered block is the empty string (no leading
1429    /// space, no parens, no separator) — the line collapses
1430    /// to just `✓ [name] 100%` with the chip / latency line
1431    /// below.
1432    #[test]
1433    fn format_coords_block_no_change_collapses_to_empty() {
1434        let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1435        if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1436            *g = String::new();
1437        }
1438        let labels = "(profile=default)";
1439        // Prime the tracker.
1440        let _ = format_coords_block(labels, false, 0, "  ", true);
1441        // Re-render the same coords — should be empty.
1442        let body = format_coords_block(labels, false, 0, "  ", true);
1443        assert_eq!(
1444            body, "",
1445            "no-change render should collapse to empty: {body:?}"
1446        );
1447    }
1448
1449    /// Tiny in-test context that lets us hand-pick every
1450    /// field. Lives here so the `phase_outcome` golden can run
1451    /// without pulling in `nmbrs-runtime`.
1452    struct TestCtx {
1453        phase_name: String,
1454        phase_seq: Option<(usize, usize)>,
1455        phase_labels: String,
1456        cycles_completed: u64,
1457        cycles_total: u64,
1458        ops_ok: u64,
1459        skips: u64,
1460        errors: u64,
1461        retries: u64,
1462        concurrency: usize,
1463        elapsed_secs: f64,
1464        consumed: u64,
1465        chips: String,
1466        depth_indent: String,
1467        use_color: bool,
1468        outcome: crate::phase_outcome::Outcome,
1469        outcome_errors: Vec<crate::phase_outcome::PhaseErrorDetail>,
1470    }
1471
1472    impl Default for TestCtx {
1473        fn default() -> Self {
1474            Self {
1475                phase_name: String::new(),
1476                phase_seq: None,
1477                phase_labels: String::new(),
1478                cycles_completed: 0,
1479                cycles_total: 0,
1480                ops_ok: 0,
1481                skips: 0,
1482                errors: 0,
1483                retries: 0,
1484                concurrency: 0,
1485                elapsed_secs: 0.0,
1486                consumed: 0,
1487                chips: String::new(),
1488                depth_indent: String::new(),
1489                use_color: false,
1490                outcome: crate::phase_outcome::Outcome::completed(),
1491                outcome_errors: Vec::new(),
1492            }
1493        }
1494    }
1495
1496    impl ReadoutContext for TestCtx {
1497        fn subject_name(&self) -> &str {
1498            &self.phase_name
1499        }
1500        fn subject_seq(&self) -> Option<(usize, usize)> {
1501            self.phase_seq
1502        }
1503        fn subject_labels(&self) -> &str {
1504            &self.phase_labels
1505        }
1506        fn cycles_completed(&self) -> u64 {
1507            self.cycles_completed
1508        }
1509        fn cycles_total(&self) -> u64 {
1510            self.cycles_total
1511        }
1512        fn ops_ok(&self) -> u64 {
1513            self.ops_ok
1514        }
1515        fn skips(&self) -> u64 {
1516            self.skips
1517        }
1518        fn errors(&self) -> u64 {
1519            self.errors
1520        }
1521        fn retries(&self) -> u64 {
1522            self.retries
1523        }
1524        fn concurrency(&self) -> usize {
1525            self.concurrency
1526        }
1527        fn elapsed_secs(&self) -> f64 {
1528            self.elapsed_secs
1529        }
1530        fn consumed(&self) -> u64 {
1531            self.consumed
1532        }
1533        fn status_metric_chips(&self) -> String {
1534            self.chips.clone()
1535        }
1536        fn depth_indent(&self) -> &str {
1537            &self.depth_indent
1538        }
1539        fn use_color(&self) -> bool {
1540            self.use_color
1541        }
1542        fn event(&self) -> crate::lifecycle::EventType {
1543            crate::lifecycle::EventType::PhaseEnd
1544        }
1545        fn outcome(&self) -> crate::phase_outcome::Outcome {
1546            self.outcome.clone()
1547        }
1548        fn outcome_errors(&self) -> &[crate::phase_outcome::PhaseErrorDetail] {
1549            &self.outcome_errors
1550        }
1551    }
1552
1553    /// Helper that renders a `PhaseOutcomeReadout` at the labeled
1554    /// LOD against `ctx`. Resets the
1555    /// [`LAST_RENDERED_COORDS`] tracker before invoking so
1556    /// each test sees the first-phase-of-session lens
1557    /// (every coord renders as "new"). Tests that need
1558    /// cross-render diffing call `format_coords_block`
1559    /// directly and manage the tracker themselves.
1560    fn render(ctx: &TestCtx) -> String {
1561        // Test-only serial guard + tracker reset so the
1562        // existing pre-SRD-? snapshot assertions
1563        // (e.g. `no_color_with_coords_and_chips` expecting
1564        // the full coord chain to appear) stay stable
1565        // regardless of whether a prior test in the same
1566        // process advanced the tracker.
1567        let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1568        if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1569            *g = String::new();
1570        }
1571        let mut s = String::new();
1572        let mut buf = StringBuf::new(&mut s);
1573        PhaseOutcomeReadout.render(
1574            ctx,
1575            Lod::Labeled,
1576            ContentMode::Value,
1577            &ReadoutOptions::new(),
1578            &mut buf,
1579        );
1580        s
1581    }
1582
1583    #[test]
1584    fn no_color_no_coords_no_chips() {
1585        // cycles_total=3 (≤10) triggers the ballot-bar variant —
1586        // the completion line shows per-op outcome glyphs so the
1587        // operator can see which ops succeeded or failed at a
1588        // glance, preserving the visibility from `phase_status`
1589        // through to the completion record.
1590        let ctx = TestCtx {
1591            phase_name: "setup".into(),
1592            phase_seq: Some((1, 2)),
1593            cycles_completed: 3,
1594            cycles_total: 3,
1595            ops_ok: 3,
1596            concurrency: 1,
1597            elapsed_secs: 0.01,
1598            consumed: 3,
1599            ..Default::default()
1600        };
1601        assert_eq!(
1602            render(&ctx),
1603            "✓ ☑☑☑ [setup] 100%\n    300/s ok:100% e:0 r:0 c:1 (0.01s)"
1604        );
1605    }
1606
1607    #[test]
1608    fn no_color_with_coords_and_chips() {
1609        let ctx = TestCtx {
1610            phase_name: "run".into(),
1611            phase_seq: Some((1, 8)),
1612            phase_labels: "(profile=alpha), (bucket=1, kind=READ)".into(),
1613            cycles_completed: 162,
1614            cycles_total: 162,
1615            ops_ok: 162,
1616            concurrency: 1,
1617            elapsed_secs: 0.01,
1618            consumed: 162,
1619            chips: " recall_at_10:79.62%".into(),
1620            ..Default::default()
1621        };
1622        assert_eq!(
1623            render(&ctx),
1624            "✓ [run] (profile=alpha), (bucket=1, kind=READ) 100%\n    16.2K/s ok:100% e:0 r:0 c:1 recall_at_10:79.62% (0.01s)"
1625        );
1626    }
1627
1628    fn render_at(ctx: &TestCtx, lod: Lod, mode: ContentMode) -> String {
1629        // Isolate every LOD render from the process-global
1630        // `LAST_RENDERED_COORDS` tracker, exactly as the
1631        // [`render`] helper does: take the serial guard and reset
1632        // the tracker so a prior render (in this test or another
1633        // running concurrently) can't make this one elide its
1634        // coords as "unchanged". Without this the completed-phase
1635        // summary path (`summarize_changed_only`) intermittently
1636        // dropped the coords line under cargo-test's parallel
1637        // runner. No production code holds this lock — realtime
1638        // readout dispatch is single-threaded; the guard exists
1639        // only to serialise the test task pool. (No guard-holding
1640        // helper calls `render_at`, so re-entrancy can't occur.)
1641        let _serial = SERIAL_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1642        if let Ok(mut g) = LAST_RENDERED_COORDS.lock() {
1643            *g = String::new();
1644        }
1645        let mut s = String::new();
1646        let mut buf = StringBuf::new(&mut s);
1647        PhaseOutcomeReadout.render(ctx, lod, mode, &ReadoutOptions::new(), &mut buf);
1648        s
1649    }
1650
1651    /// Regression: two identical completed-phase renders must
1652    /// BOTH show their scope coords. Before `render_at` reset the
1653    /// global coord tracker, the second render diffed against the
1654    /// first and elided the coords (`summarize_changed_only`) —
1655    /// the intermittent `expanded_value_*` failure under parallel
1656    /// test execution. With the reset each render is independent.
1657    #[test]
1658    fn render_at_isolates_each_render_from_prior_tracker() {
1659        let ctx = TestCtx {
1660            phase_name: "ann_query".into(),
1661            phase_seq: Some((1, 8)),
1662            phase_labels: "profile=alpha, k=10".into(),
1663            cycles_completed: 100,
1664            cycles_total: 100,
1665            ops_ok: 99,
1666            errors: 1,
1667            retries: 0,
1668            concurrency: 4,
1669            elapsed_secs: 1.5,
1670            consumed: 100,
1671            chips: " recall_at_10:79.62% latency_p99:1.23ms".into(),
1672            ..Default::default()
1673        };
1674        let first = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1675        let second = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1676        assert!(
1677            first.contains("profile=alpha, k=10"),
1678            "first render should carry coords: {first}"
1679        );
1680        assert!(
1681            second.contains("profile=alpha, k=10"),
1682            "second render must NOT elide coords (tracker must reset \
1683             per render_at call): {second}"
1684        );
1685    }
1686
1687    #[test]
1688    fn compact_value_drops_seq_rate_counts_chips() {
1689        // Push 9g (G17): Compact form is the trained-
1690        // operator scan version. Status glyph + name + pct
1691        // + elapsed only. Seq prefix, rate, ok-pct,
1692        // errors / retries / concurrency, scope coords, and
1693        // chips are all dropped. Every retained field
1694        // appears in Labeled (§3.3 monotonicity).
1695        let ctx = TestCtx {
1696            phase_name: "setup".into(),
1697            phase_seq: Some((1, 2)),
1698            phase_labels: "(profile=alpha)".into(),
1699            cycles_completed: 3,
1700            cycles_total: 3,
1701            ops_ok: 3,
1702            errors: 0,
1703            retries: 0,
1704            concurrency: 1,
1705            elapsed_secs: 0.01,
1706            consumed: 3,
1707            chips: " recall_at_10:79.62%".into(),
1708            ..Default::default()
1709        };
1710        assert_eq!(
1711            render_at(&ctx, Lod::Compact, ContentMode::Value),
1712            "✓ [setup] 100% (0.01s)",
1713        );
1714    }
1715
1716    #[test]
1717    fn compact_value_pct_zero_when_no_extent() {
1718        // total_extent == 0 → 100% per the readout's no-
1719        // extent convention (the phase ran without a
1720        // declared cycle count and is by definition complete
1721        // at this fire).
1722        let ctx = TestCtx {
1723            phase_name: "x".into(),
1724            cycles_completed: 0,
1725            cycles_total: 0,
1726            elapsed_secs: 0.5,
1727            ..Default::default()
1728        };
1729        assert_eq!(
1730            render_at(&ctx, Lod::Compact, ContentMode::Value),
1731            "✓ [x] 100% (0.50s)",
1732        );
1733    }
1734
1735    #[test]
1736    fn compact_explanation_describes_each_field() {
1737        let ctx = TestCtx {
1738            phase_name: "x".into(),
1739            ..Default::default()
1740        };
1741        let s = render_at(&ctx, Lod::Compact, ContentMode::Explanation);
1742        assert!(s.contains("done"), "expected 'done': {s}");
1743        assert!(s.contains("phase-name"), "expected 'phase-name': {s}");
1744        assert!(s.contains("progress%"), "expected 'progress%': {s}");
1745        assert!(s.contains("(elapsed)"), "expected '(elapsed)': {s}");
1746        // Compact's overlay must NOT describe fields it
1747        // doesn't show (rate, ok-pct, errors, retries,
1748        // concurrency, coords, chips, seq).
1749        assert!(
1750            !s.contains("idx/total"),
1751            "compact must not describe seq prefix it doesn't render: {s}"
1752        );
1753        assert!(
1754            !s.contains("throughput"),
1755            "compact must not describe throughput it doesn't render: {s}"
1756        );
1757    }
1758
1759    #[test]
1760    fn expanded_value_emits_multi_line_block() {
1761        // Expanded form: same data as Labeled, organised
1762        // one field per line.
1763        let ctx = TestCtx {
1764            phase_name: "ann_query".into(),
1765            phase_seq: Some((1, 8)),
1766            phase_labels: "profile=alpha, k=10".into(),
1767            cycles_completed: 100,
1768            cycles_total: 100,
1769            ops_ok: 99,
1770            errors: 1,
1771            retries: 0,
1772            concurrency: 4,
1773            elapsed_secs: 1.5,
1774            consumed: 100,
1775            chips: " recall_at_10:79.62% latency_p99:1.23ms".into(),
1776            ..Default::default()
1777        };
1778        let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1779        // Header line carries the same identity as Labeled.
1780        assert!(s.contains("✓ [ann_query]"));
1781        assert!(!s.contains("[1/8]"));
1782        assert!(s.contains("profile=alpha, k=10"));
1783        // Per-field labelled rows (every Labeled field
1784        // appears here too — §3.3 monotonicity).
1785        assert!(s.contains("progress:    100% (100 of 100)"));
1786        assert!(s.contains("throughput:"));
1787        assert!(s.contains("ok:          99%  (99 of 100)"));
1788        assert!(s.contains("reliability: e:1 r:0"));
1789        assert!(s.contains("concurrency: 4"));
1790        assert!(s.contains("metrics:"));
1791        // Chips broken into one-per-line under `metrics:`.
1792        assert!(s.contains("recall_at_10"));
1793        assert!(s.contains("latency_p99"));
1794        assert!(s.contains("elapsed:     1.50s"));
1795        // Multi-line block — verify line count.
1796        let line_count = s.lines().count();
1797        assert!(
1798            line_count >= 8,
1799            "expanded should be multi-line (got {line_count}): {s}"
1800        );
1801    }
1802
1803    #[test]
1804    fn expanded_value_omits_metrics_block_when_no_chips() {
1805        // metrics: header only renders when there are chips
1806        // to show under it.
1807        let ctx = TestCtx {
1808            phase_name: "setup".into(),
1809            cycles_completed: 1,
1810            cycles_total: 1,
1811            ops_ok: 1,
1812            concurrency: 1,
1813            elapsed_secs: 0.01,
1814            consumed: 1,
1815            chips: String::new(),
1816            ..Default::default()
1817        };
1818        let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1819        assert!(
1820            !s.contains("metrics:"),
1821            "expected no metrics: header when chips empty: {s}"
1822        );
1823    }
1824
1825    #[test]
1826    fn expanded_value_omits_coords_line_when_no_labels() {
1827        let ctx = TestCtx {
1828            phase_name: "x".into(),
1829            phase_labels: String::new(),
1830            cycles_completed: 1,
1831            cycles_total: 1,
1832            ops_ok: 1,
1833            concurrency: 1,
1834            elapsed_secs: 0.01,
1835            consumed: 1,
1836            ..Default::default()
1837        };
1838        let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
1839        assert!(
1840            !s.contains("coords:"),
1841            "expected no coords: line when labels empty: {s}"
1842        );
1843    }
1844
1845    #[test]
1846    fn expanded_explanation_describes_each_row() {
1847        let ctx = TestCtx {
1848            phase_name: "x".into(),
1849            ..Default::default()
1850        };
1851        let s = render_at(&ctx, Lod::Expanded, ContentMode::Explanation);
1852        assert!(s.contains("phase-name"));
1853        assert!(s.contains("progress:"));
1854        assert!(s.contains("throughput:"));
1855        assert!(s.contains("ok:"));
1856        assert!(s.contains("reliability:"));
1857        assert!(s.contains("concurrency:"));
1858        assert!(s.contains("elapsed:"));
1859        // Multi-line.
1860        assert!(s.lines().count() >= 7);
1861    }
1862
1863    #[test]
1864    fn monotonicity_compact_subset_of_labeled() {
1865        // §3.3 invariant: every field shown at Compact
1866        // appears at Labeled too. Verified pragmatically:
1867        // the compact rendering's stripped form (depth,
1868        // glyph, name, pct, elapsed, parens) is
1869        // substring-present in the labeled rendering once
1870        // we drop the seq / rate / ok / counts / chips
1871        // additions.
1872        let ctx = TestCtx {
1873            phase_name: "setup".into(),
1874            cycles_completed: 3,
1875            cycles_total: 3,
1876            ops_ok: 3,
1877            concurrency: 1,
1878            elapsed_secs: 0.01,
1879            consumed: 3,
1880            ..Default::default()
1881        };
1882        let labeled = render_at(&ctx, Lod::Labeled, ContentMode::Value);
1883        let compact = render_at(&ctx, Lod::Compact, ContentMode::Value);
1884        // Identity: "[setup]" appears in both.
1885        assert!(labeled.contains("[setup]") && compact.contains("[setup]"));
1886        // Status glyph appears in both.
1887        assert!(labeled.contains('✓') && compact.contains('✓'));
1888        // Pct appears in both.
1889        assert!(labeled.contains("100%") && compact.contains("100%"));
1890        // Elapsed appears in both.
1891        assert!(labeled.contains("(0.01s)") && compact.contains("(0.01s)"));
1892    }
1893
1894    #[test]
1895    fn explanation_mode_describes_each_field() {
1896        // SRD-63 §3.2: explanation overlay describes glyph
1897        // meaning. Width parity is the author's contract.
1898        let ctx = TestCtx {
1899            phase_name: "setup".into(),
1900            phase_seq: Some((1, 2)),
1901            phase_labels: "(profile=alpha)".into(),
1902            ..Default::default()
1903        };
1904        let mut s = String::new();
1905        let mut buf = StringBuf::new(&mut s);
1906        let n = PhaseOutcomeReadout.render(
1907            &ctx,
1908            Lod::Labeled,
1909            ContentMode::Explanation,
1910            &ReadoutOptions::new(),
1911            &mut buf,
1912        );
1913        assert!(n > 0, "explanation should render");
1914        // Spot-check semantic descriptors are present —
1915        // the user reads "phase-name", "progress%", etc.
1916        // rather than concrete data.
1917        assert!(s.contains("done"), "expected 'done' descriptor: {s}");
1918        assert!(s.contains("phase-name"), "expected 'phase-name': {s}");
1919        assert!(s.contains("scope-coords"), "expected 'scope-coords': {s}");
1920        // Single-placement: the body no longer carries [n/N] (the
1921        // margin owns it), so the explanation must not describe it.
1922        assert!(!s.contains("idx/total"), "seq descriptor must be gone: {s}");
1923        assert!(s.contains("progress%"), "expected 'progress%': {s}");
1924        assert!(s.contains("throughput"), "expected 'throughput': {s}");
1925        assert!(s.contains("ok:ok%"), "expected ok descriptor: {s}");
1926        assert!(s.contains("(elapsed)"), "expected '(elapsed)': {s}");
1927    }
1928
1929    // ── SRD-76 outcome-driven rendering tests ──────────────
1930
1931    /// Failed status → ✗ glyph + failure-flavoured Labeled
1932    /// layout (status class on line 1, first-error message
1933    /// on line 2). Replaces the success-line render that
1934    /// pre-SRD-76 always emitted regardless of outcome.
1935    #[test]
1936    fn labeled_failed_uses_x_glyph_and_first_error_class() {
1937        let ctx = TestCtx {
1938            phase_name: "ensure_compacted".into(),
1939            phase_labels: String::new(),
1940            elapsed_secs: 14400.0,
1941            outcome: crate::phase_outcome::Outcome::failed(),
1942            outcome_errors: vec![crate::phase_outcome::PhaseErrorDetail {
1943                class: "poll_timeout".into(),
1944                message: "phase-poll deadline reached after 14441.3s".into(),
1945                op_name: None,
1946                cycle: None,
1947                op_template: None,
1948                op_resolved: None,
1949                at_nanos: 0,
1950                retryable: false,
1951            }],
1952            ..Default::default()
1953        };
1954        let out = render(&ctx);
1955        assert!(
1956            out.starts_with("✗ "),
1957            "failed render must start with the ✗ glyph: {out:?}"
1958        );
1959        assert!(
1960            out.contains("[ensure_compacted]"),
1961            "phase name still in line 1: {out:?}"
1962        );
1963        assert!(
1964            out.contains("poll_timeout"),
1965            "first-error class on line 1: {out:?}"
1966        );
1967        assert!(
1968            out.contains("phase-poll deadline"),
1969            "first-error message on line 2: {out:?}"
1970        );
1971        assert!(out.contains("(14400.00s)"), "elapsed on line 2: {out:?}");
1972    }
1973
1974    /// Failed with multiple errors surfaces `(+N more)` so
1975    /// the operator knows the Labeled LOD is truncating.
1976    /// Expanded LOD shows the full list.
1977    #[test]
1978    fn labeled_failed_shows_more_count_when_multiple_errors() {
1979        let mk_err = |class: &str, msg: &str| crate::phase_outcome::PhaseErrorDetail {
1980            class: class.into(),
1981            message: msg.into(),
1982            op_name: None,
1983            cycle: None,
1984            op_template: None,
1985            op_resolved: None,
1986            at_nanos: 0,
1987            retryable: false,
1988        };
1989        let ctx = TestCtx {
1990            phase_name: "p".into(),
1991            elapsed_secs: 1.0,
1992            // True error count == captured (3); `+N more` is now
1993            // derived from the true `errors()` counter, not the
1994            // captured-buffer length.
1995            errors: 3,
1996            outcome: crate::phase_outcome::Outcome::failed(),
1997            outcome_errors: vec![
1998                mk_err("A", "msg-a"),
1999                mk_err("B", "msg-b"),
2000                mk_err("C", "msg-c"),
2001            ],
2002            ..Default::default()
2003        };
2004        let out = render(&ctx);
2005        assert!(
2006            out.contains("(+2 more)"),
2007            "expected `(+2 more)` truncation marker: {out:?}"
2008        );
2009    }
2010
2011    /// Skipped status renders ~ glyph but keeps the
2012    /// success-flavoured layout (the throughput/counter
2013    /// telemetry is still meaningful for replay).
2014    #[test]
2015    fn labeled_skipped_uses_tilde_glyph() {
2016        let ctx = TestCtx {
2017            phase_name: "rampup".into(),
2018            outcome: crate::phase_outcome::Outcome::skipped(),
2019            cycles_completed: 0,
2020            cycles_total: 0,
2021            elapsed_secs: 0.0,
2022            ..Default::default()
2023        };
2024        let out = render(&ctx);
2025        assert!(
2026            out.starts_with("~ "),
2027            "skipped render uses ~ glyph: {out:?}"
2028        );
2029        assert!(out.contains("[rampup]"), "phase name preserved: {out:?}");
2030    }
2031
2032    /// Expanded LOD on a Failed phase appends a per-error
2033    /// stanza below the standard block. Each error renders
2034    /// its class, message, and (when populated) cycle /
2035    /// op-template / op-resolved.
2036    #[test]
2037    fn expanded_failed_includes_per_error_block() {
2038        let ctx = TestCtx {
2039            phase_name: "ann_query".into(),
2040            cycles_completed: 5,
2041            cycles_total: 10,
2042            elapsed_secs: 1.5,
2043            outcome: crate::phase_outcome::Outcome::failed(),
2044            outcome_errors: vec![crate::phase_outcome::PhaseErrorDetail {
2045                class: "Timeout".into(),
2046                message: "read timed out".into(),
2047                op_name: Some("read".into()),
2048                cycle: Some(3),
2049                op_template: Some("SELECT * FROM ks.t WHERE k = {cycle}".into()),
2050                op_resolved: Some("SELECT * FROM ks.t WHERE k = 3".into()),
2051                at_nanos: 0,
2052                retryable: true,
2053            }],
2054            ..Default::default()
2055        };
2056        let s = render_at(&ctx, Lod::Expanded, ContentMode::Value);
2057        assert!(
2058            s.contains("✗ [ann_query]"),
2059            "expanded header uses ✗ glyph for Failed: {s}"
2060        );
2061        assert!(s.contains("status:"), "status row present: {s}");
2062        assert!(s.contains("failed"), "status label shows 'failed': {s}");
2063        assert!(s.contains("errors:"), "errors header present: {s}");
2064        assert!(
2065            s.contains("[Timeout] read timed out"),
2066            "per-error class+message: {s}"
2067        );
2068        assert!(
2069            s.contains("cycle:") && s.contains(" 3"),
2070            "cycle row present when populated: {s}"
2071        );
2072        assert!(s.contains("op-template:"), "op-template row present: {s}");
2073        assert!(s.contains("op-resolved:"), "op-resolved row present: {s}");
2074    }
2075
2076    /// Compact LOD glyph is driven by the two-axis outcome —
2077    /// untrustworthy → ✗, skipped → ~, re-usable partial → …,
2078    /// completed-and-trustworthy → ✓.
2079    #[test]
2080    fn compact_glyph_tracks_outcome_status() {
2081        use crate::phase_outcome::Outcome;
2082        for (outcome, want) in [
2083            (Outcome::completed(), '✓'),
2084            (Outcome::failed(), '✗'),
2085            (Outcome::completed_failed(), '✗'),
2086            (Outcome::skipped(), '~'),
2087            (Outcome::interrupted(), '…'),
2088        ] {
2089            let ctx = TestCtx {
2090                phase_name: "x".into(),
2091                elapsed_secs: 0.1,
2092                outcome,
2093                ..Default::default()
2094            };
2095            let s = render_at(&ctx, Lod::Compact, ContentMode::Value);
2096            assert!(
2097                s.starts_with(want),
2098                "compact glyph for {want:?} missing: {s:?}"
2099            );
2100        }
2101    }
2102
2103    /// Failed ≤10-op phase carries the ballot bar so the
2104    /// operator sees the per-op success/failure pattern that
2105    /// preceded the failure, not just the error class.
2106    #[test]
2107    fn failed_small_phase_renders_ballot_bar_with_errors_first() {
2108        let ctx = TestCtx {
2109            phase_name: "tiny".into(),
2110            cycles_completed: 5,
2111            cycles_total: 5,
2112            ops_ok: 3,
2113            errors: 2,
2114            elapsed_secs: 0.5,
2115            outcome: crate::phase_outcome::Outcome::failed(),
2116            outcome_errors: vec![crate::phase_outcome::PhaseErrorDetail {
2117                class: "Timeout".into(),
2118                message: "deadline exceeded".into(),
2119                op_name: None,
2120                cycle: None,
2121                op_template: None,
2122                op_resolved: None,
2123                at_nanos: 0,
2124                retryable: false,
2125            }],
2126            ..Default::default()
2127        };
2128        let out = render(&ctx);
2129        // 2 errors → ☒☒, 3 successes → ☑☑☑.
2130        assert!(
2131            out.starts_with("✗ ☒☒☑☑☑ "),
2132            "failed ≤10 phase should lead with bar (errors first): {out:?}"
2133        );
2134    }
2135
2136    #[test]
2137    fn err_color_promotes_when_errors_or_retries() {
2138        let ctx = TestCtx {
2139            phase_name: "run".into(),
2140            phase_seq: Some((1, 1)),
2141            cycles_completed: 10,
2142            cycles_total: 10,
2143            ops_ok: 9,
2144            errors: 1,
2145            retries: 0,
2146            concurrency: 1,
2147            elapsed_secs: 1.0,
2148            consumed: 10,
2149            use_color: true,
2150            ..Default::default()
2151        };
2152        let out = render(&ctx);
2153        // Yellow used (errors > 0) — confirm the ANSI code
2154        // sequence appears around the `e:` chunk. Tail line
2155        // carries the counters under the new two-line layout.
2156        assert!(
2157            out.contains("\x1b[33me:1 r:0\x1b[0m"),
2158            "expected yellow err_color around `e:1 r:0`, got: {out:?}"
2159        );
2160        assert!(
2161            out.contains('\n'),
2162            "expected two-line break in labeled render: {out:?}"
2163        );
2164    }
2165    /// A phase whose ops ALL `if:`-skipped reads as gated off — under
2166    /// the default `skipped_phases=mark` mode the completion is the
2167    /// explicit ⊘ form: skip counter, no rate, no ok%, no fabricated
2168    /// measurement of any kind.
2169    #[test]
2170    fn fully_skipped_phase_shows_skip_chip_not_fake_ok() {
2171        let ctx = TestCtx {
2172            phase_name: "recall_postcompact".into(),
2173            cycles_completed: 10_000,
2174            cycles_total: 10_000,
2175            skips: 10_000,
2176            elapsed_secs: 0.02,
2177            consumed: 10_000,
2178            concurrency: 20,
2179            ..TestCtx::default()
2180        };
2181        let out = render(&ctx);
2182        assert!(out.contains("gated off"), "explicit skip marker: {out:?}");
2183        assert!(out.contains('⊘'), "skip glyph shown: {out:?}");
2184        assert!(out.contains("skip:10000"), "skip count shown: {out:?}");
2185        assert!(
2186            !out.contains("ok:"),
2187            "no ok% of any kind on a gated-off phase: {out:?}"
2188        );
2189        assert!(!out.contains("/s"), "no rate on a gated-off phase: {out:?}");
2190    }
2191}