Skip to main content

bloq_ir/
binary.rs

1//! The compact binary Bloq IR exchange format.
2//!
3//! Layout: a 4-byte magic (`b"BLOQ"`), one version byte, then the whole
4//! [`Bloq`] postcard-encoded (varint integers, length-prefixed sequences).
5//! The payload is the IR's serde representation — self-contained (templates,
6//! shared detector bundles, instance ids, side tables, `boundary_flows`), and it round-trips all
7//! provenance channels, so a save/load loses nothing.
8//!
9//! Node ids are preserved exactly, holes included: [`SubGraph`] serializes live
10//! node-id pairs and ordered live edges, then reconstructs vacant node slots.
11//! Classical definitions are stored once per graph level and referenced by its
12//! nodes; decoding restores sharing without changing invocation identity.
13//! The deterministic schedule (keyed on node slot index) is identical after a
14//! round trip; vacant edge slots are not preserved.
15//!
16//! The version stays at 1 during rapid development, even when the serialized
17//! shape changes. A reader rejects other version bytes, but the shared version
18//! does not promise compatibility with older pre-release payloads. Postcard is
19//! not self-describing, so artifacts must match the current IR field layout.
20
21use crate::Bloq;
22
23/// Compact consecutive records of one instance without changing their order.
24/// Human-readable serde retains the ordinary array of record objects.
25pub(crate) mod instance_measurements {
26    use std::fmt;
27
28    use serde::{
29        Deserialize, Deserializer, Serialize, Serializer,
30        de::{Error, SeqAccess, Visitor},
31        ser::SerializeSeq,
32    };
33
34    use crate::{InstanceMeasurement, TemplateInstanceId};
35
36    pub(crate) fn serialize<S: Serializer>(
37        records: &[InstanceMeasurement],
38        serializer: S,
39    ) -> Result<S::Ok, S::Error> {
40        if serializer.is_human_readable() {
41            return records.serialize(serializer);
42        }
43        let runs = records.chunk_by(|a, b| a.instance == b.instance);
44        // A flat integer stream: total records, then (run length, instance,
45        // local ids...) per run. The total permits one decoded allocation.
46        let mut sequence =
47            serializer.serialize_seq(Some(1 + 2 * runs.clone().count() + records.len()))?;
48        sequence.serialize_element(&records.len())?;
49        for run in runs {
50            sequence.serialize_element(&run.len())?;
51            sequence.serialize_element(&run[0].instance)?;
52            for record in run {
53                sequence.serialize_element(&record.measurement)?;
54            }
55        }
56        sequence.end()
57    }
58
59    struct Records;
60
61    impl<'de> Visitor<'de> for Records {
62        type Value = Vec<InstanceMeasurement>;
63
64        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65            formatter.write_str("a record count followed by instance runs")
66        }
67
68        fn visit_seq<A: SeqAccess<'de>>(self, mut sequence: A) -> Result<Self::Value, A::Error> {
69            let count: usize = sequence
70                .next_element()?
71                .ok_or_else(|| A::Error::custom("missing record count"))?;
72            // Like serde's Vec decoder, cap speculative allocation from an
73            // untrusted count at 1 MiB. Further growth needs actual records.
74            let mut records =
75                Vec::with_capacity(count.min((1 << 20) / size_of::<InstanceMeasurement>()));
76            while let Some(length) = sequence.next_element::<usize>()? {
77                if length == 0 || length > count - records.len() {
78                    return Err(A::Error::custom("invalid record run length"));
79                }
80                let instance: TemplateInstanceId = sequence
81                    .next_element()?
82                    .ok_or_else(|| A::Error::custom("record run needs an instance"))?;
83                for _ in 0..length {
84                    let measurement = sequence
85                        .next_element()?
86                        .ok_or_else(|| A::Error::custom("record run is truncated"))?;
87                    records.push(InstanceMeasurement {
88                        instance,
89                        measurement,
90                    });
91                }
92            }
93            if records.len() != count {
94                return Err(A::Error::custom("record count does not match its runs"));
95            }
96            Ok(records)
97        }
98    }
99
100    pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
101        deserializer: D,
102    ) -> Result<Vec<InstanceMeasurement>, D::Error> {
103        if deserializer.is_human_readable() {
104            return Vec::deserialize(deserializer);
105        }
106        deserializer.deserialize_seq(Records)
107    }
108}
109
110/// File magic prefixing every binary `.bloq` artifact.
111pub const BLOQ_BINARY_MAGIC: [u8; 4] = *b"BLOQ";
112
113/// Pre-release binary format version; stays at 1 during rapid development.
114pub const BLOQ_BINARY_VERSION: u8 = 1;
115
116/// The conventional file extension for the binary format.
117pub const BLOQ_BINARY_EXTENSION: &str = "bloq";
118
119/// Why a byte buffer failed to decode as a binary `.bloq` artifact.
120#[derive(Debug, thiserror::Error)]
121#[non_exhaustive]
122pub enum BinaryDecodeError {
123    /// Input does not start with the Bloq binary magic bytes.
124    #[error("not a bloq binary artifact: missing `BLOQ` magic")]
125    BadMagic,
126    /// Input ends before its version byte or payload.
127    #[error("truncated bloq binary artifact: magic without a version byte")]
128    Truncated,
129    /// Input uses an unsupported binary format version.
130    #[error(
131        "unsupported bloq binary version {found} (this reader supports only \
132         version {BLOQ_BINARY_VERSION}; there is no cross-version compatibility)"
133    )]
134    UnsupportedVersion {
135        /// Version read from the input.
136        found: u8,
137    },
138    /// Postcard could not decode the payload.
139    #[error("malformed bloq binary payload")]
140    Payload(#[source] postcard::Error),
141    /// Bytes remain after the decoded program.
142    #[error("{extra} trailing byte(s) after the bloq binary payload")]
143    TrailingData {
144        /// Number of trailing bytes.
145        extra: usize,
146    },
147}
148
149impl Bloq {
150    /// Encode this program as a binary `.bloq` artifact.
151    ///
152    /// # Panics
153    ///
154    /// Panics if the in-memory IR violates postcard's sequence contract.
155    pub fn to_binary(&self) -> Vec<u8> {
156        let mut bytes = Vec::with_capacity(1024);
157        bytes.extend_from_slice(&BLOQ_BINARY_MAGIC);
158        bytes.push(BLOQ_BINARY_VERSION);
159        postcard::to_extend(self, bytes)
160            .expect("postcard serialization into a Vec cannot fail for serde-derived IR types")
161    }
162
163    /// Decode a binary `.bloq` artifact.
164    ///
165    /// Decoding rejects structurally malformed payloads (trailing bytes,
166    /// duplicate node ids, dangling edges, an implausibly sparse node-id
167    /// space, unsorted registry/PauliMap entries) but does not check IR
168    /// semantics — run [`Bloq::validate`] on untrusted input.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`BinaryDecodeError`] for an invalid header, unsupported
173    /// version, malformed payload, or trailing data.
174    pub fn from_binary(bytes: &[u8]) -> Result<Self, BinaryDecodeError> {
175        let payload = bytes
176            .strip_prefix(&BLOQ_BINARY_MAGIC)
177            .ok_or(BinaryDecodeError::BadMagic)?;
178        let (&version, payload) = payload.split_first().ok_or(BinaryDecodeError::Truncated)?;
179        if version != BLOQ_BINARY_VERSION {
180            return Err(BinaryDecodeError::UnsupportedVersion { found: version });
181        }
182        // `take_from_bytes` rather than `from_bytes`: a payload followed by
183        // trailing junk (padding, a concatenated second artifact) must be
184        // rejected, not silently decoded as its first program.
185        let (bloq, rest) =
186            postcard::take_from_bytes(payload).map_err(BinaryDecodeError::Payload)?;
187        if !rest.is_empty() {
188            return Err(BinaryDecodeError::TrailingData { extra: rest.len() });
189        }
190        Ok(bloq)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use crate::{BloqNodeId, QuantumTimeline, test_fixture::sample_bloq};
197
198    use super::*;
199
200    #[test]
201    fn retry_region_keeps_its_binary_tag_and_removed_regions_are_rejected() {
202        use crate::{ClassicalExpr, RegionNode, SubGraph};
203
204        let region = RegionNode::RepeatUntilSuccess {
205            body: SubGraph::new(),
206            restart_condition: ClassicalExpr::Const(false),
207            restart_source: None,
208        };
209        let bytes = postcard::to_extend(&region, Vec::new()).unwrap();
210        assert_eq!(bytes[0], 1, "preserve the pre-removal retry tag");
211        let restored: RegionNode = postcard::from_bytes(&bytes).unwrap();
212        assert_eq!(postcard::to_extend(&restored, Vec::new()).unwrap(), bytes);
213
214        let mut program = Bloq::new();
215        program.add_node(crate::BloqNode::region(region));
216        let mut artifact = program.to_binary();
217        let offsets = artifact
218            .windows(bytes.len())
219            .enumerate()
220            .filter_map(|(offset, payload)| (payload == bytes).then_some(offset))
221            .collect::<Vec<_>>();
222        assert_eq!(offsets.len(), 1, "the single serialized region");
223        Bloq::from_binary(&artifact).unwrap();
224        artifact[offsets[0]] = 0;
225        assert!(matches!(
226            Bloq::from_binary(&artifact),
227            Err(BinaryDecodeError::Payload(_))
228        ));
229
230        // The removed conditional region used tag 0. Reject it immediately,
231        // before any old body or predicate can be read as a retry payload.
232        postcard::from_bytes::<RegionNode>(&[0]).unwrap_err();
233        serde_json::from_str::<RegionNode>(r#"{"Cond":{"condition":{"Const":false},"body":{}}}"#)
234            .unwrap_err();
235        Bloq::from_text("BLOQIR 1\ngraph {\n n0 cond 0 {\n body {\n }\n }\n}").unwrap_err();
236    }
237
238    #[test]
239    fn record_runs_preserve_order_duplicates_and_full_width_ids() {
240        use crate::{ClassicalNode, InstanceMeasurement, TemplateInstanceId};
241
242        for pairs in [
243            vec![],
244            vec![(0, 0)],
245            vec![
246                (300, 8),
247                (300, 2),
248                (300, 2),
249                (0, 0),
250                (300, 1),
251                (u32::MAX, u32::MAX),
252            ],
253            (0..260).map(|i| (i / 130, i)).collect(),
254        ] {
255            let measurements = pairs
256                .into_iter()
257                .map(|(instance, measurement)| InstanceMeasurement {
258                    instance: TemplateInstanceId(instance),
259                    measurement,
260                })
261                .collect::<Vec<_>>();
262            let node = ClassicalNode::observable_fragment(measurements.clone(), Vec::new());
263            let bytes = postcard::to_extend(&node, Vec::new()).unwrap();
264            let restored: ClassicalNode = postcard::from_bytes(&bytes).unwrap();
265            assert_eq!(restored, node);
266            assert_eq!(postcard::to_extend(&restored, Vec::new()).unwrap(), bytes);
267            assert_eq!(
268                serde_json::to_value(&node).unwrap(),
269                serde_json::json!({"Observable": {"index": null, "measurements": measurements, "operators": []}})
270            );
271            assert_eq!(
272                serde_json::from_value::<ClassicalNode>(serde_json::to_value(&node).unwrap())
273                    .unwrap(),
274                node
275            );
276            for length in 0..bytes.len() {
277                postcard::from_bytes::<ClassicalNode>(&bytes[..length]).unwrap_err();
278            }
279        }
280        #[derive(serde::Deserialize)]
281        struct Records(#[serde(with = "super::instance_measurements")] Vec<InstanceMeasurement>);
282        assert!(
283            postcard::from_bytes::<Records>(&postcard::to_extend(&vec![0u64], Vec::new()).unwrap())
284                .unwrap()
285                .0
286                .is_empty()
287        );
288        // Count mismatches, empty runs, and partial runs are malformed.
289        for stream in [
290            vec![],
291            vec![1],
292            vec![1, 0],
293            vec![1, 2, 0, 0, 1],
294            vec![1, 1],
295            vec![1, 1, 0],
296            vec![0, 1, 0, 0],
297            vec![u64::MAX],
298        ] {
299            let bytes = postcard::to_extend(&stream, Vec::new()).unwrap();
300            assert!(postcard::from_bytes::<Records>(&bytes).is_err());
301        }
302    }
303
304    #[test]
305    fn binary_round_trip_preserves_ids_edges_and_bytes() {
306        let bloq = sample_bloq();
307        let bytes = bloq.to_binary();
308        assert_eq!(&bytes[..5], b"BLOQ\x01");
309        let restored = Bloq::from_binary(&bytes).expect("round trip decodes");
310
311        // Node ids survive exactly, hole included (the fixture removes n1).
312        let original_ids: Vec<BloqNodeId> = bloq.node_ids().collect();
313        let restored_ids: Vec<BloqNodeId> = restored.node_ids().collect();
314        assert_eq!(original_ids, restored_ids);
315        assert!(!original_ids.contains(&BloqNodeId(1)));
316
317        // The deterministic schedule (SEM-ORD) is keyed on slot ids.
318        assert_eq!(
319            bloq.deterministic_emit_order().expect("acyclic"),
320            restored.deterministic_emit_order().expect("acyclic"),
321        );
322
323        assert_eq!(bloq.edge_count(), restored.edge_count());
324        assert_eq!(bloq.templates().len(), restored.templates().len());
325
326        // Re-encoding the decoded program is byte-identical (stable encoding).
327        assert_eq!(bytes, restored.to_binary());
328
329        // Editing a boxed seam after cloning still detaches its graph level.
330        let mut edited = restored.clone();
331        let mut seams = 0;
332        edited.top_mut().for_each_edge_mut(|edge| {
333            if let crate::BloqEdge::Quantum(quantum) = edge {
334                quantum.pipes.clear();
335                seams += 1;
336            }
337        });
338        assert!(seams > 0);
339        assert_eq!(bytes, restored.to_binary());
340        assert_ne!(bytes, edited.to_binary());
341    }
342
343    #[test]
344    fn binary_round_trip_keeps_one_shared_bundle_and_two_uses() {
345        let bloq = Bloq::from_text(
346            "BLOQIR 1\n\
347             template t0 {\n  circuit {\n    M (0,0):m0\n  }\n}\n\
348             bundle b0 owners t0 {\n  detector o0:m0\n}\n\
349             graph {\n\
350               n0 quantum {\n    instance i0 t0 @ (0,0)\n    use b0 i0 @ (0,0)\n  }\n\
351               n1 quantum {\n    instance i1 t0 @ (2,0)\n    use b0 i1 @ (2,0)\n  }\n\
352             }\n",
353        )
354        .unwrap();
355        let restored = Bloq::from_binary(&bloq.to_binary()).unwrap();
356        assert_eq!(restored.detector_bundles().len(), 1);
357        assert_eq!(
358            restored[BloqNodeId(0)]
359                .expect_quantum()
360                .detector_bundles
361                .len(),
362            1
363        );
364        assert_eq!(
365            restored[BloqNodeId(1)]
366                .expect_quantum()
367                .detector_bundles
368                .len(),
369            1
370        );
371        assert_eq!(restored.to_binary(), bloq.to_binary());
372    }
373
374    #[test]
375    fn from_binary_rejects_bad_magic_and_version() {
376        assert!(matches!(
377            Bloq::from_binary(b"nope"),
378            Err(BinaryDecodeError::BadMagic)
379        ));
380        assert!(matches!(
381            Bloq::from_binary(b"BLOQ"),
382            Err(BinaryDecodeError::Truncated)
383        ));
384        let mut bytes = sample_bloq().to_binary();
385        bytes[4] = 99;
386        assert!(matches!(
387            Bloq::from_binary(&bytes),
388            Err(BinaryDecodeError::UnsupportedVersion { found: 99 })
389        ));
390    }
391
392    #[test]
393    fn quantum_timeline_round_trips() {
394        let mut bloq = sample_bloq();
395        let timeline = QuantumTimeline {
396            layer_round_ends: vec![3, 3, 8],
397        };
398        bloq.node_mut(BloqNodeId(0))
399            .expect("fixture quantum node")
400            .expect_quantum_mut()
401            .timeline = Some(timeline.clone());
402
403        let restored = Bloq::from_binary(&bloq.to_binary()).expect("round trip decodes");
404
405        assert_eq!(
406            restored[BloqNodeId(0)].expect_quantum().timeline.as_ref(),
407            Some(&timeline)
408        );
409    }
410
411    #[test]
412    fn from_binary_rejects_trailing_bytes() {
413        // A padded or concatenated buffer must not decode as its first program.
414        let mut bytes = sample_bloq().to_binary();
415        bytes.extend_from_slice(b"junk");
416        assert!(matches!(
417            Bloq::from_binary(&bytes),
418            Err(BinaryDecodeError::TrailingData { extra: 4 })
419        ));
420    }
421}