Skip to main content

bloq_ir/text/
mod.rs

1//! The human-readable Bloq IR text exchange format.
2//!
3//! Writing and re-parsing preserves templates (circuits with
4//! explicit measurement ids, side tables, `boundary_flows`), the node graph
5//! with exact node ids (holes from removals included, so the deterministic
6//! schedule survives), and all provenance channels.
7//! [`Bloq`]'s `Display` impl renders this format.
8//!
9//! # Layout
10//!
11//! ```text
12//! BLOQIR 1
13//! metadata bloq_compile.code_distance u64 3
14//! metadata bloq_compile.convention string fixed-bulk
15//! logical-input (0,0,0) instance i0 x X(8,0) z Z(8,0)
16//! logical-output (0,0,1) instance i0 x X(8,0) z Z(8,0)
17//!
18//! template t0 {
19//!   circuit {                           # entry body b0
20//!     H (0,0)
21//!     TICK
22//!     M(0.01) (0,0):m0                  # optional readout-flip probability
23//!     DEPOLARIZE1(0.001) (0,0)
24//!     MPP X(1,1)*X(1,3):m1
25//!     REPEAT 3 b1                       # bodies are flat, by id
26//!     CPAULI X[m0]@(0,0) Z[m2]@(1,0)
27//!   }
28//!   body b1 {
29//!     MX (1,0):m2
30//!   }
31//!   meas m9 @ (4,4)                     # registry records no op implies
32//!   detector -m0*m2 @ (0.5,1)           # optional sign; terms: mN | sN
33//!   detector body(b1) m2*s0
34//!   loop(b1,s0) init m0 next m2
35//!   flow X(0,0) -> Z(1,0)*X(2,0) meas m0*m2 sign -1 center (1,1) restart
36//!   restart m0*m2
37//! }
38//!
39//! bundle b0 owners t0 {
40//!   detector o0:m0 @ (0,1)             # owner slots are bundle-local
41//! }
42//!
43//! graph {
44//!   n0 quantum {
45//!     timeline rounds 3 6                 # cumulative rounds per occupied z layer
46//!     instance i0 t0 @ (8,0)
47//!     detector i0:m0*i1:m2 @ (1,2)      # node parity terms: iN:mK
48//!     use b0 i0 @ (0,0)                # bind owners to global instances
49//!     guard 0 b0                        # guard use index 0 as a whole
50//!     restart i0:m0
51//!     from blocks (0,0,0) (1,0,0)
52//!   }
53//!   n2 observable fragment measurements i0:m0*i0:m2 from generator 0
54//!   n3 compute in0 ^ !(in1 & in2) from frame x (0,0,1)
55//!   n6 observable fragment operators i0 input X(0,0)*Z(0,1)
56//!   n7 observable 0
57//!   result n7:flip                      # optional local bit result; default false
58//!   bindings n6                         # optional fragment/region exports; default empty
59//!   n8 discard in0
60//!   n9 rus in0 {
61//!     body { ... }                      # a nested graph level
62//!   }
63//!   n11 rus in0 source n1:flip {
64//!     body { ... }
65//!   }
66//!   n0 -> n2 order
67//!   n2 -> n7 compose 0
68//!   n6 -> n7 compose 1
69//!   n7 -> n3 value 0 flip                # omitted port means corrected
70//!   n3 -> n7 value 2 feedback 3
71//!   n0 -> n1 quantum (0,0,0)>(0,0,1) padding (0,0,0)>(0,0,1) offset (8,0) one t1 loop t2
72//! }
73//! ```
74//!
75//! `circuit bN { ... }` names a nonzero entry body; `circuit { ... }` means b0.
76//! All other bodies retain their explicit ids.
77//!
78//! Node ids (`nK`) are written explicitly and gaps in the sequence are
79//! reconstructed as vacant graph slots on parse. What is *not* preserved
80//! (by this codec or the binary one) is vacancy past the last live node and
81//! the free-slot reuse order — history that only affects the ids future
82//! `add_node` calls hand out, never the program's semantics.
83//!
84//! Metadata keys and string values use percent-escaped UTF-8 tokens; `~`
85//! represents an empty token. `#` starts a comment anywhere.
86//!
87//! The measurement registry is implied: `M`/`MX`/`MY`/`MPP` op targets define
88//! `(id, qubit)` pairs, and `meas` lines add the records no op implies.
89//!
90//! Parsing rejects structurally malformed input (duplicate node ids, dangling
91//! edge endpoints, one measurement id bound to two qubits, overflowing
92//! coordinate translations, exhausted measurement-output ids, an implausibly
93//! sparse node-id space, or sparse circuit body ids) but does not check IR
94//! semantics — run [`Bloq::validate`] on untrusted input. There is no
95//! cross-version compatibility: the reader rejects any header version other
96//! than its own.
97
98mod parse;
99mod write;
100
101use std::fmt::{self, Write as _};
102
103use crate::Bloq;
104
105pub use parse::TextParseError;
106
107/// The pre-release text format version. IR changes update the codec without
108/// bumping this value until the format is released.
109pub const BLOQ_TEXT_VERSION: u32 = 1;
110
111/// The conventional file extension for the text format.
112pub const BLOQ_TEXT_EXTENSION: &str = "bloqir";
113
114fn encode_metadata_token(value: &str) -> String {
115    if value.is_empty() {
116        return "~".to_owned();
117    }
118    let mut encoded = String::with_capacity(value.len());
119    for byte in value.bytes() {
120        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
121            encoded.push(char::from(byte));
122        } else {
123            write!(encoded, "%{byte:02X}").expect("writing to a String cannot fail");
124        }
125    }
126    encoded
127}
128
129fn decode_metadata_token(token: &str) -> Result<String, &'static str> {
130    if token == "~" {
131        return Ok(String::new());
132    }
133    let source = token.as_bytes();
134    let mut decoded = Vec::with_capacity(source.len());
135    let mut index = 0;
136    while index < source.len() {
137        if source[index] != b'%' {
138            decoded.push(source[index]);
139            index += 1;
140            continue;
141        }
142        let hex = source
143            .get(index + 1..index + 3)
144            .ok_or("incomplete percent escape")?;
145        let digit = |byte: u8| match byte {
146            b'0'..=b'9' => Some(byte - b'0'),
147            b'a'..=b'f' => Some(byte - b'a' + 10),
148            b'A'..=b'F' => Some(byte - b'A' + 10),
149            _ => None,
150        };
151        let high = digit(hex[0]).ok_or("invalid percent escape")?;
152        let low = digit(hex[1]).ok_or("invalid percent escape")?;
153        decoded.push((high << 4) | low);
154        index += 3;
155    }
156    String::from_utf8(decoded).map_err(|_| "metadata token is not UTF-8")
157}
158
159impl Bloq {
160    /// Render this program in the text exchange format (also `Display`).
161    pub fn to_text(&self) -> String {
162        write::write_bloq_text(self)
163    }
164
165    /// Parse a program from the text exchange format.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`TextParseError`] for malformed syntax, unsupported versions,
170    /// invalid references, or structurally invalid serialized data.
171    pub fn from_text(text: &str) -> Result<Self, TextParseError> {
172        let mut bloq = parse::parse_bloq_text(text)?;
173        bloq.top_mut().share_classical_data();
174        Ok(bloq)
175    }
176}
177
178impl fmt::Display for Bloq {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(&self.to_text())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use bloq_circuit::GateType;
187
188    use crate::test_fixture::sample_bloq;
189    use crate::{
190        Bloq, BloqNode, BloqNodeId, BloqValidationError, ClassicalNode, MetadataValue,
191        NodeTemplateInstanceMergeError, QuantumTimeline,
192    };
193
194    /// Structural equality via the binary codec: identical postcard bytes ⇔
195    /// identical programs (slot occupancy included).
196    fn assert_same_program(a: &Bloq, b: &Bloq) {
197        assert_eq!(a.to_binary(), b.to_binary());
198    }
199
200    #[test]
201    fn text_round_trip_is_lossless() {
202        let bloq = sample_bloq();
203        let text = bloq.to_text();
204        assert!(text.starts_with("BLOQIR 1\n"));
205        let restored = Bloq::from_text(&text).unwrap_or_else(|error| {
206            panic!("fixture text must parse: {error}\n---\n{text}");
207        });
208        assert_same_program(&bloq, &restored);
209        // And the text itself is a fixpoint.
210        assert_eq!(text, restored.to_text());
211    }
212
213    #[test]
214    fn detector_bundle_text_round_trip_preserves_sharing_and_guard() {
215        let bloq = Bloq::from_text(
216            "BLOQIR 1\n\
217             template t0 {\n  circuit {\n    M (0,0):m0\n  }\n}\n\
218             bundle b0 owners t0 {\n  detector -o0:m0 @ (1,2)\n}\n\
219             graph {\n\
220               n0 quantum {\n    instance i0 t0 @ (0,0)\n    detector -i0:m0 @ (1,2)\n    use b0 i0 @ (0,0)\n    guard 0 b0\n  }\n\
221               n1 quantum {\n    instance i1 t0 @ (2,0)\n    use b0 i1 @ (2,0)\n  }\n\
222             }\n",
223        )
224        .unwrap();
225        let text = bloq.to_text();
226        assert_eq!(text.matches("bundle b0 owners").count(), 1);
227        assert_eq!(text.matches("use b0").count(), 2);
228        let restored = Bloq::from_text(&text).unwrap();
229        assert_same_program(&bloq, &restored);
230        assert_eq!(restored.detector_bundles().len(), 1);
231        assert_eq!(
232            restored[BloqNodeId(0)].expect_quantum().guards[0].detector_bundles,
233            vec![0]
234        );
235        let mut rows = restored
236            .node_detectors(restored[BloqNodeId(0)].expect_quantum())
237            .unwrap();
238        assert_eq!(
239            rows.next().unwrap().to_owned(),
240            rows.next().unwrap().to_owned()
241        );
242        let second = restored
243            .node_detectors(restored[BloqNodeId(1)].expect_quantum())
244            .unwrap()
245            .next()
246            .unwrap()
247            .to_owned();
248        assert_eq!(second.coords.unwrap().as_slice(), &[3.0, 2.0]);
249    }
250
251    #[test]
252    fn malformed_bundle_tokens_return_parse_errors() {
253        for text in [
254            "BLOQIR 1\nbundle b1 owners {\n}\ngraph {\n}\n",
255            "BLOQIR 1\nbundle b0 owners {\n detector i0:m0\n}\ngraph {\n}\n",
256            "BLOQIR 1\nbundle b0 owners {\n detector o4294967296:m0\n}\ngraph {\n}\n",
257            "BLOQIR 1\ngraph {\n n0 quantum {\n use b0 i0\n }\n}\n",
258        ] {
259            assert!(Bloq::from_text(text).is_err(), "{text}");
260        }
261    }
262
263    #[test]
264    fn malformed_bundle_bindings_return_typed_errors() {
265        use crate::DetectorBundleError;
266
267        let bloq = Bloq::from_text(
268            "BLOQIR 1\n\
269             bundle b0 owners t0 t0 {\n detector o0:m0\n}\n\
270             graph {\n n0 quantum {\n use b0 i0 @ (0,0)\n }\n}\n",
271        )
272        .unwrap();
273        assert!(matches!(
274            bloq.node_detectors(bloq[BloqNodeId(0)].expect_quantum()),
275            Err(DetectorBundleError::BindingArity {
276                expected: 2,
277                actual: 1,
278                ..
279            })
280        ));
281
282        let bloq = Bloq::from_text(
283            "BLOQIR 1\n\
284             bundle b0 owners t0 {\n detector o1:m0\n}\n\
285             graph {\n n0 quantum {\n use b0 i0 @ (0,0)\n }\n}\n",
286        )
287        .unwrap();
288        assert!(matches!(
289            bloq.node_detectors(bloq[BloqNodeId(0)].expect_quantum()),
290            Err(DetectorBundleError::InvalidLocalOwner {
291                owner: 1,
292                owners: 1
293            })
294        ));
295    }
296
297    #[test]
298    fn expression_codecs_preserve_arity_nesting_and_selection_order() {
299        use crate::ClassicalExpr::{self, And, Const, In, Or, Xor};
300        let mut expressions = Vec::new();
301        for build in [Xor, And, Or] {
302            expressions.extend([
303                build(Box::new([])),
304                build(Box::new([In(0)])),
305                build(Box::new([In(0), In(1), In(2)])),
306                build(Box::new([
307                    build(Box::new([In(0), In(1)])),
308                    build(Box::new([In(2), Const(true)])),
309                ])),
310            ]);
311        }
312        expressions.extend([
313            ClassicalExpr::Parity {
314                inputs: Box::new([]),
315                constant: true,
316            },
317            ClassicalExpr::Parity {
318                inputs: Box::new([u32::MAX, 0, u32::MAX]),
319                constant: false,
320            },
321            ClassicalExpr::parity([0, 1, 2], true),
322        ]);
323        expressions.push(ClassicalExpr::select(
324            In(0),
325            Xor(Box::new([In(1), Const(true), Const(false)])),
326            ClassicalExpr::select(In(2), Or(Box::new([])), And(Box::new([]))),
327        ));
328        for expr in expressions {
329            let mut program = Bloq::new();
330            program.add_node(BloqNode::classical(ClassicalNode::Compute { expr }));
331            let text = program.to_text();
332            assert_same_program(&program, &Bloq::from_text(&text).unwrap());
333            assert_same_program(&program, &Bloq::from_binary(&program.to_binary()).unwrap());
334        }
335        let flat = Bloq::from_text("BLOQIR 1\ngraph {\n n0 compute in0 ^ in1 ^ in2\n}\n").unwrap();
336        let ClassicalNode::Compute {
337            expr: Xor(operands),
338        } = flat[BloqNodeId(0)].try_classical().unwrap()
339        else {
340            panic!("flat parity");
341        };
342        assert_eq!(operands.len(), 3);
343        for malformed in [
344            "parity()",
345            "parity(in0)",
346            "parity(0, 1)",
347            "parity(0, !in0)",
348            "select()",
349            "select(0)",
350            "select(0,1)",
351            "select(0,1,0,1)",
352            "select(0 1 0)",
353            "select(0,1,0,)",
354            "xor(0,)",
355            "in0 xor in1",
356        ] {
357            assert!(
358                Bloq::from_text(&format!(
359                    "BLOQIR 1\ngraph {{\n n0 compute {malformed}\n}}\n"
360                ))
361                .is_err(),
362                "{malformed}"
363            );
364        }
365    }
366
367    #[test]
368    fn nonzero_entry_body_round_trips_without_renumbering() {
369        use bloq_circuit::{CircuitBody, CoordCircuit, Op};
370
371        let mut circuit = CoordCircuit::new();
372        circuit.do_gate(GateType::H, [glam::IVec2::ZERO]).unwrap();
373        let entry = circuit.add_body(CircuitBody::new());
374        circuit.set_entry_body(entry).unwrap();
375        circuit.push_repeat(bloq_circuit::BodyId(0), 2);
376        let mut bloq = Bloq::new();
377        let template = bloq
378            .templates_mut()
379            .insert(crate::BloqTemplate::new(circuit));
380        let mut quantum = BloqNode::from_members(vec![]);
381        quantum
382            .expect_quantum_mut()
383            .instances
384            .push(crate::TemplateInstance::new(
385                crate::TemplateInstanceId(0),
386                template,
387                glam::IVec2::ZERO,
388            ));
389        bloq.add_node(quantum);
390        bloq.validate().unwrap();
391        let restored = Bloq::from_binary(&bloq.to_binary()).unwrap();
392        let text = restored.to_text();
393        assert!(text.contains("circuit b1 {"));
394        let parsed = Bloq::from_text(&text).unwrap();
395        assert_same_program(&bloq, &parsed);
396        let circuit = &parsed.templates()[crate::TemplateId(0)].circuit;
397        assert_eq!(circuit.entry_body(), entry);
398        assert!(matches!(
399            circuit.body(entry).unwrap().ops(),
400            [Op::Repeat {
401                body: bloq_circuit::BodyId(0),
402                repetitions: 2
403            }]
404        ));
405        Bloq::from_text("BLOQIR 1\ntemplate t0 {\ncircuit {\n}\ncircuit b1 {\n}\n}\ngraph {\n}\n")
406            .unwrap_err();
407    }
408
409    #[test]
410    fn quantum_timeline_round_trips() {
411        let mut bloq = sample_bloq();
412        let quantum = bloq
413            .node_mut(BloqNodeId(0))
414            .expect("fixture quantum node")
415            .expect_quantum_mut();
416        quantum.timeline = Some(QuantumTimeline {
417            layer_round_ends: vec![3, 3, 8],
418        });
419
420        let text = bloq.to_text();
421        let restored = Bloq::from_text(&text).expect("timeline parses");
422        assert_same_program(&bloq, &restored);
423    }
424
425    #[test]
426    fn spatial_port_provenance_round_trips() {
427        let bloq = Bloq::from_text(
428            "BLOQIR 1\n\
429             template t0 {\n\
430               circuit {\n\
431               }\n\
432             }\n\
433             graph {\n\
434               n0 quantum {\n\
435                 instance i0 t0 @ (0,0) from spatial-port input cube (1,2,3)\n\
436                 from blocks (1,2,3)\n\
437               }\n\
438               n1 quantum {\n\
439                 instance i1 t0 @ (0,0) from spatial-port output temporal-port (4,5,6)\n\
440                 from spatial-port output (4,5,6)\n\
441               }\n\
442             }\n",
443        )
444        .expect("spatial-Port provenance parses");
445
446        let text = bloq.to_text();
447        let restored = Bloq::from_text(&text).expect("spatial-Port provenance re-parses");
448
449        assert_same_program(&bloq, &restored);
450        assert!(text.contains("from spatial-port input cube (1,2,3)"));
451        assert!(text.contains("from spatial-port output (4,5,6)"));
452    }
453
454    #[test]
455    fn text_round_trip_of_empty_program() {
456        let bloq = Bloq::new();
457        let restored = Bloq::from_text(&bloq.to_text()).expect("empty program parses");
458        assert_same_program(&bloq, &restored);
459    }
460
461    #[test]
462    fn signed_parities_and_flows_round_trip() {
463        let bloq = Bloq::from_text(
464            "BLOQIR 1\n\
465             template t0 {\n\
466               circuit {\n\
467               }\n\
468               detector -0\n\
469               flow _ -> Z(0,0) sign -1\n\
470             }\n\
471             graph {\n\
472             }\n",
473        )
474        .expect("signed IR parses");
475
476        let text = bloq.to_text();
477        assert!(text.contains("detector -0"));
478        assert!(text.contains("flow _ -> Z(0,0) sign -1"));
479        let restored = Bloq::from_text(&text).expect("written signed IR parses");
480        assert_same_program(&bloq, &restored);
481    }
482
483    #[test]
484    fn metadata_round_trip_is_lossless() {
485        let mut bloq = Bloq::new();
486        bloq.insert_metadata("compiler key#%", MetadataValue::U64(u64::MAX));
487        bloq.insert_metadata(
488            "compiler.string",
489            MetadataValue::String("value # % \u{96ea}".to_owned()),
490        );
491        bloq.insert_metadata("empty", MetadataValue::String(String::new()));
492
493        let text = bloq.to_text();
494        let restored = Bloq::from_text(&text).expect("metadata parses");
495
496        assert_eq!(bloq.metadata(), restored.metadata());
497        assert_same_program(&bloq, &restored);
498        assert_eq!(text, restored.to_text());
499    }
500
501    #[test]
502    fn from_text_rejects_duplicate_metadata_keys() {
503        let error = Bloq::from_text(
504            "BLOQIR 1\nmetadata key u64 1\nmetadata key string value\ngraph {\n}\n",
505        )
506        .expect_err("duplicate metadata key");
507
508        assert_eq!(error.line, 3);
509        assert!(error.to_string().contains("duplicate metadata key `key`"));
510    }
511
512    #[test]
513    fn output_declarations_have_explicit_defaults_and_reject_duplicates() {
514        let program =
515            Bloq::from_text("BLOQIR 1\ngraph {\n n0 compute 1\n result false\n bindings\n}\n")
516                .unwrap();
517        assert_eq!(program.value_output(), None);
518        assert!(program.boundary_outputs().is_empty());
519        assert!(!program.to_text().contains("result"));
520        for declaration in ["result false\n result n0", "bindings\n bindings n0"] {
521            let source = format!("BLOQIR 1\ngraph {{\n n0 compute 1\n {declaration}\n}}\n");
522            assert!(
523                Bloq::from_text(&source)
524                    .unwrap_err()
525                    .to_string()
526                    .contains("duplicate")
527            );
528        }
529    }
530
531    #[test]
532    fn text_round_trip_of_empty_fragment() {
533        let mut bloq = Bloq::new();
534        bloq.add_node(BloqNode::classical(ClassicalNode::observable_fragment(
535            Vec::new(),
536            Vec::new(),
537        )));
538        let text = bloq.to_text();
539        assert!(text.contains("n0 observable fragment\n"));
540
541        let restored = Bloq::from_text(&text).expect("writer's empty fragment parses");
542
543        assert_same_program(&bloq, &restored);
544    }
545
546    #[test]
547    fn output_ports_and_composition_round_trip_in_every_reference() {
548        let source = "BLOQIR 1\ngraph {\n\
549          n0 observable 0\n\
550          n1 observable fragment\n\
551          n2 compute in0\n\
552          n3 quantum {\n  }\n\
553          n4 quantum {\n  }\n\
554          n5 rus in0 source n0:flip {\n\
555            body {\n n0 observable 1\n result n0:flip\n }\n\
556          }\n\
557          n1 -> n0 compose 0\n\
558          n0 -> n2 value 0 flip readout\n\
559          n0 -> n3 value 0 corrected\n\
560          n3 -> n4 quantum when n0:flip\n\
561          result n0:corrected\n\
562        }\n";
563        let program = Bloq::from_text(source).unwrap();
564        let text = program.to_text();
565        assert!(text.contains("n1 -> n0 compose 0"));
566        assert!(text.contains("value 0 flip readout"));
567        assert!(text.contains("quantum when n0:flip"));
568        assert!(text.contains("source n0:flip"));
569        assert!(text.contains("result n0:flip"));
570        assert!(!text.contains("corrected"), "corrected is the default port");
571        assert_same_program(&program, &Bloq::from_text(&text).unwrap());
572        assert_same_program(&program, &Bloq::from_binary(&program.to_binary()).unwrap());
573    }
574
575    #[test]
576    fn obsolete_nodes_and_unknown_ports_are_rejected() {
577        for statement in [
578            "n0 accumulate",
579            "n0 include",
580            "n0 observable-raw 0",
581            "n0 decode-accept 0",
582            "n0 readout-recipe",
583            "n0 compute 0\n result n0:raw",
584            "n0 compute 0\n n0 -> n0 value 0 raw",
585            "n0 observable fragment\n n0 -> n0 compose 0 flip",
586        ] {
587            let source = format!("BLOQIR 1\ngraph {{\n {statement}\n}}\n");
588            assert!(Bloq::from_text(&source).is_err(), "accepted {statement}");
589        }
590    }
591
592    #[test]
593    fn from_text_rejects_unknown_version() {
594        let error =
595            Bloq::from_text("BLOQIR 99\ngraph {\n}\n").expect_err("version 99 is unsupported");
596        assert_eq!(error.line, 1);
597    }
598
599    #[test]
600    fn from_text_reports_line_numbers() {
601        let error =
602            Bloq::from_text("BLOQIR 1\ngraph {\n  n0 wobble\n}\n").expect_err("unknown node kind");
603        assert_eq!(error.line, 3);
604    }
605
606    #[test]
607    fn rus_without_body_local_restart_source_round_trips() {
608        let source = "BLOQIR 1\ngraph {\n n0 rus 0 {\n body {\n }\n }\n}\n";
609        let program = Bloq::from_text(source).unwrap();
610        program.validate().unwrap();
611        assert!(!program.to_text().contains("source"));
612        assert_same_program(&program, &Bloq::from_text(&program.to_text()).unwrap());
613        assert_same_program(&program, &Bloq::from_binary(&program.to_binary()).unwrap());
614    }
615
616    #[test]
617    fn rus_unfed_restart_input_without_source_fails_validation() {
618        let program =
619            Bloq::from_text("BLOQIR 1\ngraph {\n n0 rus in0 {\n body {\n }\n }\n}\n").unwrap();
620        assert!(program.validate().is_err());
621    }
622
623    #[test]
624    fn from_text_rejects_duplicate_node_at_its_line() {
625        let error = Bloq::from_text("BLOQIR 1\ngraph {\n  n0 observable 0\n  n0 observable 1\n}\n")
626            .expect_err("duplicate node id");
627        assert_eq!(error.line, 4);
628    }
629
630    #[test]
631    fn from_text_rejects_dangling_edge_endpoint_at_its_line() {
632        let error = Bloq::from_text("BLOQIR 1\ngraph {\n  n0 observable 0\n  n0 -> n7 order\n}\n")
633            .expect_err("edge endpoint is not a node");
634        assert_eq!(error.line, 4);
635    }
636
637    /// A single huge node id must be rejected up front — materializing its
638    /// vacant slots would cost O(id), an OOM lever on untrusted input.
639    #[test]
640    fn from_text_rejects_implausibly_sparse_node_ids() {
641        let error = Bloq::from_text("BLOQIR 1\ngraph {\n  n4294967295 observable 0\n}\n")
642            .expect_err("sparse id space");
643        assert_eq!(error.line, 3);
644    }
645
646    #[test]
647    fn from_text_rejects_sparse_body_ids_without_allocating_to_the_id() {
648        let error =
649            Bloq::from_text("BLOQIR 1\ntemplate t0 {\n  body b4294967295 {\n  }\n}\ngraph {\n}\n")
650                .expect_err("sparse body id");
651        assert_eq!(error.line, 3);
652    }
653
654    #[test]
655    fn from_text_rejects_empty_mpp_products() {
656        let error = Bloq::from_text(
657            "BLOQIR 1\ntemplate t0 {\n  circuit {\n    MPP _:m0\n  }\n}\ngraph {\n}\n",
658        )
659        .expect_err("empty MPP product");
660        assert_eq!(error.line, 4);
661    }
662
663    #[test]
664    fn zero_product_mpp_is_invalid_before_and_after_binary_round_trip() {
665        let mut bloq = Bloq::from_text(
666            "BLOQIR 1\ntemplate t0 {\ncircuit {\n}\n}\ngraph {\nn0 quantum {\ninstance i0 t0 @ (0,0)\n}\n}\n",
667        ).unwrap();
668        bloq.templates_mut()
669            .make_mut(crate::TemplateId(0))
670            .unwrap()
671            .circuit
672            .body_mut(bloq_circuit::BodyId(0))
673            .unwrap()
674            .ops_mut()
675            .push(bloq_circuit::Op::MPP {
676                products: vec![],
677                measurements: vec![],
678            });
679        let restored = Bloq::from_binary(&bloq.to_binary()).unwrap();
680        for program in [&bloq, &restored] {
681            assert!(matches!(
682                program.validate(),
683                Err(BloqValidationError::InvalidTemplateCircuit {
684                    source: NodeTemplateInstanceMergeError::EmptyMpp,
685                    ..
686                })
687            ));
688            Bloq::from_text(&program.to_text()).unwrap_err();
689        }
690    }
691
692    #[test]
693    fn cyclic_repeat_from_text_validates_as_an_error() {
694        let bloq = Bloq::from_text(
695            "BLOQIR 1\ntemplate t0 {\n  circuit {\n    REPEAT 2 b0\n  }\n}\ngraph {\n  n0 quantum {\n    instance i0 t0 @ (0,0)\n  }\n}\n",
696        )
697        .expect("cyclic repeat is semantic, not syntax");
698        assert!(matches!(
699            bloq.validate().unwrap_err(),
700            BloqValidationError::InvalidTemplateCircuit {
701                source: NodeTemplateInstanceMergeError::CyclicBody(bloq_circuit::BodyId(0)),
702                ..
703            }
704        ));
705    }
706
707    /// One measurement id bound to two different qubits must be a parse
708    /// error, not the `MeasRegistry::reserve` panic.
709    #[test]
710    fn from_text_rejects_conflicting_measurement_records() {
711        let error = Bloq::from_text(
712            "BLOQIR 1\ntemplate t0 {\n  circuit {\n    M (0,0):m0\n  }\n  \
713             meas m0 @ (1,1)\n}\ngraph {\n}\n",
714        )
715        .expect_err("conflicting measurement qubits");
716        assert_eq!(error.line, 6);
717    }
718
719    #[test]
720    fn from_text_rejects_conflicting_implied_measurements_at_op_line() {
721        let error = Bloq::from_text(
722            "BLOQIR 1\ntemplate t0 {\n  circuit {\n    M (0,0):m0 (1,0):m0\n  }\n}\ngraph {\n}\n",
723        )
724        .expect_err("one implied id cannot name two qubits");
725
726        assert_eq!(error.line, 4);
727    }
728
729    #[test]
730    fn from_text_rejects_exhausted_measurement_outputs() {
731        let cases = [
732            (
733                "BLOQIR 1\ntemplate t0 {\n  meas m4294967295 @ (0,0)\n}\ngraph {\n}\n",
734                3,
735            ),
736            (
737                "BLOQIR 1\ntemplate t0 {\n  circuit {\n    M (0,0):m4294967295\n  }\n}\ngraph {\n}\n",
738                4,
739            ),
740            (
741                "BLOQIR 1\ntemplate t0 {\n  circuit {\n    MPP X(0,0):m4294967295\n  }\n}\ngraph {\n}\n",
742                4,
743            ),
744        ];
745
746        for (source, line) in cases {
747            let error = Bloq::from_text(source).expect_err("mMAX cannot be registered");
748            assert_eq!(error.line, line);
749            assert!(
750                error
751                    .to_string()
752                    .contains("exhausts the measurement id space")
753            );
754        }
755    }
756
757    #[test]
758    fn from_text_rejects_overflowing_instance_translation() {
759        let error = Bloq::from_text(
760            "BLOQIR 1\ntemplate t0 {\n  circuit {\n    H (2147483647,0)\n  }\n}\ngraph {\n  n0 quantum {\n    instance i0 t0 @ (1,0)\n  }\n}\n",
761        )
762        .expect_err("translated coordinate exceeds i32");
763
764        assert!(error.to_string().contains("overflows after offset"));
765    }
766
767    /// Id sigils are digits-only; `u32::from_str`'s leading `+` is malformed.
768    #[test]
769    fn from_text_rejects_plus_prefixed_ids() {
770        let error = Bloq::from_text("BLOQIR 1\ngraph {\n  n+0 observable 0\n}\n")
771            .expect_err("`n+0` is not a node id");
772        assert_eq!(error.line, 3);
773    }
774
775    #[test]
776    fn from_text_rejects_plus_prefixed_correction_and_parity_ids() {
777        let cases = [
778            (
779                "BLOQIR 1\ntemplate t0 {\n  circuit {\n    CPAULI X[m+0]@(0,0)\n  }\n}\ngraph {\n}\n",
780                4,
781                "expected `m<N>`",
782            ),
783            (
784                "BLOQIR 1\ntemplate t0 {\n  loop(b0,s0) init s+0 next 0\n}\ngraph {\n}\n",
785                3,
786                "expected `s<N>`",
787            ),
788            (
789                "BLOQIR 1\ntemplate t0 {\n  detector s+0\n}\ngraph {\n}\n",
790                3,
791                "expected `s<N>`",
792            ),
793        ];
794
795        for (source, line, expected) in cases {
796            let error = Bloq::from_text(source).expect_err("plus-prefixed id is malformed");
797            assert_eq!(error.line, line);
798            assert!(error.to_string().contains(expected));
799        }
800    }
801}