Skip to main content

onnx_std/text/
ser.rs

1//! The model → text printer (ONNX_RS §5.4 "Implementation reference").
2//!
3//! Output shape, for a model `Z = Add(X, Y)`:
4//!
5//! ```text
6//! <
7//!   ir_version: 10,
8//!   opset_import: ["" : 21]
9//! >
10//! main (float[2, 3] X, float[2, 3] Y) => (float[2, 3] Z) {
11//!   Z = Add(X, Y)
12//! }
13//! ```
14//!
15//! Design principles honoured here (§5.3): SSA-like syntax, `dtype[shape]`
16//! types, compact `<attr = value>` attributes, `//` comments, weights as
17//! references (never inlined), and nested `graph { ... }` blocks for control-flow
18//! subgraphs.
19
20use std::fmt::Write as _;
21
22use onnx_runtime_ir::{Attribute, DataType, Dim, Graph, NodeId, Shape, ValueId, WeightRef};
23
24use crate::model::Model;
25
26/// Options controlling textual output (ONNX_RS §5.4 `PrintOptions`).
27#[derive(Clone, Debug)]
28pub struct PrintOptions {
29    /// Indentation unit for one nesting level (default `"  "`).
30    pub indent: String,
31    /// Emit a `// initializers` reference block listing weight name + type
32    /// (never the data). Default `true`.
33    pub weight_shapes_only: bool,
34    /// Emit `doc_string`s as trailing `//` comments. Default `false`.
35    pub doc_strings: bool,
36}
37
38impl Default for PrintOptions {
39    fn default() -> Self {
40        Self {
41            indent: "  ".to_string(),
42            weight_shapes_only: true,
43            doc_strings: false,
44        }
45    }
46}
47
48/// Print `model` as text using default [`PrintOptions`].
49pub fn to_text(model: &Model) -> String {
50    to_text_with(model, &PrintOptions::default())
51}
52
53/// Print `model` as text with explicit options.
54pub fn to_text_with(model: &Model, opts: &PrintOptions) -> String {
55    let mut out = String::new();
56    let meta = &model.metadata;
57
58    // Model header block: ir_version + opset imports (sorted for determinism).
59    out.push_str("<\n");
60    let _ = writeln!(out, "{}ir_version: {},", opts.indent, meta.ir_version);
61    let mut imports: Vec<(&String, &u64)> = model.graph.opset_imports.iter().collect();
62    imports.sort_by(|a, b| a.0.cmp(b.0));
63    let imports_str = imports
64        .iter()
65        .map(|(domain, version)| format!("{:?} : {}", domain, version))
66        .collect::<Vec<_>>()
67        .join(", ");
68    let _ = writeln!(out, "{}opset_import: [{}]", opts.indent, imports_str);
69    out.push_str(">\n");
70
71    let graph_name = if meta.graph_name.is_empty() {
72        "main"
73    } else {
74        &meta.graph_name
75    };
76    print_graph(&mut out, &model.graph, graph_name, 0, opts);
77    if let Some(proto) = model.retained_proto() {
78        super::extensions::append(proto, &mut out);
79    }
80    out
81}
82
83/// Print a graph body (top-level or a nested control-flow subgraph).
84fn print_graph(out: &mut String, graph: &Graph, name: &str, depth: usize, opts: &PrintOptions) {
85    let pad = opts.indent.repeat(depth);
86    let inner = opts.indent.repeat(depth + 1);
87
88    let inputs = graph
89        .inputs
90        .iter()
91        .map(|&v| typed_value(graph, v))
92        .collect::<Vec<_>>()
93        .join(", ");
94    let outputs = graph
95        .outputs
96        .iter()
97        .map(|&v| typed_value(graph, v))
98        .collect::<Vec<_>>()
99        .join(", ");
100
101    let _ = writeln!(out, "{}{} ({}) => ({}) {{", pad, name, inputs, outputs);
102
103    // Initializers as references (never inlined data) — §5.3.
104    if opts.weight_shapes_only && !graph.initializers.is_empty() {
105        let _ = writeln!(out, "{}// initializers", inner);
106        let mut inits: Vec<(&ValueId, &WeightRef)> = graph.initializers.iter().collect();
107        inits.sort_by_key(|(v, _)| v.0);
108        for (vid, weight) in inits {
109            let _ = writeln!(
110                out,
111                "{}// {} {} = <{} data omitted>",
112                inner,
113                weight_type(weight),
114                value_name(graph, *vid),
115                weight_kind(weight),
116            );
117        }
118    }
119
120    // Nodes in topological order (falls back to arena order on a cycle so a
121    // malformed graph still dumps rather than panics).
122    let order = graph
123        .topological_order()
124        .unwrap_or_else(|_| graph.nodes.keys().collect());
125    for nid in order {
126        print_node(out, graph, nid, depth + 1, opts);
127    }
128
129    let _ = writeln!(out, "{}}}", pad);
130}
131
132/// Print one node, including any nested subgraph attributes.
133fn print_node(out: &mut String, graph: &Graph, nid: NodeId, depth: usize, opts: &PrintOptions) {
134    let pad = opts.indent.repeat(depth);
135    let node = graph.node(nid);
136
137    let outputs = node
138        .outputs
139        .iter()
140        .map(|&v| value_name(graph, v))
141        .collect::<Vec<_>>()
142        .join(", ");
143
144    // Op name, qualified with a non-default domain.
145    let op = if node.domain.is_empty() || node.domain == "ai.onnx" {
146        node.op_type.clone()
147    } else {
148        format!("{}.{}", node.domain, node.op_type)
149    };
150
151    // Scalar / list attributes rendered inline; subgraph attributes deferred to
152    // nested blocks after the node line.
153    let mut inline_attrs: Vec<(&String, &Attribute)> = node
154        .attributes
155        .iter()
156        .filter(|(_, a)| !is_subgraph_attr(a))
157        .collect();
158    inline_attrs.sort_by(|a, b| a.0.cmp(b.0));
159    let attr_str = if inline_attrs.is_empty() {
160        String::new()
161    } else {
162        let body = inline_attrs
163            .iter()
164            .map(|(k, v)| format!("{} = {}", k, attr_value(v)))
165            .collect::<Vec<_>>()
166            .join(", ");
167        format!(" <{}>", body)
168    };
169
170    let inputs = node
171        .inputs
172        .iter()
173        .map(|slot| match slot {
174            Some(v) => value_name(graph, *v),
175            None => "".to_string(),
176        })
177        .collect::<Vec<_>>()
178        .join(", ");
179
180    let doc = if opts.doc_strings {
181        match &node.doc_string {
182            Some(d) if !d.is_empty() => format!("  // {}", d),
183            _ => String::new(),
184        }
185    } else {
186        String::new()
187    };
188
189    let lhs = if outputs.is_empty() {
190        String::new()
191    } else {
192        format!("{} = ", outputs)
193    };
194    let _ = writeln!(out, "{}{}{}{}({}){}", pad, lhs, op, attr_str, inputs, doc);
195
196    // Nested subgraph bodies (If/Loop/Scan) — §5.3.
197    let mut subgraph_attrs: Vec<&String> = node
198        .attributes
199        .iter()
200        .filter(|(_, a)| is_subgraph_attr(a))
201        .map(|(k, _)| k)
202        .collect();
203    subgraph_attrs.sort();
204    for attr_name in subgraph_attrs {
205        match &node.attributes[attr_name] {
206            Attribute::Graph(inline) => {
207                let sub = graph
208                    .subgraphs
209                    .get(&(nid, attr_name.clone()))
210                    .unwrap_or(inline);
211                let _ = writeln!(out, "{}{} = graph", pad, attr_name);
212                print_graph(out, sub, "", depth, opts);
213            }
214            Attribute::Graphs(inline) => {
215                for (index, fallback) in inline.iter().enumerate() {
216                    let indexed_name = format!("{attr_name}[{index}]");
217                    let sub = graph
218                        .subgraphs
219                        .get(&(nid, indexed_name.clone()))
220                        .unwrap_or(fallback);
221                    let _ = writeln!(out, "{}{} = graph", pad, indexed_name);
222                    print_graph(out, sub, "", depth, opts);
223                }
224            }
225            _ => {}
226        }
227    }
228}
229
230fn is_subgraph_attr(attr: &Attribute) -> bool {
231    match attr {
232        Attribute::Graph(_) => true,
233        Attribute::Graphs(graphs) => !graphs.is_empty(),
234        _ => false,
235    }
236}
237
238/// A value rendered as `dtype[shape] name` (or just `dtype name` for a scalar).
239fn typed_value(graph: &Graph, vid: ValueId) -> String {
240    let value = graph.value(vid);
241    let ty = type_string(value.dtype, &value.shape, graph);
242    format!("{} {}", ty, value_name(graph, vid))
243}
244
245/// `dtype[d0, d1, ...]`, with symbolic dims shown by name.
246fn type_string(dtype: DataType, shape: &Shape, graph: &Graph) -> String {
247    let dims = shape
248        .iter()
249        .map(|d| dim_string(*d, graph))
250        .collect::<Vec<_>>()
251        .join(", ");
252    format!("{}[{}]", dtype_name(dtype), dims)
253}
254
255fn dim_string(dim: Dim, graph: &Graph) -> String {
256    match dim {
257        Dim::Static(n) => n.to_string(),
258        Dim::Symbolic(sid) => graph
259            .symbol_constraints
260            .get(&sid)
261            .and_then(|c| c.name.clone())
262            .map(|name| {
263                if name
264                    .chars()
265                    .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
266                {
267                    name
268                } else {
269                    format!("{name:?}")
270                }
271            })
272            .unwrap_or_else(|| format!("s{}", sid.0)),
273    }
274}
275
276/// The name of a value, falling back to an SSA-style `%vN` for anonymous values.
277fn value_name(graph: &Graph, vid: ValueId) -> String {
278    match graph.try_value(vid).and_then(|v| v.name.as_deref()) {
279        Some(name) if !name.is_empty() => name.to_string(),
280        _ => format!("%v{}", vid.0),
281    }
282}
283
284fn weight_type(weight: &WeightRef) -> String {
285    let dims = weight
286        .dims()
287        .iter()
288        .map(|d| d.to_string())
289        .collect::<Vec<_>>()
290        .join(", ");
291    format!("{}[{}]", dtype_name(weight.dtype()), dims)
292}
293
294fn weight_kind(weight: &WeightRef) -> &'static str {
295    match weight {
296        WeightRef::Inline(_) => "inline",
297        WeightRef::External { .. } => "external",
298    }
299}
300
301/// Render a scalar or list attribute value compactly.
302fn attr_value(attr: &Attribute) -> String {
303    match attr {
304        Attribute::Int(v) => v.to_string(),
305        Attribute::Float(v) => format!("{:?}", v),
306        Attribute::String(bytes) => match std::str::from_utf8(bytes) {
307            Ok(s) => format!("{:?}", s),
308            Err(_) => format!("<{} bytes>", bytes.len()),
309        },
310        Attribute::Ints(v) if v.is_empty() => "[]:ints".to_string(),
311        Attribute::Ints(v) => format!(
312            "[{}]",
313            v.iter()
314                .map(|i| i.to_string())
315                .collect::<Vec<_>>()
316                .join(", ")
317        ),
318        Attribute::Floats(v) if v.is_empty() => "[]:floats".to_string(),
319        Attribute::Floats(v) => format!(
320            "[{}]",
321            v.iter()
322                .map(|f| format!("{:?}", f))
323                .collect::<Vec<_>>()
324                .join(", ")
325        ),
326        Attribute::Strings(values) if values.is_empty() => "[]:strings".to_string(),
327        Attribute::Strings(values) => values
328            .iter()
329            .map(|bytes| std::str::from_utf8(bytes).map(|value| format!("{value:?}")))
330            .collect::<Result<Vec<_>, _>>()
331            .map(|values| format!("[{}]", values.join(", ")))
332            .unwrap_or_else(|_| format!("<{} strings>", values.len())),
333        Attribute::Tensor(t) => format!("<tensor {}[{:?}]>", dtype_name(t.dtype), t.dims),
334        Attribute::Tensors(tensors) => format!("<{} tensors>", tensors.len()),
335        Attribute::SparseTensor(_) => "<sparse tensor>".to_string(),
336        Attribute::SparseTensors(tensors) => format!("<{} sparse tensors>", tensors.len()),
337        Attribute::TypeProto(_) => "<type>".to_string(),
338        Attribute::TypeProtos(types) => format!("<{} types>", types.len()),
339        // Subgraph attributes are printed as nested blocks, not inline.
340        Attribute::Graphs(graphs) if graphs.is_empty() => "[]:graphs".to_string(),
341        Attribute::Graph(_) | Attribute::Graphs(_) => "<graph>".to_string(),
342    }
343}
344
345/// ONNX textual dtype spellings.
346fn dtype_name(dtype: DataType) -> &'static str {
347    match dtype {
348        DataType::Undefined => "undefined",
349        DataType::Float32 => "float",
350        DataType::Uint8 => "uint8",
351        DataType::Int8 => "int8",
352        DataType::Uint16 => "uint16",
353        DataType::Int16 => "int16",
354        DataType::Int32 => "int32",
355        DataType::Int64 => "int64",
356        DataType::String => "string",
357        DataType::Bool => "bool",
358        DataType::Float16 => "float16",
359        DataType::Float64 => "float64",
360        DataType::Uint32 => "uint32",
361        DataType::Uint64 => "uint64",
362        DataType::Complex64 => "complex64",
363        DataType::Complex128 => "complex128",
364        DataType::BFloat16 => "bfloat16",
365        DataType::Float8E4M3FN => "float8e4m3fn",
366        DataType::Float8E4M3FNUZ => "float8e4m3fnuz",
367        DataType::Float8E5M2 => "float8e5m2",
368        DataType::Float8E5M2FNUZ => "float8e5m2fnuz",
369        DataType::Uint4 => "uint4",
370        DataType::Int4 => "int4",
371        DataType::Float4E2M1 => "float4e2m1",
372        DataType::Float8E8M0 => "float8e8m0",
373        DataType::Uint2 => "uint2",
374        DataType::Int2 => "int2",
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use onnx_runtime_ir::{Node, TensorData, static_shape};
382    use onnx_runtime_loader::ModelMetadata;
383
384    fn add_model() -> Model {
385        let mut g = Graph::new();
386        g.opset_imports.insert(String::new(), 21);
387        let x = g.create_named_value("X", DataType::Float32, static_shape([2, 3]));
388        let y = g.create_named_value("Y", DataType::Float32, static_shape([2, 3]));
389        let z = g.create_named_value("Z", DataType::Float32, static_shape([2, 3]));
390        g.add_input(x);
391        g.add_input(y);
392        let mut node = Node::new(NodeId(0), "Add", vec![Some(x), Some(y)], vec![z]);
393        node.name = "add0".to_string();
394        g.insert_node(node);
395        g.add_output(z);
396        Model::with_metadata(g, ModelMetadata::default())
397    }
398
399    #[test]
400    fn dumps_header_signature_and_node() {
401        let text = to_text(&add_model());
402        assert!(text.contains("ir_version: 10"), "header:\n{text}");
403        assert!(text.contains("opset_import: [\"\" : 21]"), "opset:\n{text}");
404        assert!(
405            text.contains("main (float[2, 3] X, float[2, 3] Y) => (float[2, 3] Z)"),
406            "signature:\n{text}"
407        );
408        assert!(text.contains("Z = Add(X, Y)"), "node:\n{text}");
409    }
410
411    #[test]
412    fn renders_inline_attribute() {
413        let mut g = Graph::new();
414        g.opset_imports.insert(String::new(), 21);
415        let x = g.create_named_value("X", DataType::Float32, static_shape([4]));
416        let y = g.create_named_value("Y", DataType::Float32, static_shape([4]));
417        g.add_input(x);
418        let mut node = Node::new(NodeId(0), "LeakyRelu", vec![Some(x)], vec![y]);
419        node.attributes
420            .insert("alpha".to_string(), Attribute::Float(0.1));
421        g.insert_node(node);
422        g.add_output(y);
423        let text = to_text(&Model::new(g));
424        assert!(text.contains("Y = LeakyRelu <alpha = 0.1>(X)"), "{text}");
425    }
426
427    #[test]
428    fn initializers_are_references_not_data() {
429        let mut g = Graph::new();
430        g.opset_imports.insert(String::new(), 21);
431        let w = g.create_named_value("W", DataType::Float32, static_shape([2]));
432        let init = TensorData::from_raw(DataType::Float32, vec![2], vec![0u8; 8]);
433        g.set_initializer(w, WeightRef::Inline(init));
434        let text = to_text(&Model::new(g));
435        assert!(text.contains("// initializers"), "{text}");
436        assert!(
437            text.contains("float[2] W = <inline data omitted>"),
438            "{text}"
439        );
440        // The raw bytes must never appear inline.
441        assert!(!text.contains("\\x00"), "{text}");
442    }
443
444    fn unary_subgraph(input: &str, output: &str, op: &str) -> Graph {
445        let mut graph = Graph::new();
446        let x = graph.create_named_value(input, DataType::Float32, static_shape([2]));
447        let y = graph.create_named_value(output, DataType::Float32, static_shape([2]));
448        graph.add_input(x);
449        graph.insert_node(Node::new(NodeId(0), op, vec![Some(x)], vec![y]));
450        graph.add_output(y);
451        graph
452    }
453
454    #[test]
455    fn prints_single_and_list_subgraph_bodies() {
456        let then_branch = unary_subgraph("then_in", "then_out", "Relu");
457        let first_case = unary_subgraph("case0_in", "case0_out", "Neg");
458        let second_case = unary_subgraph("case1_in", "case1_out", "Identity");
459
460        let mut graph = Graph::new();
461        graph.opset_imports.insert(String::new(), 21);
462        let cond = graph.create_named_value("cond", DataType::Bool, static_shape([]));
463        let out = graph.create_named_value("out", DataType::Float32, static_shape([2]));
464        graph.add_input(cond);
465        let mut node = Node::new(NodeId(0), "If", vec![Some(cond)], vec![out]);
466        node.attributes.insert(
467            "then_branch".into(),
468            Attribute::Graph(Box::new(then_branch.clone())),
469        );
470        node.attributes.insert(
471            "branches".into(),
472            Attribute::Graphs(vec![first_case.clone(), second_case.clone()]),
473        );
474        let node_id = graph.insert_node(node);
475        graph
476            .subgraphs
477            .insert((node_id, "then_branch".into()), then_branch);
478        graph
479            .subgraphs
480            .insert((node_id, "branches[0]".into()), first_case);
481        graph
482            .subgraphs
483            .insert((node_id, "branches[1]".into()), second_case);
484        graph.add_output(out);
485
486        let text = to_text(&Model::new(graph));
487        assert!(text.contains("then_branch = graph"), "{text}");
488        assert!(text.contains("then_out = Relu(then_in)"), "{text}");
489        assert!(text.contains("branches[0] = graph"), "{text}");
490        assert!(text.contains("case0_out = Neg(case0_in)"), "{text}");
491        assert!(text.contains("branches[1] = graph"), "{text}");
492        assert!(text.contains("case1_out = Identity(case1_in)"), "{text}");
493    }
494}