metrale_circuit/display/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-28: `met circuit display`: the human view of one fusion plan. It builds a styled
4//! document (lines of spans, each span a semantic [`Style`]) from the same circuit and plan
5//! `met circuit show` prints; the CLI maps styles to colours.
6//!
7//! Owner: metrale-circuit.
8//! Invariants:
9//! - Pure: no environment, no TTY probe, no ANSI. Width and glyph set are inputs.
10//! - No line is wider than [`DisplayOpts::width`]; below [`COMPACT_BELOW`] columns the layout
11//!   drops the side annotation column instead of wrapping boxes.
12//! - A materialised edge is never drawn inside a fused frame: inside a frame every hand-off is
13//!   on-chip, and what the kernel also writes is named on the frame's bottom edge.
14
15use crate::fuser::FusionPlan;
16use crate::ir::Circuit;
17
18mod card;
19mod diagram;
20mod draw;
21pub mod glyphs;
22mod labels;
23mod rows;
24mod segments;
25mod strip;
26
27pub use glyphs::Glyphs;
28
29/// 2026-09-28: Below this width the diagrams move annotations onto their own lines.
30pub const COMPACT_BELOW: usize = 72;
31/// 2026-09-28: The narrowest width the renderer lays out.
32pub const MIN_WIDTH: usize = 40;
33
34/// 2026-09-28: What a span means; the CLI picks its colour.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum Style {
37    /// 2026-09-28: Body text.
38    Plain,
39    /// 2026-09-28: Titles.
40    Heading,
41    /// 2026-09-28: Emphasised values (counts, the digest).
42    Accent,
43    /// 2026-09-28: Secondary text.
44    Dim,
45    /// 2026-09-28: Card and light-op borders.
46    Border,
47    /// 2026-09-28: A heavy (opaque) op: projections, attention, the recurrence, experts.
48    OpHeavy,
49    /// 2026-09-28: A light op: norms, adds, activations, quantization.
50    OpLight,
51    /// 2026-09-28: A materialised edge (a DRAM round trip).
52    EdgeMaterialized,
53    /// 2026-09-28: A fused edge (stays on chip).
54    EdgeFused,
55    /// 2026-09-28: A fused group's frame and title.
56    FusedFrame,
57    /// 2026-09-28: A tensor format or shape.
58    Format,
59    /// 2026-09-28: A bit-identical numerics badge.
60    NumericsBitIdentical,
61    /// 2026-09-28: A reference numerics badge.
62    NumericsReference,
63    /// 2026-09-28: A differs numerics badge.
64    NumericsDiffers,
65    /// 2026-09-28: A GatedDeltaNet layer glyph.
66    LayerGdn,
67    /// 2026-09-28: An attention layer glyph.
68    LayerAttn,
69    /// 2026-09-28: A dense FFN glyph.
70    LayerDense,
71    /// 2026-09-28: A MoE FFN glyph.
72    LayerMoe,
73}
74
75/// 2026-09-28: A run of text in one style.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Span {
78    /// 2026-09-28: The text; never contains a newline.
79    pub text: String,
80    /// 2026-09-28: Its meaning.
81    pub style: Style,
82}
83
84/// 2026-09-28: One output line.
85#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct Line {
87    /// 2026-09-28: Spans, left to right.
88    pub spans: Vec<Span>,
89}
90
91impl Line {
92    /// 2026-09-28: Terminal columns: every glyph the renderer emits is one column wide.
93    pub fn width(&self) -> usize {
94        self.spans.iter().map(|s| s.text.chars().count()).sum()
95    }
96
97    /// 2026-09-28: The text without styles.
98    pub fn plain(&self) -> String {
99        self.spans.iter().map(|s| s.text.as_str()).collect()
100    }
101
102    pub(crate) fn push(&mut self, text: impl Into<String>, style: Style) -> &mut Self {
103        let text = text.into();
104        if !text.is_empty() {
105            self.spans.push(Span { text, style });
106        }
107        self
108    }
109
110    pub(crate) fn pad_to(&mut self, width: usize) -> &mut Self {
111        let w = self.width();
112        if w < width {
113            self.push(" ".repeat(width - w), Style::Plain);
114        }
115        self
116    }
117}
118
119/// 2026-09-28: A rendered document.
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
121pub struct Document {
122    /// 2026-09-28: Lines, top to bottom.
123    pub lines: Vec<Line>,
124}
125
126impl Document {
127    /// 2026-09-28: The uncoloured text, trailing spaces trimmed, one `\n` per line.
128    pub fn plain(&self) -> String {
129        let mut out = String::new();
130        for l in &self.lines {
131            out.push_str(l.plain().trim_end());
132            out.push('\n');
133        }
134        out
135    }
136}
137
138/// 2026-09-28: How much of the model to draw.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Expand {
141    /// 2026-09-28: One diagram per distinct layer plan, with a `× N layers` badge.
142    Summary,
143    /// 2026-09-28: One concrete layer, with its module bindings.
144    Layer(usize),
145    /// 2026-09-28: Every layer.
146    AllLayers,
147}
148
149/// 2026-09-28: Rendering options.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct DisplayOpts {
152    /// 2026-09-28: Columns available.
153    pub width: usize,
154    /// 2026-09-28: Glyph set.
155    pub glyphs: Glyphs,
156    /// 2026-09-28: How much to draw.
157    pub expand: Expand,
158}
159
160/// 2026-09-28: Facts about the instance the plan came from, for the header card.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct DisplayInfo {
163    /// 2026-09-28: Checkpoint id.
164    pub checkpoint: String,
165    /// 2026-09-28: Recipe id.
166    pub recipe: String,
167    /// 2026-09-28: Bytes of materialised edges per step and the arena they fit in, when the
168    /// buffer planner laid the plan out.
169    pub bytes: Option<(u64, u64)>,
170    /// 2026-10-02: Each node's pipeline as facts (`crate::pipeline::PlanPipelines::facts`), drawn
171    /// under its box; `None` (or an empty list) draws none.
172    pub pipelines: Vec<Option<Vec<String>>>,
173}
174
175/// 2026-09-28: Why a document was not produced.
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
177pub enum DisplayError {
178    /// 2026-09-28: `--layer` past the model's layers.
179    #[error("layer {layer} is out of range: this model has layers 0..={last}")]
180    LayerOutOfRange {
181        /// 2026-09-28: Requested layer.
182        layer: usize,
183        /// 2026-09-28: Last valid layer.
184        last: usize,
185    },
186    /// 2026-09-28: `--layer` for a plan whose section has no layers (the draft head).
187    #[error("the {0} plan has no numbered layers; drop --layer")]
188    NoLayers(&'static str),
189    /// 2026-09-28: Narrower than [`MIN_WIDTH`].
190    #[error("width {0} is below the {MIN_WIDTH}-column minimum")]
191    TooNarrow(usize),
192}
193
194/// 2026-09-28: Render `plan` of `circuit`.
195pub fn display(
196    circuit: &Circuit,
197    plan: &FusionPlan,
198    info: &DisplayInfo,
199    opts: &DisplayOpts,
200) -> Result<Document, DisplayError> {
201    if opts.width < MIN_WIDTH {
202        return Err(DisplayError::TooNarrow(opts.width));
203    }
204    let layers = diagram::layers_in_plan(circuit, plan);
205    if let Expand::Layer(n) = opts.expand {
206        let Some(&last) = layers.last() else {
207            return Err(DisplayError::NoLayers(plan.mode.name()));
208        };
209        if !layers.contains(&n) {
210            return Err(DisplayError::LayerOutOfRange { layer: n, last });
211        }
212    }
213    let g = opts.glyphs.set();
214    let mut doc = Document::default();
215    card::header(&mut doc, circuit, plan, info, opts.width, &g);
216    if !layers.is_empty() {
217        strip::strip(&mut doc, circuit, &layers, opts.width, &g);
218    }
219    diagram::diagrams(&mut doc, circuit, plan, opts, &g, &info.pipelines);
220    card::footer(&mut doc, plan, info, opts.width, &g);
221    for l in &mut doc.lines {
222        fit(l, opts.width, &g);
223    }
224    Ok(doc)
225}
226
227/// 2026-09-28: Truncate a line to `width` columns, ending in the ellipsis. The layout sizes
228/// every line to fit; this is the backstop for a long module name.
229pub(crate) fn fit(line: &mut Line, width: usize, g: &glyphs::Set) {
230    if line.width() <= width {
231        return;
232    }
233    let keep = width.saturating_sub(g.ellipsis.chars().count());
234    let mut used = 0;
235    let mut out = Vec::new();
236    for s in line.spans.drain(..) {
237        let n = s.text.chars().count();
238        if used + n <= keep {
239            used += n;
240            out.push(s);
241            continue;
242        }
243        let text: String = s.text.chars().take(keep - used).collect();
244        out.push(Span {
245            text,
246            style: s.style,
247        });
248        out.push(Span {
249            text: g.ellipsis.to_string(),
250            style: Style::Dim,
251        });
252        break;
253    }
254    line.spans = out;
255}
256
257/// 2026-09-28: Truncate `text` to `max` columns with the ellipsis.
258pub(crate) fn clip(text: &str, max: usize, g: &glyphs::Set) -> String {
259    if text.chars().count() <= max {
260        return text.to_string();
261    }
262    let e = g.ellipsis.chars().count();
263    let mut s: String = text.chars().take(max.saturating_sub(e)).collect();
264    s.push_str(g.ellipsis);
265    s.chars().take(max).collect()
266}