Skip to main content

candle_graph/
report.rs

1//! Output formats.
2//!
3//! Two consumers, two shapes. The tree is for humans. The JSON is for agents, and is
4//! deliberately *not* a raw dump: it leads with coverage and diagnostics so a reader learns how
5//! much of the model was actually seen before reading any of it.
6
7use crate::ir::*;
8use crate::load::Crate;
9use crate::verify::VerifyReport;
10use crate::{
11    dataflow::{EdgeKind, ExprGraph, GradState, NodeKind},
12    op_semantics::AbstractDtype,
13};
14
15/// Human-facing indented tree.
16pub fn tree(structure: &Structure, krate: &Crate, show_spans: bool) -> String {
17    let mut out = String::new();
18    let Some(root) = structure.root else {
19        return "no root instance\n".to_string();
20    };
21    render(structure, krate, root, 0, show_spans, &mut out);
22
23    let coverage = structure.coverage();
24    out.push_str(&format!(
25        "\n{} instances, {} parameters ({} certain, {} conditional, {} unknown), {} diagnostics\n",
26        coverage.instances,
27        coverage.params,
28        coverage.params_certain,
29        coverage.params_conditional,
30        coverage.params_unknown,
31        coverage.diagnostics,
32    ));
33    out
34}
35
36fn render(
37    structure: &Structure,
38    krate: &Crate,
39    id: ModuleInstanceId,
40    depth: usize,
41    show_spans: bool,
42    out: &mut String,
43) {
44    let instance = structure.instance(id);
45    let def = structure.def(instance.def);
46    let pad = "  ".repeat(depth);
47
48    let label = match &instance.via_field {
49        Some(field) => format!("{field}: {}", def.name),
50        None => def.name.clone(),
51    };
52    let prefix = if instance.prefix.is_empty() {
53        "<root>".to_string()
54    } else {
55        instance.prefix.to_string()
56    };
57
58    out.push_str(&format!("{pad}{label}  [{}/{prefix}]", instance.root));
59    if instance.prefix_derived {
60        out.push('~');
61    }
62    if let Some(repeat) = &instance.repeat {
63        out.push_str(&format!("  x{{{}}} over {}", repeat.var, repeat.bound));
64    }
65    if let Certainty::Conditional(reason) = &instance.certainty {
66        out.push_str(&format!("  (conditional: {reason})"));
67    }
68    if show_spans {
69        out.push_str(&format!("  @{}", krate.file_label(instance.origin)));
70    }
71    out.push('\n');
72
73    for param_id in structure
74        .params
75        .iter()
76        .filter(|p| p.owner == id)
77        .map(|p| p.id)
78    {
79        let param = &structure.params[param_id.0];
80        let site = structure.site(param.site);
81        // Show the key relative to the owning module's prefix, so a leaf reads
82        // `input_layernorm.weight` rather than a bare `weight` that could be any of several.
83        let leaf = relative(&param.key, &instance.prefix);
84
85        out.push_str(&format!("{pad}  - {leaf}"));
86        if let Some(shape) = &site.shape {
87            out.push_str(&format!("  shape={shape}"));
88        }
89        match &param.checkpoint {
90            CheckpointMatch::Found { shape, dtype, .. } => {
91                out.push_str(&format!("  ckpt={shape:?} {dtype}"));
92            }
93            CheckpointMatch::FoundMany { count, .. } => {
94                out.push_str(&format!("  ckpt={count} tensors"));
95            }
96            CheckpointMatch::Missing => out.push_str("  ckpt=MISSING"),
97            CheckpointMatch::NotChecked => {}
98        }
99        if let Certainty::Conditional(reason) = &param.certainty {
100            out.push_str(&format!("  (conditional: {reason})"));
101        }
102        if show_spans {
103            out.push_str(&format!("  @{}", krate.file_label(site.span)));
104        }
105        out.push('\n');
106    }
107
108    for child in &instance.children {
109        render(structure, krate, *child, depth + 1, show_spans, out);
110    }
111}
112
113/// Strip a module prefix from a parameter key for display. Falls back to the full key when the
114/// prefix does not match, which is the honest thing to show if the two ever diverge.
115fn relative(key: &Key, prefix: &Key) -> String {
116    if key.segs.len() > prefix.segs.len() && key.segs[..prefix.segs.len()] == prefix.segs[..] {
117        let rest: Vec<String> = key.segs[prefix.segs.len()..]
118            .iter()
119            .map(|s| s.to_string())
120            .collect();
121        return rest.join(".");
122    }
123    key.to_string()
124}
125
126/// Flat parameter list, one dotted key per line. This is the form to diff in CI.
127pub fn keys(structure: &Structure) -> String {
128    let mut lines: Vec<String> = structure
129        .params
130        .iter()
131        .map(|p| {
132            let marker = match &p.certainty {
133                Certainty::Certain => "",
134                Certainty::Conditional(_) => "  # conditional",
135                Certainty::Unknown(_) => "  # unknown",
136            };
137            format!("{}{marker}", p.key)
138        })
139        .collect();
140    lines.sort();
141    lines.dedup();
142    lines.join("\n") + "\n"
143}
144
145/// Agent-facing JSON. Coverage and diagnostics come first on purpose.
146pub fn json(
147    structure: &Structure,
148    krate: &Crate,
149    verify: Option<&VerifyReport>,
150) -> serde_json::Value {
151    let diagnostics: Vec<serde_json::Value> = structure
152        .diagnostics
153        .iter()
154        .map(|d| {
155            serde_json::json!({
156                "at": krate.file_label(d.span),
157                "message": d.message,
158                "key": d.key.as_ref().map(|k| k.to_string()),
159            })
160        })
161        .collect();
162
163    let params: Vec<serde_json::Value> = structure
164        .params
165        .iter()
166        .map(|p| {
167            let site = structure.site(p.site);
168            let owner = structure.instance(p.owner);
169            serde_json::json!({
170                "key": p.key.to_string(),
171                "root": p.root,
172                "template": p.key.is_template(),
173                "kind": site.kind,
174                "shape": site.shape,
175                "acquired_via": site.acquisition,
176                "module": structure.def(owner.def).name,
177                "module_prefix": owner.prefix.to_string(),
178                "certainty": p.certainty,
179                "checkpoint": p.checkpoint,
180                "at": krate.file_label(site.span),
181            })
182        })
183        .collect();
184
185    let modules: Vec<serde_json::Value> = structure
186        .instances
187        .iter()
188        .map(|m| {
189            serde_json::json!({
190                "id": m.id.0,
191                "prefix": m.prefix.to_string(),
192                "root": m.root,
193                "prefix_derived": m.prefix_derived,
194                "type": structure.def(m.def).name,
195                "field": m.via_field,
196                "parent": m.parent.map(|p| structure.instance(p).prefix.to_string()),
197                "parent_id": m.parent.map(|p| p.0),
198                "repeat": m.repeat,
199                "certainty": m.certainty,
200                "at": krate.file_label(m.origin),
201            })
202        })
203        .collect();
204
205    serde_json::json!({
206        "schema": "candle-graph/structure/1",
207        "coverage": structure.coverage(),
208        "diagnostics": diagnostics,
209        "verify": verify,
210        "modules": modules,
211        "parameters": params,
212    })
213}
214
215/// Viewer- and agent-facing dataflow JSON with stable labels and human-readable source locations.
216pub fn dataflow_json(graph: &ExprGraph, krate: &Crate) -> serde_json::Value {
217    let nodes: Vec<serde_json::Value> = graph
218        .nodes
219        .iter()
220        .map(|node| {
221            let (kind, label) = node_kind(&node.kind);
222            serde_json::json!({
223                "id": format!("n{}", node.id.0),
224                "kind": kind,
225                "label": label,
226                "shape": node.shape,
227                "dtype": dtype_name(node.dtype),
228                "grad_state": grad_name(node.grad),
229                "at": krate.file_label(node.span),
230            })
231        })
232        .collect();
233    let edges: Vec<serde_json::Value> = graph
234        .edges
235        .iter()
236        .map(|edge| {
237            serde_json::json!({
238                "id": format!("e{}", edge.id.0),
239                "from": format!("n{}", edge.from.0),
240                "to": format!("n{}", edge.to.0),
241                "kind": edge_kind(edge.kind),
242                "label": edge.label,
243            })
244        })
245        .collect();
246    let conflicts: Vec<serde_json::Value> = graph
247        .dtype_conflicts
248        .iter()
249        .map(|conflict| {
250            serde_json::json!({
251                "node": format!("n{}", conflict.edge_or_node.0),
252                "op": conflict.op,
253                "left": dtype_name(conflict.left),
254                "right": dtype_name(conflict.right),
255                "at": krate.file_label(conflict.span),
256                "message": conflict.message,
257            })
258        })
259        .collect();
260    let risks: Vec<serde_json::Value> = graph
261        .dtype_risks
262        .iter()
263        .map(|risk| {
264            serde_json::json!({
265                "node": format!("n{}", risk.edge_or_node.0),
266                "op": risk.op,
267                "known": dtype_name(risk.known),
268                "at": krate.file_label(risk.span),
269                "message": risk.message,
270            })
271        })
272        .collect();
273    let diagnostics: Vec<serde_json::Value> = graph
274        .diagnostics
275        .iter()
276        .map(|diagnostic| {
277            serde_json::json!({
278                "at": krate.file_label(diagnostic.span),
279                "message": diagnostic.message,
280            })
281        })
282        .collect();
283
284    serde_json::json!({
285        "schema": "candle-graph/dataflow/1",
286        "coverage": {
287            "nodes": graph.nodes.len(),
288            "edges": graph.edges.len(),
289            "parameters": graph.param_nodes.len(),
290            "losses": graph.loss_nodes.len(),
291            "dead_parameters": graph.dead_params().len(),
292            "severing_edges": graph.severing_edges().len(),
293            "dtype_conflicts": graph.dtype_conflicts.len(),
294            "dtype_risks": graph.dtype_risks.len(),
295            "diagnostics": graph.diagnostics.len(),
296        },
297        "entry_return": graph.entry_return.map(|id| format!("n{}", id.0)),
298        "loss_nodes": graph.loss_nodes.iter().map(|id| format!("n{}", id.0)).collect::<Vec<_>>(),
299        "parameter_nodes": graph.param_nodes.iter().map(|id| format!("n{}", id.0)).collect::<Vec<_>>(),
300        "dead_parameters": graph.dead_params().iter().map(|id| format!("n{}", id.0)).collect::<Vec<_>>(),
301        "severing_edges": graph.severing_edges().iter().map(|id| format!("e{}", id.0)).collect::<Vec<_>>(),
302        "dtype_conflicts": conflicts,
303        "dtype_risks": risks,
304        "diagnostics": diagnostics,
305        "nodes": nodes,
306        "edges": edges,
307    })
308}
309
310/// Stable, sorted findings used in human summaries and CI baselines.
311pub fn dataflow_findings(graph: &ExprGraph, krate: &Crate) -> Vec<String> {
312    let mut findings = Vec::new();
313    for conflict in &graph.dtype_conflicts {
314        findings.push(format!(
315            "dtype-conflict\t{}\t{}\t{} vs {}\t{}",
316            krate.file_label(conflict.span),
317            conflict.op,
318            dtype_name(conflict.left),
319            dtype_name(conflict.right),
320            conflict.message
321        ));
322    }
323    for risk in &graph.dtype_risks {
324        findings.push(format!(
325            "dtype-risk\t{}\t{}\tknown {} + unknown\t{}",
326            krate.file_label(risk.span),
327            risk.op,
328            dtype_name(risk.known),
329            risk.message
330        ));
331    }
332    for id in graph.dead_params() {
333        let node = graph.node(id);
334        let (_, label) = node_kind(&node.kind);
335        findings.push(format!(
336            "dead-parameter\t{}\t{}\t{}",
337            krate.file_label(node.span),
338            label,
339            grad_name(node.grad)
340        ));
341    }
342    for edge_id in graph.severing_edges() {
343        let edge = graph.edge(edge_id);
344        let target = graph.node(edge.to);
345        let (_, label) = node_kind(&target.kind);
346        findings.push(format!(
347            "severing-edge\t{}\t{}\t{}",
348            krate.file_label(target.span),
349            label,
350            edge.label.as_deref().unwrap_or("data")
351        ));
352    }
353    findings.sort();
354    findings.dedup();
355    findings
356}
357
358/// Compact human-readable dataflow summary.
359pub fn dataflow_text(graph: &ExprGraph, krate: &Crate) -> String {
360    let findings = dataflow_findings(graph, krate);
361    let mut out = format!(
362        "\ndataflow: {} nodes, {} edges, {} loss sinks, {} dtype conflicts, {} dtype risks, {} dead trainable parameters, {} severing edges\n",
363        graph.nodes.len(),
364        graph.edges.len(),
365        graph.loss_nodes.len(),
366        graph.dtype_conflicts.len(),
367        graph.dtype_risks.len(),
368        graph.dead_params().len(),
369        graph.severing_edges().len(),
370    );
371    for finding in findings {
372        out.push_str("  ");
373        out.push_str(&finding.replace('\t', "  "));
374        out.push('\n');
375    }
376    out
377}
378
379fn node_kind(kind: &NodeKind) -> (&'static str, String) {
380    match kind {
381        NodeKind::Param { name } => ("parameter", name.clone()),
382        NodeKind::Local { name } => ("local", name.clone()),
383        NodeKind::Call { callee } => ("operation", callee.clone()),
384        NodeKind::Literal { text } => ("literal", text.clone()),
385        NodeKind::Phi => ("phi", "branch join".to_string()),
386        NodeKind::Return => ("return", "return".to_string()),
387        NodeKind::Unknown { reason } => ("unknown", reason.clone()),
388    }
389}
390
391fn grad_name(grad: GradState) -> &'static str {
392    match grad {
393        GradState::Trainable => "Trainable",
394        GradState::Frozen => "Frozen",
395        GradState::Differentiable => "Differentiable",
396        GradState::Severed => "Severed",
397        GradState::LayoutDependent => "LayoutDependent",
398        GradState::Unknown => "Unknown",
399    }
400}
401
402fn dtype_name(dtype: AbstractDtype) -> &'static str {
403    match dtype {
404        AbstractDtype::F64 => "F64",
405        AbstractDtype::F32 => "F32",
406        AbstractDtype::F16 => "F16",
407        AbstractDtype::Bf16 => "BF16",
408        AbstractDtype::I16 => "I16",
409        AbstractDtype::I32 => "I32",
410        AbstractDtype::I64 => "I64",
411        AbstractDtype::U32 => "U32",
412        AbstractDtype::U8 => "U8",
413        AbstractDtype::F8E4M3 => "F8E4M3",
414        AbstractDtype::F6E2M3 => "F6E2M3",
415        AbstractDtype::F6E3M2 => "F6E3M2",
416        AbstractDtype::F4 => "F4",
417        AbstractDtype::F8E8M0 => "F8E8M0",
418        AbstractDtype::Unknown => "Unknown",
419    }
420}
421
422fn edge_kind(kind: EdgeKind) -> &'static str {
423    match kind {
424        EdgeKind::Data => "data",
425        EdgeKind::Severing => "severing",
426        EdgeKind::Control => "control",
427    }
428}