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