Skip to main content

onnx_runtime_loader/
encoder.rs

1//! ONNX protobuf **encoding** — the inverse of [`graph_builder`](crate::graph_builder)
2//! and [`weights`](crate::weights) (§19.1, §55.4 dump path).
3//!
4//! Serialises an [`onnx_runtime_ir::Graph`] (plus model-level metadata that the
5//! IR does not itself store) back into an ONNX `ModelProto`, then to protobuf
6//! bytes via `prost`. This is the foundational capability the EPContext writer
7//! (§55.4) builds on, but it is deliberately **model-agnostic**: it hardcodes no
8//! op type, vendor, or model name.
9//!
10//! ## Round-trip contract
11//!
12//! Everything the load path (`decode → build → weights`) preserves survives an
13//! `encode → decode` round-trip byte-for-byte:
14//!
15//! * nodes: `op_type`, `domain`, `input`/`output` order (incl. skipped optional
16//!   slots), attributes, `doc_string`;
17//! * graph inputs / outputs / interior `value_info` (dtype + static & symbolic
18//!   dims, symbols re-emitted by their interned name);
19//! * initializers: all supported dtypes, raw little-endian bytes byte-exact
20//!   (including `STRING` payloads);
21//! * opset imports, `ir_version`, producer fields, model `doc_string`,
22//!   `metadata_props`.
23//!
24//! ### STRING attributes are byte-preserving (§55.3/§55.4)
25//!
26//! ONNX `STRING` attributes are arbitrary byte strings — a compiled-vendor blob,
27//! a relative path, or text — that are not guaranteed to be valid UTF-8. The IR
28//! stores them as raw bytes ([`Attribute::String`](onnx_runtime_ir::Attribute)),
29//! so both decode and encode round-trip the bytes exactly with **zero** op or
30//! attribute-name knowledge. The `EPContext` `ep_cache_context` opaque blob is
31//! preserved purely by this generic mechanism — the encoder contains no
32//! op-specific branch.
33//!
34//! ## Fields deliberately not encoded
35//!
36//! The IR `Graph` does not model these, so they cannot be reproduced and are
37//! emitted empty / default:
38//!
39//! * `TrainingInfoProto`, `FunctionProto`, sparse initializers, quantization
40//!   annotations (not represented in the IR). Model-local functions ARE
41//!   supported on the load path — [`crate::function_inline`] expands every
42//!   function call into its primitive body before the IR is built — but the IR
43//!   `Graph` does not retain the original `FunctionProto` declarations, so they
44//!   are not re-emitted here;
45//! * nested `GraphProto.name` values — the IR does not retain graph names for
46//!   control-flow bodies, so nested graphs are emitted with an empty name.
47
48use std::collections::HashSet;
49use std::path::Path;
50
51use prost::Message;
52
53use onnx_runtime_ir::{
54    Attribute, DataType, Dim, Graph, Node, Shape, TensorData, TypeProto, ValueId, WeightRef,
55};
56
57use crate::LoaderError;
58use crate::proto::onnx::{
59    self, AttributeProto, GraphProto, ModelProto, NodeProto, OperatorSetIdProto,
60    StringStringEntryProto, TensorProto, TensorShapeProto, ValueInfoProto,
61    attribute_proto::AttributeType, tensor_shape_proto, type_proto,
62};
63use crate::weights::WeightStore;
64
65/// Default ONNX `ir_version` stamped when [`ModelMetadata`] does not override it
66/// (IR version 11, matching the maintained-fixture floor enforced by
67/// `onnx-std`'s `fixture_ir_opset_guard` and paired with default opset 24).
68pub const DEFAULT_IR_VERSION: i64 = 11;
69
70/// Default `ai.onnx` opset stamped for a graph that declares no default-domain
71/// opset import (IR >= 3 requires one). Kept in lock-step with
72/// [`DEFAULT_IR_VERSION`] and the maintained-fixture opset floor (24).
73pub const DEFAULT_OPSET_VERSION: i64 = 24;
74
75/// Model-level metadata that the IR [`Graph`] does not itself carry.
76///
77/// The load path drops these (it only keeps `opset_imports` on the `Graph`), so
78/// a caller that wants a faithful `ModelProto` supplies them here. All fields
79/// default to empty/zero except [`ir_version`](ModelMetadata::ir_version).
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ModelMetadata {
82    /// `ModelProto.ir_version`.
83    pub ir_version: i64,
84    /// `ModelProto.producer_name`.
85    pub producer_name: String,
86    /// `ModelProto.producer_version`.
87    pub producer_version: String,
88    /// `ModelProto.domain`.
89    pub domain: String,
90    /// `ModelProto.model_version`.
91    pub model_version: i64,
92    /// `ModelProto.doc_string`.
93    pub doc_string: Option<String>,
94    /// `GraphProto.name` for the top-level graph.
95    pub graph_name: String,
96    /// `ModelProto.metadata_props` (`key → value`), emitted in order.
97    pub metadata_props: Vec<(String, String)>,
98}
99
100impl Default for ModelMetadata {
101    fn default() -> Self {
102        Self {
103            ir_version: DEFAULT_IR_VERSION,
104            producer_name: String::new(),
105            producer_version: String::new(),
106            domain: String::new(),
107            model_version: 0,
108            doc_string: None,
109            graph_name: String::new(),
110            metadata_props: Vec::new(),
111        }
112    }
113}
114
115/// An IR [`Graph`] bundled with the model-level metadata and live weight bytes
116/// needed to encode a complete ONNX `ModelProto`.
117///
118/// Construct with [`Model::new`] and refine via [`Model::with_metadata`] /
119/// [`Model::with_weights`]. A [`WeightStore`] is required only when the graph
120/// has `External`-backed initializers (inline initializers carry their own
121/// bytes).
122pub struct Model<'a> {
123    /// The graph to encode.
124    pub graph: &'a Graph,
125    /// Model-level metadata (see [`ModelMetadata`]).
126    pub metadata: ModelMetadata,
127    /// Live weight store backing any [`WeightRef::External`] initializers.
128    pub weights: Option<&'a WeightStore>,
129}
130
131impl<'a> Model<'a> {
132    /// A model over `graph` with default metadata and no external weight store.
133    ///
134    /// **Note on defaulted metadata:** the load path does not preserve
135    /// model-level metadata (it keeps only `opset_imports` on the `Graph`), so a
136    /// model built this way stamps [`DEFAULT_IR_VERSION`] and empty producer /
137    /// version / `metadata_props` fields. A rewrite path that must reproduce the
138    /// original model faithfully should capture the source metadata and pass it
139    /// via [`Model::with_metadata`] rather than relying on these defaults.
140    pub fn new(graph: &'a Graph) -> Self {
141        Self {
142            graph,
143            metadata: ModelMetadata::default(),
144            weights: None,
145        }
146    }
147
148    /// Attach model-level metadata.
149    pub fn with_metadata(mut self, metadata: ModelMetadata) -> Self {
150        self.metadata = metadata;
151        self
152    }
153
154    /// Attach the live [`WeightStore`] backing external initializers.
155    pub fn with_weights(mut self, weights: &'a WeightStore) -> Self {
156        self.weights = Some(weights);
157        self
158    }
159}
160
161/// Encode `model` into serialized ONNX protobuf bytes.
162/// Refuse to write a graph whose nodes disagree with its opset imports.
163///
164/// `Node::version` lets a rewrite emit a newer standard operator without
165/// claiming the rest of the graph was upgraded with it, which is what makes
166/// mixed-opset transformations possible. ONNX's protobuf has nowhere to put
167/// it, so writing such a graph out would silently produce a model asserting
168/// the wrong version for that operator.
169///
170/// Checked here rather than at each caller: this is the single funnel every
171/// serialisation path goes through, so no future writer can bypass it.
172fn reject_unrepresentable_node_versions(graph: &Graph) -> Result<(), LoaderError> {
173    for node in graph.nodes.values() {
174        let Some(node_version) = node.version else {
175            continue;
176        };
177        let graph_version = graph
178            .opset_imports
179            .get(node.domain.as_str())
180            .copied()
181            .unwrap_or(0);
182        if u64::try_from(node_version).is_ok_and(|version| version == graph_version) {
183            continue;
184        }
185        return Err(LoaderError::NodeVersionNotRepresentable {
186            node: if node.name.is_empty() {
187                format!("#{}", node.id.0)
188            } else {
189                node.name.clone()
190            },
191            op_type: node.op_type.clone(),
192            domain: node.domain.clone(),
193            node_version,
194            graph_version,
195        });
196    }
197    Ok(())
198}
199
200pub fn encode_model(model: &Model) -> Result<Vec<u8>, LoaderError> {
201    Ok(encode_model_proto(model)?.encode_to_vec())
202}
203
204/// Encode `model` and write the serialized bytes to `path`.
205pub fn write_model(model: &Model, path: impl AsRef<Path>) -> Result<(), LoaderError> {
206    let bytes = encode_model(model)?;
207    let path = path.as_ref();
208    std::fs::write(path, bytes).map_err(|source| LoaderError::Io {
209        path: path.to_path_buf(),
210        source,
211    })
212}
213
214/// Build the [`ModelProto`] for `model` without serialising it (useful when the
215/// caller wants to mutate the proto before encoding, e.g. the §55.4 writer
216/// splicing in `EPContext` nodes).
217pub fn encode_model_proto(model: &Model) -> Result<ModelProto, LoaderError> {
218    reject_unrepresentable_node_versions(model.graph)?;
219    let meta = &model.metadata;
220    let graph = encode_graph_proto(model.graph, model.weights, true, &meta.graph_name)?;
221
222    // Opset imports sorted by domain for deterministic output.
223    let mut opset_import: Vec<OperatorSetIdProto> = model
224        .graph
225        .opset_imports
226        .iter()
227        .map(|(domain, &version)| OperatorSetIdProto {
228            domain: domain.clone(),
229            version: version as i64,
230        })
231        .collect();
232    if meta.ir_version >= 3
233        && !opset_import
234            .iter()
235            .any(|opset| opset.domain.is_empty() || opset.domain == "ai.onnx")
236    {
237        // IR >= 3 requires a default-domain import even for an empty graph.
238        opset_import.push(OperatorSetIdProto {
239            domain: String::new(),
240            version: DEFAULT_OPSET_VERSION,
241        });
242    }
243    opset_import.sort_by(|a, b| a.domain.cmp(&b.domain));
244
245    let metadata_props = meta
246        .metadata_props
247        .iter()
248        .map(|(key, value)| StringStringEntryProto {
249            key: key.clone(),
250            value: value.clone(),
251        })
252        .collect();
253
254    Ok(ModelProto {
255        ir_version: meta.ir_version,
256        opset_import,
257        producer_name: meta.producer_name.clone(),
258        producer_version: meta.producer_version.clone(),
259        domain: meta.domain.clone(),
260        model_version: meta.model_version,
261        doc_string: meta.doc_string.clone().unwrap_or_default(),
262        graph: Some(graph),
263        metadata_props,
264        ..Default::default()
265    })
266}
267
268/// Encode a [`Graph`] into a `GraphProto`. `is_top_level` reserved for future
269/// subgraph-specific behaviour; currently the same fields are emitted for both.
270fn encode_graph_proto(
271    graph: &Graph,
272    weights: Option<&WeightStore>,
273    _is_top_level: bool,
274    name: &str,
275) -> Result<GraphProto, LoaderError> {
276    // 1. Initializers, ordered by value id for determinism.
277    let mut init_ids: Vec<ValueId> = graph.initializers.keys().copied().collect();
278    init_ids.sort_by_key(|v| v.0);
279    let mut initializer = Vec::with_capacity(init_ids.len());
280    for vid in &init_ids {
281        let weight = &graph.initializers[vid];
282        let iname = value_name(graph, *vid).unwrap_or_default().to_string();
283        initializer.push(encode_weight(iname, weight, weights)?);
284    }
285
286    // 2. Graph inputs / outputs as ValueInfoProtos.
287    let input: Vec<ValueInfoProto> = graph
288        .inputs
289        .iter()
290        .map(|&vid| encode_value_info(graph, vid))
291        .collect();
292    let output: Vec<ValueInfoProto> = graph
293        .outputs
294        .iter()
295        .map(|&vid| encode_value_info(graph, vid))
296        .collect();
297
298    // 3. Interior value_info: every named value that is not a graph input,
299    //    output, or initializer. Anonymous values (skipped optional outputs,
300    //    unnamed SSA edges) carry no name and are omitted.
301    let mut excluded: HashSet<ValueId> = HashSet::new();
302    excluded.extend(graph.inputs.iter().copied());
303    excluded.extend(graph.outputs.iter().copied());
304    excluded.extend(init_ids.iter().copied());
305    let mut value_info = Vec::new();
306    for (vid, value) in graph.values.iter() {
307        if excluded.contains(&vid) {
308            continue;
309        }
310        if value.name.as_deref().is_some_and(|n| !n.is_empty()) {
311            value_info.push(encode_value_info(graph, vid));
312        }
313    }
314
315    // 4. Nodes, in ascending node-id (== load) order.
316    let mut node = Vec::with_capacity(graph.num_nodes());
317    for (_, n) in graph.nodes.iter() {
318        node.push(encode_node(graph, n, weights)?);
319    }
320
321    Ok(GraphProto {
322        node,
323        name: name.to_string(),
324        initializer,
325        input,
326        output,
327        value_info,
328        ..Default::default()
329    })
330}
331
332/// Encode a single node into a `NodeProto`.
333fn encode_node(
334    graph: &Graph,
335    node: &Node,
336    weights: Option<&WeightStore>,
337) -> Result<NodeProto, LoaderError> {
338    let input: Vec<String> = node
339        .inputs
340        .iter()
341        .map(|slot| match slot {
342            Some(vid) => value_name(graph, *vid).unwrap_or_default().to_string(),
343            None => String::new(),
344        })
345        .collect();
346    let output: Vec<String> = node
347        .outputs
348        .iter()
349        .map(|&vid| value_name(graph, vid).unwrap_or_default().to_string())
350        .collect();
351
352    // Sort attributes by name so the encoding is deterministic (the IR stores
353    // them in a HashMap).
354    let mut keys: Vec<&String> = node.attributes.keys().collect();
355    keys.sort();
356    let mut attribute = Vec::with_capacity(keys.len());
357    for key in keys {
358        attribute.push(encode_attribute(
359            graph,
360            node,
361            key,
362            &node.attributes[key],
363            weights,
364        )?);
365    }
366
367    Ok(NodeProto {
368        input,
369        output,
370        name: node.name.clone(),
371        op_type: node.op_type.clone(),
372        domain: node.domain.clone(),
373        attribute,
374        doc_string: node.doc_string.clone().unwrap_or_default(),
375        ..Default::default()
376    })
377}
378
379/// Encode one IR [`Attribute`] into an `AttributeProto`, setting the `type`
380/// discriminator to match the populated field (required for IR ≥ 0.0.2).
381fn encode_attribute(
382    graph: &Graph,
383    node: &Node,
384    name: &str,
385    attr: &Attribute,
386    weights: Option<&WeightStore>,
387) -> Result<AttributeProto, LoaderError> {
388    let mut ap = AttributeProto {
389        name: name.to_string(),
390        ..Default::default()
391    };
392    match attr {
393        Attribute::Int(v) => {
394            ap.i = *v;
395            ap.r#type = AttributeType::Int as i32;
396        }
397        Attribute::Float(v) => {
398            ap.f = *v;
399            ap.r#type = AttributeType::Float as i32;
400        }
401        Attribute::String(s) => {
402            ap.s = s.clone();
403            ap.r#type = AttributeType::String as i32;
404        }
405        Attribute::Ints(v) => {
406            ap.ints = v.clone();
407            ap.r#type = AttributeType::Ints as i32;
408        }
409        Attribute::Floats(v) => {
410            ap.floats = v.clone();
411            ap.r#type = AttributeType::Floats as i32;
412        }
413        Attribute::Strings(v) => {
414            ap.strings = v.clone();
415            ap.r#type = AttributeType::Strings as i32;
416        }
417        Attribute::Tensor(t) => {
418            ap.t = Some(encode_tensor(t));
419            ap.r#type = AttributeType::Tensor as i32;
420        }
421        Attribute::Tensors(tensors) => {
422            ap.tensors = tensors.iter().map(encode_tensor).collect();
423            ap.r#type = AttributeType::Tensors as i32;
424        }
425        Attribute::Graph(inline) => {
426            let subgraph = graph
427                .subgraphs
428                .get(&(node.id, name.to_string()))
429                .unwrap_or(inline);
430            ap.g = Some(encode_graph_proto(subgraph, weights, false, "")?);
431            ap.r#type = AttributeType::Graph as i32;
432        }
433        Attribute::Graphs(inline) => {
434            ap.graphs = inline
435                .iter()
436                .enumerate()
437                .map(|(index, fallback)| {
438                    let key = (node.id, format!("{name}[{index}]"));
439                    let subgraph = graph.subgraphs.get(&key).unwrap_or(fallback);
440                    encode_graph_proto(subgraph, weights, false, "")
441                })
442                .collect::<Result<Vec<_>, _>>()?;
443            ap.r#type = AttributeType::Graphs as i32;
444        }
445        Attribute::TypeProto(tp) => {
446            ap.tp = Some(encode_type_proto(graph, tp));
447            ap.r#type = AttributeType::TypeProto as i32;
448        }
449        Attribute::TypeProtos(types) => {
450            ap.type_protos = types
451                .iter()
452                .map(|value| encode_type_proto(graph, value))
453                .collect();
454            ap.r#type = AttributeType::TypeProtos as i32;
455        }
456        Attribute::SparseTensor(tensor) => {
457            ap.sparse_tensor = Some(encode_sparse_tensor(tensor));
458            ap.r#type = AttributeType::SparseTensor as i32;
459        }
460        Attribute::SparseTensors(tensors) => {
461            ap.sparse_tensors = tensors.iter().map(encode_sparse_tensor).collect();
462            ap.r#type = AttributeType::SparseTensors as i32;
463        }
464    }
465    Ok(ap)
466}
467
468/// Encode a [`TensorData`] into a `TensorProto`, preserving raw little-endian
469/// bytes (or `STRING` payloads) byte-exactly.
470fn encode_tensor(t: &TensorData) -> TensorProto {
471    let mut tp = TensorProto {
472        dims: t.dims.iter().map(|&d| d as i64).collect(),
473        data_type: t.dtype.to_onnx(),
474        name: t.name.clone().unwrap_or_default(),
475        ..Default::default()
476    };
477    if t.dtype == DataType::String {
478        tp.string_data = t.strings.iter().map(|s| s.clone().into_bytes()).collect();
479    } else {
480        tp.raw_data = t.data.clone();
481    }
482
483    tp
484}
485
486fn encode_sparse_tensor(tensor: &onnx_runtime_ir::SparseTensorData) -> onnx::SparseTensorProto {
487    onnx::SparseTensorProto {
488        values: Some(encode_tensor(&tensor.values)),
489        indices: Some(encode_tensor(&tensor.indices)),
490        dims: tensor.dims.iter().map(|&dim| dim as i64).collect(),
491    }
492}
493
494/// Encode an initializer [`WeightRef`] into a `TensorProto` named `name`.
495///
496/// Inline weights are emitted directly; external weights are materialised inline
497/// (as `raw_data`) from the provided [`WeightStore`]. Preserving the external
498/// `data_location` reference on write is a follow-up (see the decision note).
499fn encode_weight(
500    name: String,
501    weight: &WeightRef,
502    weights: Option<&WeightStore>,
503) -> Result<TensorProto, LoaderError> {
504    match weight {
505        WeightRef::Inline(t) => {
506            let mut tp = encode_tensor(t);
507            tp.name = name;
508            Ok(tp)
509        }
510        WeightRef::External { dtype, dims, .. } => {
511            if *dtype == DataType::String {
512                return Err(LoaderError::GraphBuild(format!(
513                    "external initializer {name:?}: STRING external data is unsupported"
514                )));
515            }
516            let bytes = weights.and_then(|s| s.bytes(weight)).ok_or_else(|| {
517                LoaderError::GraphBuild(format!(
518                    "external initializer {name:?}: weight bytes unavailable \
519                     (attach a WeightStore via Model::with_weights)"
520                ))
521            })?;
522            Ok(TensorProto {
523                name,
524                data_type: dtype.to_onnx(),
525                dims: dims.iter().map(|&d| d as i64).collect(),
526                raw_data: bytes.to_vec(),
527                ..Default::default()
528            })
529        }
530    }
531}
532
533/// Encode a value's `(dtype, shape)` into a `ValueInfoProto`.
534fn encode_value_info(graph: &Graph, vid: ValueId) -> ValueInfoProto {
535    let value = graph.value(vid);
536    ValueInfoProto {
537        name: value.name.clone().unwrap_or_default(),
538        r#type: Some(encode_tensor_type(graph, value.dtype, &value.shape)),
539        ..Default::default()
540    }
541}
542
543/// Build a tensor `TypeProto` from an element type and shape.
544fn encode_tensor_type(graph: &Graph, dtype: DataType, shape: &Shape) -> onnx::TypeProto {
545    onnx::TypeProto {
546        value: Some(type_proto::Value::TensorType(type_proto::Tensor {
547            elem_type: dtype.to_onnx(),
548            shape: Some(encode_shape(graph, shape)),
549        })),
550        ..Default::default()
551    }
552}
553
554/// Encode an IR [`Shape`] into a `TensorShapeProto`. Static dims become
555/// `dim_value`; symbolic dims are re-emitted by their interned name as
556/// `dim_param`, or as a valueless (unknown) dimension when unnamed.
557fn encode_shape(graph: &Graph, shape: &Shape) -> TensorShapeProto {
558    use tensor_shape_proto::{Dimension, dimension::Value as DV};
559    let dim = shape
560        .iter()
561        .map(|d| {
562            let value = match d {
563                Dim::Static(n) => Some(DV::DimValue(*n as i64)),
564                Dim::Symbolic(sym) => graph
565                    .symbol_constraints
566                    .get(sym)
567                    .and_then(|c| c.name.clone())
568                    .map(DV::DimParam),
569            };
570            Dimension {
571                value,
572                ..Default::default()
573            }
574        })
575        .collect();
576    TensorShapeProto { dim }
577}
578
579/// Encode an IR [`TypeProto`] (the inverse of `graph_builder::convert_type_proto`).
580fn encode_type_proto(graph: &Graph, tp: &TypeProto) -> onnx::TypeProto {
581    let value = match tp {
582        TypeProto::Tensor { dtype, shape } => type_proto::Value::TensorType(type_proto::Tensor {
583            elem_type: dtype.to_onnx(),
584            shape: Some(encode_shape(graph, shape)),
585        }),
586        TypeProto::SparseTensor { dtype, shape } => {
587            type_proto::Value::SparseTensorType(type_proto::SparseTensor {
588                elem_type: dtype.to_onnx(),
589                shape: Some(encode_shape(graph, shape)),
590            })
591        }
592        TypeProto::Sequence(inner) => {
593            type_proto::Value::SequenceType(Box::new(type_proto::Sequence {
594                elem_type: Some(Box::new(encode_type_proto(graph, inner))),
595            }))
596        }
597        TypeProto::Optional(inner) => {
598            type_proto::Value::OptionalType(Box::new(type_proto::Optional {
599                elem_type: Some(Box::new(encode_type_proto(graph, inner))),
600            }))
601        }
602        TypeProto::Map { key, value } => type_proto::Value::MapType(Box::new(type_proto::Map {
603            key_type: key.to_onnx(),
604            value_type: Some(Box::new(encode_type_proto(graph, value))),
605        })),
606    };
607    onnx::TypeProto {
608        value: Some(value),
609        ..Default::default()
610    }
611}
612
613/// The name of a graph value, if it has one.
614fn value_name(graph: &Graph, vid: ValueId) -> Option<&str> {
615    graph.try_value(vid).and_then(|v| v.name.as_deref())
616}
617
618#[cfg(test)]
619mod node_version_tests {
620    use super::*;
621    use onnx_runtime_ir::{DataType, Dim, Node, NodeId};
622
623    fn graph_with_node_version(version: Option<i64>) -> Graph {
624        let mut graph = Graph::new();
625        graph.opset_imports.insert(String::new(), 13);
626        let x = graph.create_named_value("x", DataType::Float32, vec![Dim::Static(2)]);
627        let y = graph.create_named_value("y", DataType::Float32, vec![Dim::Static(2)]);
628        graph.add_input(x);
629        graph.add_output(y);
630        let mut node = Node::new(NodeId(0), "Swish", vec![Some(x)], vec![y]);
631        node.version = version;
632        node.name = "swish".to_string();
633        graph.insert_node(node);
634        graph
635    }
636
637    /// Writing a node whose opset disagrees with the graph must fail loudly.
638    ///
639    /// ONNX's protobuf has nowhere to record a per-node version, so the value
640    /// would simply vanish and the file would assert this operator is at the
641    /// graph's version. Nothing downstream could tell — which is exactly why
642    /// this refuses rather than truncating.
643    #[test]
644    fn refuses_to_write_a_node_whose_version_the_format_cannot_hold() {
645        let graph = graph_with_node_version(Some(24));
646        let error = reject_unrepresentable_node_versions(&graph)
647            .expect_err("a mixed-version graph must not serialise");
648        let text = error.to_string();
649        assert!(text.contains("swish"), "must name the node: {text}");
650        assert!(text.contains("24"), "must give the node's version: {text}");
651        assert!(text.contains("13"), "must give the graph's version: {text}");
652        assert!(
653            text.contains("serialise before") || text.contains("fusion disabled"),
654            "must say how to proceed: {text}"
655        );
656    }
657
658    /// A node that agrees with the graph, or states nothing, writes normally.
659    #[test]
660    fn allows_versions_the_format_can_represent() {
661        reject_unrepresentable_node_versions(&graph_with_node_version(None))
662            .expect("an unversioned node is the ordinary case");
663        reject_unrepresentable_node_versions(&graph_with_node_version(Some(13)))
664            .expect("a node agreeing with the graph loses nothing when written");
665    }
666}