diff --git a/frontend/src/elaborate.rs b/frontend/src/elaborate.rs index 74b5c4e..4701ba4 100644 --- a/frontend/src/elaborate.rs +++ b/frontend/src/elaborate.rs @@ -33,6 +33,7 @@ use std::sync::Arc; use chumsky::span::SimpleSpan; use quon_core::DepthExpr; +use quon_core::gates::surface_gate; use thiserror::Error; use crate::ast::{BinOp, Expr, LitPat, Pat, Stmt}; @@ -539,7 +540,8 @@ pub fn elaborate_circuit_body( } } if let Expr::Controlled(inner) = &gate.0 { - let (control, target) = tuple2(&qubits)?; + let (control, target) = split_control_target(&qubits)?; + let target = singleton_target(target); return decompose_controlled( inner, &control, @@ -1178,6 +1180,145 @@ fn tuple2(qubits: &Sp) -> Result<(Sp, Sp), ElabError> { } } +/// Split `controlled(c) @ qubits` into the control wire and the body target. +/// +/// `(control, target)` keeps `target` as written (an index or a nested tuple). +/// A longer flat tuple `@(control, t1, t2, …)` is the same body wires without +/// the inner parentheses. +fn split_control_target(qubits: &Sp) -> Result<(Sp, Sp), ElabError> { + match &qubits.0 { + Expr::Tuple(items) if items.len() == 2 => Ok((items[0].clone(), items[1].clone())), + Expr::Tuple(items) if items.len() > 2 => { + let body = (Expr::Tuple(items[1..].to_vec()), qubits.1); + Ok((items[0].clone(), body)) + } + _ => Err(ElabError::unsupported( + "expected controlled placement `(control, target)`", + qubits.1, + )), + } +} + +/// A one-element target tuple is just that element. +fn singleton_target(target: Sp) -> Sp { + match &target.0 { + Expr::Tuple(items) if items.len() == 1 => items[0].clone(), + _ => target, + } +} + +/// Body wires of a controlled placement: an explicit list, or one start index +/// that expands contiguously. +struct BodyWires { + explicit: Option>>, + start: Option>, +} + +fn body_wires(target: &Sp) -> BodyWires { + match &target.0 { + Expr::Tuple(items) if !items.is_empty() => BodyWires { + explicit: Some(items.clone()), + start: None, + }, + _ => BodyWires { + explicit: None, + start: Some(target.clone()), + }, + } +} + +/// The `width` body wires beginning at `offset`. +/// +/// Explicit tuples are sliced. A start index `s` yields `s+offset`, …, +/// `s+offset+width-1` (a single index when `width` is 1). +fn wire_slice( + wires: &BodyWires, + offset: i64, + width: i64, + span: SimpleSpan, +) -> Result, ElabError> { + if width <= 0 || offset < 0 { + return Err(ElabError::unsupported( + "controlled(par) arm with no wires", + span, + )); + } + if let Some(explicit) = &wires.explicit { + let start = offset as usize; + let end = start.saturating_add(width as usize); + if end > explicit.len() { + return Err(ElabError::unsupported( + "controlled(par) target tuple does not cover the body", + span, + )); + } + let slice = &explicit[start..end]; + if slice.len() == 1 { + Ok(slice[0].clone()) + } else { + Ok((Expr::Tuple(slice.to_vec()), span)) + } + } else if let Some(start) = &wires.start { + // Shifting or expanding a non-literal leaves every arm on `start` + // (`shift_qubit_targets` only rewrites `Expr::Int`). Fail closed. + if (offset != 0 || width != 1) && !matches!(start.0, Expr::Int(n) if n >= 0) { + return Err(ElabError::unsupported( + "controlled(par) start index must be a non-negative literal", + start.1, + )); + } + if width == 1 { + return Ok(shift_qubit_targets(start, offset)); + } + let mut items = Vec::with_capacity(width as usize); + for i in 0..width { + items.push(shift_qubit_targets(start, offset.saturating_add(i))); + } + Ok((Expr::Tuple(items), span)) + } else { + Err(ElabError::unsupported( + "controlled(par) is missing its target", + span, + )) + } +} + +/// How many body wires `elem` occupies. Named gates use their registry arity; +/// anything else is the elaborated tree's highest index plus one (arms are +/// indexed from 0). +fn arm_wire_width( + elem: &Sp, + classical_env: &ClassicalEnv, + ctx: &ElabCtx, + fuel: &mut u32, +) -> Result { + if let Some(width) = primitive_wire_count(&elem.0) { + return Ok(width); + } + let elaborated = elaborate_circuit_body(elem, classical_env, ctx, fuel)?; + Ok(max_qubit_index(&elaborated) + .map(|m| m as i64 + 1) + .unwrap_or(1)) +} + +fn primitive_wire_count(expr: &Expr) -> Option { + let name = match expr { + Expr::Var(name) => name.as_str(), + Expr::App(f, x) => { + let (head, args) = flatten_app(f, x); + if args.len() != 1 { + return None; + } + let Expr::Var(name) = &head.0 else { + return None; + }; + name.as_str() + } + _ => return None, + }; + surface_gate(name).map(|info| info.arity as i64) +} + fn gate_app(name: &str, qubit: &Sp, span: chumsky::span::SimpleSpan) -> Sp { ( Expr::GateApp { @@ -1289,19 +1430,20 @@ fn elaborate_named_callee( Ok(None) } -/// `controlled(c) @ (control, target)` (SPEC §4.4 / issue #182). +/// `controlled(c) @ (control, target)` (SPEC §4.4 / issue #182, #369). /// /// Control distributes over sequential composition and circuit blocks: /// `controlled(A |> B) = controlled(A) |> controlled(B)` on the same wires. -/// `par { body } * k` expands to `k` controlled copies on contiguous targets -/// `target, target+1, …` when `body` is width-1 (the only shape this path -/// places with a 2-tuple `(control, target)` start). Clifford+T single-qubit -/// generators and `Rx`/`Ry`/`Rz` use known decompositions into `CNOT`/`CZ`/ -/// `CY`/`Rz`/local singles. A controlled call to a *named parametric circuit* -/// or a zero-arg circuit function (issue #374) is elaborated first — its -/// `for`/`repeat`/`let`/nested-call structure unrolled into a concrete gate -/// tree — then control distributes over the result. Anything else is a -/// span-accurate [`ElabError::Unsupported`]. +/// `target` is either one start index — `par` then occupies the contiguous +/// wires `target, target+1, …` — or an explicit tuple of body wires +/// (`@(control, (t1, t2))`, or the flat `@(control, t1, t2)`). Each `par` arm +/// consumes a slice of that tuple whose length is the arm's width. +/// Clifford+T single-qubit generators and `Rx`/`Ry`/`Rz` use known +/// decompositions into `CNOT`/`CZ`/`CY`/`Rz`/local singles. A controlled call +/// to a *named parametric circuit* or a zero-arg circuit function (issue #374) +/// is elaborated first — its `for`/`repeat`/`let`/nested-call structure +/// unrolled into a concrete gate tree — then control distributes over the +/// result. Anything else is a span-accurate [`ElabError::Unsupported`]. fn decompose_controlled( inner: &Sp, control: &Sp, @@ -1340,30 +1482,47 @@ fn decompose_controlled( if k == 0 { return Ok(empty_circuit(span)); } - // Width-1 body copies land on contiguous targets starting at `target`. + let width = arm_wire_width(body, classical_env, ctx, fuel)?; + let wires = body_wires(target); + if let Some(explicit) = &wires.explicit { + let need = k.saturating_mul(width); + if explicit.len() as i64 != need { + return Err(fail( + "controlled(par) target tuple does not match the body width", + )); + } + } let mut composed = empty_circuit(span); for i in 0..k { - let t = shift_qubit_targets(target, i); + let t = wire_slice(&wires, i.saturating_mul(width), width, span)?; let step = decompose_controlled(body, control, &t, classical_env, ctx, fuel, span)?; composed = compose_nonempty(composed, step, span); } Ok(composed) } Expr::ParN(elems) => { - // `controlled(par { c₁, …, cₖ })` distributes control over each arm, - // each on a contiguous target slice. The arm's width (read from its - // elaborated gate tree) offsets the next arm's targets, mirroring the - // `Par` repeat arm. Falls back to the generic "unsupported" error if - // an arm is not a decomposable width-1 body. + // `controlled(par { c₁, …, cₖ })` distributes control over each arm. + // An explicit target tuple is sliced by each arm's width; a single + // start index lays the arms on contiguous wires. The width is the + // *body's* width (not the controlled decomposition, which also + // touches the control wire). + let wires = body_wires(target); let mut composed = empty_circuit(span); let mut offset = 0i64; for elem in elems { - let t = shift_qubit_targets(target, offset); + let w = arm_wire_width(elem, classical_env, ctx, fuel)?; + let t = wire_slice(&wires, offset, w, span)?; let step = decompose_controlled(elem, control, &t, classical_env, ctx, fuel, span)?; - let w = max_qubit_index(&step).map(|m| m as i64 + 1).unwrap_or(1); - offset += w; + offset = offset.saturating_add(w); composed = compose_nonempty(composed, step, span); } + if let Some(explicit) = &wires.explicit + && offset != explicit.len() as i64 + { + return Err(fail( + "controlled(par) target tuple does not match the body width", + )); + } Ok(composed) } Expr::Adjoint(c) => { @@ -1488,6 +1647,16 @@ fn controlled_named_gate( _classical_env: &ClassicalEnv, span: SimpleSpan, ) -> Result, ElabError> { + // Every decomposition below is a single-qubit generator on `target`. A + // tuple here would nest the control pair and silently drop body wires. + if matches!(&target.0, Expr::Tuple(items) if items.len() != 1) { + return Err(ElabError::unsupported( + "controlled() of a multi-qubit gate", + target.1, + )); + } + let target_owned = singleton_target(target.clone()); + let target = &target_owned; let ct = control_target_tuple(control, target, span); let cnot = || cnot_app(ct.clone(), span); match (name, angle) { @@ -1886,6 +2055,227 @@ mod controlled_tests { ); } + fn placement_targets(expr: &Sp) -> Vec> { + let placements = collect_gate_placements(expr).expect("placements"); + placements + .iter() + .map(|(_, q)| match &q.0 { + Expr::Int(n) => vec![*n], + Expr::Tuple(items) => items + .iter() + .map(|i| match i.0 { + Expr::Int(n) => n, + _ => panic!("non-literal qubit"), + }) + .collect(), + _ => panic!("bad qubit expr"), + }) + .collect() + } + + fn controlled_at(inner: Sp, target: Sp) -> Result, ElabError> { + let ctx = empty_ctx(); + let mut fuel = 10_000u32; + decompose_controlled( + &inner, + &lit_int(0), + &target, + &HashMap::new(), + &ctx, + &mut fuel, + no_span(), + ) + } + + fn cnot_operand_pairs(expr: &Sp) -> Vec> { + let placements = collect_gate_placements(expr).expect("placements"); + placements + .iter() + .filter(|(gate, _)| matches!(&gate.0, Expr::Var(name) if name == "CNOT")) + .map(|(_, qubits)| match &qubits.0 { + Expr::Tuple(items) => items + .iter() + .map(|item| match item.0 { + Expr::Int(n) => n, + _ => panic!("non-literal CNOT operand"), + }) + .collect(), + _ => panic!("CNOT target is not a pair"), + }) + .collect() + } + + /// The surface spelling `@(0, (1, 2))` goes through `split_control_target` + /// (nested body tuple), not a target that was already split. + #[test] + fn controlled_parn_surface_nested_tuple_cnot_pairs() { + let h = ( + Expr::GateApp { + gate: Box::new(var("H")), + qubits: Box::new(lit_int(0)), + }, + no_span(), + ); + let body = (Expr::ParN(vec![h.clone(), h]), no_span()); + let qubits = ( + Expr::Tuple(vec![ + lit_int(0), + (Expr::Tuple(vec![lit_int(1), lit_int(2)]), no_span()), + ]), + no_span(), + ); + let app = ( + Expr::GateApp { + gate: Box::new((Expr::Controlled(Box::new(body)), no_span())), + qubits: Box::new(qubits), + }, + no_span(), + ); + let ctx = empty_ctx(); + let mut fuel = 10_000u32; + let elaborated = + elaborate_circuit_body(&app, &HashMap::new(), &ctx, &mut fuel).expect("elaborate"); + assert_eq!( + cnot_operand_pairs(&elaborated), + vec![vec![0, 1], vec![0, 2]] + ); + } + + /// Flat `@(0, 1, 2)` is the same wires without the inner parentheses. + #[test] + fn controlled_parn_surface_flat_tuple_cnot_pairs() { + let h = ( + Expr::GateApp { + gate: Box::new(var("H")), + qubits: Box::new(lit_int(0)), + }, + no_span(), + ); + let body = (Expr::ParN(vec![h.clone(), h]), no_span()); + let app = ( + Expr::GateApp { + gate: Box::new((Expr::Controlled(Box::new(body)), no_span())), + qubits: Box::new(( + Expr::Tuple(vec![lit_int(0), lit_int(1), lit_int(2)]), + no_span(), + )), + }, + no_span(), + ); + let ctx = empty_ctx(); + let mut fuel = 10_000u32; + let elaborated = + elaborate_circuit_body(&app, &HashMap::new(), &ctx, &mut fuel).expect("elaborate"); + assert_eq!( + cnot_operand_pairs(&elaborated), + vec![vec![0, 1], vec![0, 2]] + ); + } + + /// Contiguous `@(control, start)` with a literal start. + #[test] + fn controlled_par_surface_literal_start_cnot_pairs() { + let body = ( + Expr::Par(Box::new(var("H")), Box::new(lit_int(2))), + no_span(), + ); + let app = ( + Expr::GateApp { + gate: Box::new((Expr::Controlled(Box::new(body)), no_span())), + qubits: Box::new((Expr::Tuple(vec![lit_int(0), lit_int(1)]), no_span())), + }, + no_span(), + ); + let ctx = empty_ctx(); + let mut fuel = 10_000u32; + let elaborated = + elaborate_circuit_body(&app, &HashMap::new(), &ctx, &mut fuel).expect("elaborate"); + assert_eq!( + cnot_operand_pairs(&elaborated), + vec![vec![0, 1], vec![0, 2]] + ); + } + + #[test] + fn controlled_par_non_literal_start_fails_closed() { + let body = ( + Expr::Par(Box::new(var("H")), Box::new(lit_int(2))), + no_span(), + ); + let err = controlled_at(body, var("s")).expect_err("symbolic start"); + match err { + ElabError::Unsupported { construct, .. } => { + assert!( + construct.contains("literal"), + "expected a literal-start rejection, got {construct}" + ); + } + other => panic!("unexpected {other}"), + } + } + + /// Issue #369: `par { H @0, H @0 }` under `@(0, (1, 2))` places CH on + /// (0, 1) and (0, 2), not on a shifted copy of the whole tuple. + #[test] + fn controlled_parn_explicit_target_tuple() { + let h = ( + Expr::GateApp { + gate: Box::new(var("H")), + qubits: Box::new(lit_int(0)), + }, + no_span(), + ); + let body = (Expr::ParN(vec![h.clone(), h]), no_span()); + let target = (Expr::Tuple(vec![lit_int(1), lit_int(2)]), no_span()); + let elaborated = controlled_at(body, target).expect("decompose parn"); + let targets = placement_targets(&elaborated); + assert!( + targets.iter().any(|t| t == &[0, 1]) && targets.iter().any(|t| t == &[0, 2]), + "expected pairs on (0,1) and (0,2), got {targets:?}" + ); + assert!( + !targets.iter().any(|t| t.contains(&3)), + "control wire must not inflate the arm offset, got {targets:?}" + ); + } + + /// A start index lays repeat-copies on contiguous wires, including when + /// the targets are not a prefix of `1, 2` (explicit tuple, non-contiguous). + #[test] + fn controlled_par_repeat_explicit_targets_need_not_be_contiguous() { + let body = ( + Expr::Par(Box::new(var("H")), Box::new(lit_int(2))), + no_span(), + ); + let target = (Expr::Tuple(vec![lit_int(1), lit_int(3)]), no_span()); + let elaborated = controlled_at(body, target).expect("decompose par"); + let targets = placement_targets(&elaborated); + assert!( + targets.iter().any(|t| t == &[0, 1]) && targets.iter().any(|t| t == &[0, 3]), + "expected pairs on (0,1) and (0,3), got {targets:?}" + ); + } + + /// ParN start-index layout offsets by the body's width, not by the + /// controlled step's max index (which includes the control wire). + #[test] + fn controlled_parn_start_index_is_contiguous() { + let h = ( + Expr::GateApp { + gate: Box::new(var("H")), + qubits: Box::new(lit_int(0)), + }, + no_span(), + ); + let body = (Expr::ParN(vec![h.clone(), h]), no_span()); + let elaborated = controlled_at(body, lit_int(1)).expect("decompose parn"); + let targets = placement_targets(&elaborated); + assert!( + targets.iter().any(|t| t == &[0, 1]) && targets.iter().any(|t| t == &[0, 2]), + "expected pairs on (0,1) and (0,2), got {targets:?}" + ); + } + #[test] fn unsupported_nested_controlled_names_construct_and_span() { let nested = ( diff --git a/frontend/src/typecheck/circuit.rs b/frontend/src/typecheck/circuit.rs index 1f7f42c..08f9a79 100644 --- a/frontend/src/typecheck/circuit.rs +++ b/frontend/src/typecheck/circuit.rs @@ -124,6 +124,80 @@ pub fn is_specialisable_rotation(name: &str) -> bool { // generic `synth`/`check`/`expr_to_depth`/`depth_of` helpers (child-module access). Only the // methods the facade dispatches into are `pub(super)`; intra-circuit helpers stay private. +/// Wires named by a `controlled(c) @ …` placement, plus the last index implied +/// by the contiguous-start shorthand (when the body is `par` and only +/// `(control, start)` was written). +struct ControlledPlacement<'a> { + indices: Vec<&'a Sp>, + implied_last: Option, +} + +/// Reinterpret a controlled gate's `@` operand (issue #369). +/// +/// Returns `None` when `gate` is not `controlled(...)` applied to a tuple, so +/// the caller keeps the flat "one slot per qubit" rule. `Some(Err)` is a +/// target-count mismatch under the controlled spelling. +fn controlled_placement<'a>( + gate: &'a Expr, + qubits: &'a Sp, + arity: u64, +) -> Option, TypeError>> { + let Expr::Controlled(inner) = gate else { + return None; + }; + let Expr::Tuple(items) = &qubits.0 else { + return None; + }; + if items.is_empty() { + return None; + } + // `(control, target)` — `target` is one index, or a tuple of body wires. + // A longer tuple is the flat spelling `@(c, t1, t2, …)`. + let (indices, body_is_single_index) = if items.len() == 2 { + match &items[1].0 { + Expr::Tuple(body) => { + let mut indices = Vec::with_capacity(1 + body.len()); + indices.push(&items[0]); + indices.extend(body.iter()); + (indices, false) + } + _ => (vec![&items[0], &items[1]], true), + } + } else { + (items.iter().collect(), false) + }; + if indices.len() as u64 == arity { + return Some(Ok(ControlledPlacement { + indices, + implied_last: None, + })); + } + // `controlled(par …) @(control, start)` occupies `start .. start + k` + // contiguously. The two written indices are control and that start. + // `start` must be a non-negative literal. Otherwise `implied_last` would + // stay unset (so `start + i` is never compared to the register width) and + // elaboration, which only rewrites `Expr::Int`, would leave every arm on + // `start`. + let par_body = matches!(inner.0, Expr::Par(_, _) | Expr::ParN(_)); + if par_body && body_is_single_index && items.len() == 2 && arity > 2 && indices.len() == 2 { + let Some(start) = literal_index(indices[1]) else { + return Some(Err(TypeError::NonLiteralControlledStart { + span: indices[1].1, + })); + }; + let implied_last = start.saturating_add(arity - 2); + return Some(Ok(ControlledPlacement { + indices, + implied_last: Some(implied_last), + })); + } + Some(Err(TypeError::GateTargetArity { + expected: arity, + found: indices.len(), + span: qubits.1, + })) +} + impl super::TypeChecker { /// View `ty` as a circuit, returning its four indices. fn as_circuit( @@ -152,28 +226,49 @@ impl super::TypeChecker { /// Place a gate (`gate_ty : Circuit`) onto qubit targets within the ambient /// register, yielding `Circuit` (SPEC §5.6). The number of targets must equal /// the gate's arity, and each literal index must lie in `0..w`. + /// + /// `controlled(c) @ …` is the exception (issue #369). Its surface form is the pair + /// `(control, target)`, and `target` may itself be the body's wire tuple + /// (`@(0, (1, 2))`) or, for `par`, a single start index that occupies the next + /// `arity - 1` contiguous wires. Those spellings name `arity` qubits — the + /// controlled circuit's width — rather than `arity` flat tuple slots. pub(super) fn place_gate( &mut self, env: &Env, delta: &mut Delta, + gate: &Sp, gate_ty: Ty, qubits: &Sp, ) -> Result { let (gn, _, gd, gc) = self.as_circuit(&gate_ty, qubits.1)?; let arity = gn.as_const().unwrap_or(1); - let targets: Vec<&Sp> = match &qubits.0 { - Expr::Tuple(es) => es.iter().collect(), - _ => vec![qubits], + let (targets, implied_last) = match controlled_placement(&gate.0, qubits, arity) { + Some(placed) => { + let placed = placed?; + (placed.indices, placed.implied_last) + } + None => { + let targets: Vec<&Sp> = match &qubits.0 { + Expr::Tuple(es) => es.iter().collect(), + _ => vec![qubits], + }; + if targets.len() as u64 != arity { + return Err(TypeError::GateTargetArity { + expected: arity, + found: targets.len(), + span: qubits.1, + }); + } + (targets, None) + } }; - if targets.len() as u64 != arity { - return Err(TypeError::GateTargetArity { - expected: arity, - found: targets.len(), - span: qubits.1, - }); - } let width = self.ambient_width(qubits.1)?; - let placement = self.placement_width(&width, &targets)?; + let mut placement = self.placement_width(&width, &targets)?; + if let Some(last) = implied_last + && let Some(p) = placement.as_const() + { + placement = DepthExpr::Nat(p.max(last.saturating_add(1))); + } for t in &targets { self.check(env, delta, t, &Ty::Int)?; if let (Some(w), Some(idx)) = (placement.as_const(), literal_index(t)) @@ -190,12 +285,15 @@ impl super::TypeChecker { && let (Some(p), Some(m)) = (placement.as_const(), cap.as_const()) && p > m { + let written = targets.iter().filter_map(|t| literal_index(t)).max(); + let index = match (written, implied_last) { + (Some(idx), Some(last)) => idx.max(last), + (Some(idx), None) => idx, + (None, Some(last)) => last, + (None, None) => 0, + }; return Err(TypeError::IndexOutOfBounds { - index: targets - .iter() - .filter_map(|t| literal_index(t)) - .max() - .unwrap_or(0), + index, width: m, span: qubits.1, }); diff --git a/frontend/src/typecheck/error.rs b/frontend/src/typecheck/error.rs index 3078856..b80712a 100644 --- a/frontend/src/typecheck/error.rs +++ b/frontend/src/typecheck/error.rs @@ -201,6 +201,11 @@ pub enum TypeError { }, /// QEC constructor distance is not a literal `Nat` (deferred specialization unsupported). NonLiteralQecDistance { span: SimpleSpan }, + /// `controlled(par …) @(control, start)` names a contiguous run of body wires. + /// `start` has to be a non-negative integer literal: each implied index + /// `start + i` is bounds-checked, and elaboration only shifts `Expr::Int`. + /// A symbolic start would leave every arm on `start` and skip the width check. + NonLiteralControlledStart { span: SimpleSpan }, /// A construct that belongs to the linear/quantum fragment (issues #10–#15) was /// encountered while type-checking the classical fragment. Unsupported { @@ -250,6 +255,7 @@ impl TypeError { | TypeError::NonCliffordDistanceMismatch { span, .. } | TypeError::QecCtorRequiresDistance { span, .. } | TypeError::NonLiteralQecDistance { span } + | TypeError::NonLiteralControlledStart { span } | TypeError::Unsupported { span, .. } => *span, } } @@ -296,6 +302,7 @@ impl TypeError { } TypeError::QecCtorRequiresDistance { .. } => DiagnosticCode::QEC_CTOR_REQUIRES_DISTANCE, TypeError::NonLiteralQecDistance { .. } => DiagnosticCode::QEC_NON_LITERAL_DISTANCE, + TypeError::NonLiteralControlledStart { .. } => DiagnosticCode::CIRCUIT_GATE_TARGET, TypeError::Unsupported { .. } => DiagnosticCode::UNSUPPORTED_QUANTUM, } } @@ -523,6 +530,10 @@ impl fmt::Display for TypeError { f, "QEC constructor distance must be a literal Nat (e.g. `repetition_code<3>()`)" ), + TypeError::NonLiteralControlledStart { .. } => write!( + f, + "controlled(par) @(control, start) requires a non-negative literal start index" + ), TypeError::Unsupported { construct, .. } => write!( f, "`{construct}` is part of the linear/quantum fragment and is not yet type-checked" diff --git a/frontend/src/typecheck/mod.rs b/frontend/src/typecheck/mod.rs index 93f10de..7553ce6 100644 --- a/frontend/src/typecheck/mod.rs +++ b/frontend/src/typecheck/mod.rs @@ -612,7 +612,7 @@ impl TypeChecker { if self.circuit_width.is_empty() { self.apply_circuit(env, delta, g, qubits) } else { - self.place_gate(env, delta, g, qubits) + self.place_gate(env, delta, gate, g, qubits) } } Expr::Compose(l, r) => self.synth_compose(env, delta, l, r, span), @@ -1049,7 +1049,7 @@ impl TypeChecker { return if self.circuit_width.is_empty() { self.apply_circuit(env, delta, f_ty, x) } else { - self.place_gate(env, delta, f_ty, x) + self.place_gate(env, delta, f, f_ty, x) }; } let (dom, cod) = self.as_function(&f_ty, f.1)?; diff --git a/frontend/src/typecheck/tests.rs b/frontend/src/typecheck/tests.rs index 304061c..dd4e23c 100644 --- a/frontend/src/typecheck/tests.rs +++ b/frontend/src/typecheck/tests.rs @@ -699,6 +699,166 @@ fn controlled_increments_widths_and_depth() { accepts("fn f(c: Circuit<2,2,2,Clifford>): Circuit<3,3,3,Clifford> = controlled(c)"); } +/// Issue #369: `@(control, (t1, t2))` names the control plus the body's wires, +/// which is the controlled circuit's width — not two flat target slots. +#[test] +fn controlled_parn_nested_target_tuple_is_accepted() { + accepts( + "fn ctrl_layer(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + (controlled(par { H @0, H @0 })) @(0, (1, 2))\n\ + }", + ); +} + +#[test] +fn controlled_parn_flat_targets_are_accepted() { + accepts( + "fn ctrl_layer(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0, H @0 }) @(0, 1, 2)\n\ + }", + ); +} + +#[test] +fn controlled_par_repeat_explicit_and_start_targets_are_accepted() { + accepts( + "fn explicit(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0 } * 2) @(0, (1, 2))\n\ + }\n\ + fn start(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0 } * 2) @(0, 1)\n\ + }", + ); +} + +#[test] +fn controlled_parn_target_count_mismatch_is_rejected() { + let err = reject_err( + "fn ctrl_layer(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0, H @0 }) @(0, (1, 2, 3))\n\ + }", + ); + assert!( + matches!( + err, + TypeError::GateTargetArity { + expected: 3, + found: 4, + .. + } + ), + "got {err:?}" + ); +} + +#[test] +fn controlled_of_wide_circuit_rejects_a_bare_start_index() { + // The contiguous-start shorthand is only for `par`. A black-box body must + // name every wire: `@(control, (t1, t2))`. + let err = reject_err( + "fn f(c: Circuit<2, 2, 1, Clifford>): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(c) @(0, 1)\n\ + }", + ); + assert!( + matches!( + err, + TypeError::GateTargetArity { + expected: 3, + found: 2, + .. + } + ), + "got {err:?}" + ); +} + +#[test] +fn controlled_of_wide_circuit_accepts_nested_targets() { + accepts( + "fn f(c: Circuit<2, 2, 1, Clifford>): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(c) @(0, (1, 2))\n\ + }", + ); +} + +#[test] +fn controlled_par_non_literal_start_is_rejected() { + // A symbolic start would leave `implied_last` unset, so `s + 1` is never + // compared to the register, and both arms would elaborate onto `s`. + let err = reject_err( + "fn f(s: Int): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0 } * 2) @(0, s)\n\ + }", + ); + assert!( + matches!(err, TypeError::NonLiteralControlledStart { .. }), + "got {err:?}" + ); +} + +#[test] +fn controlled_parn_non_literal_start_is_rejected() { + let err = reject_err( + "fn f(s: Int): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0, H @0 }) @(0, s)\n\ + }", + ); + assert!( + matches!(err, TypeError::NonLiteralControlledStart { .. }), + "got {err:?}" + ); +} + +#[test] +fn controlled_par_start_past_register_is_out_of_bounds() { + // `@(0, 2)` on a width-3 register implies body wires 2 and 3. Wire 3 is + // past the register even though the written start index is in range. + let err = reject_err( + "fn f(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { H @0 } * 2) @(0, 2)\n\ + }", + ); + assert!( + matches!( + err, + TypeError::IndexOutOfBounds { + index: 3, + width: 3, + .. + } + ), + "got {err:?}" + ); +} + +#[test] +fn controlled_par_multiqubit_arm_typechecks() { + // Width matches (control + the CNOT's two wires), so the checker accepts + // it. Elaboration still rejects the multi-qubit arm; see lower.rs. + accepts( + "fn f(): Circuit<3, 3, 2, Clifford> = circuit {\n\ + controlled(par { CNOT @(0, 1) }) @(0, (1, 2))\n\ + }", + ); +} + +#[test] +fn controlled_h_still_rejects_a_single_target() { + let err = reject_err("fn f(): Circuit<2, 2, 2, Clifford> = circuit { controlled(H) @0 }"); + assert!( + matches!( + err, + TypeError::GateTargetArity { + expected: 2, + found: 1, + .. + } + ), + "got {err:?}" + ); +} + #[test] fn repeat_multiplies_depth_by_the_count() { // Acceptance criterion: `repeat(k, c)` with `k: Int`, `c: Circuit` is `k*d`. diff --git a/frontend/tests/lower.rs b/frontend/tests/lower.rs index a441f4a..5caa158 100644 --- a/frontend/tests/lower.rs +++ b/frontend/tests/lower.rs @@ -87,6 +87,154 @@ fn bell_state_fixture_passes_linearity_verifier() { ); } +/// CNOT operand pairs in a lowered module, as qubit indices. +/// +/// Each `quantum.circ.gate` is wire-preserving, so an SSA value stays on the +/// block argument it was derived from. A second arm dropped, or both arms +/// left on the same wire, changes this list. +fn cnot_qubit_pairs(mlir: &str) -> Vec<(usize, usize)> { + let mut qubits: std::collections::HashMap = std::collections::HashMap::new(); + let mut pairs = Vec::new(); + for line in mlir.lines() { + let line = line.trim(); + if let Some(args) = line.strip_prefix("^bb0(") { + qubits.clear(); + let mut index = 0usize; + for part in args.split(',') { + let name = part + .split(':') + .next() + .unwrap_or("") + .trim() + .trim_end_matches(')'); + if let Some(rest) = name.strip_prefix("%arg") { + let arg_index: usize = rest + .parse() + .unwrap_or_else(|_| panic!("bad block arg {name} in {line}")); + assert_eq!(arg_index, index, "block args are not dense: {line}"); + qubits.insert(name.to_string(), index); + index += 1; + } + } + continue; + } + if !line.contains("\"quantum.circ.gate\"") { + continue; + } + let (result, rest) = line + .split_once('=') + .unwrap_or_else(|| panic!("gate has no result: {line}")); + let open = rest + .find('(') + .unwrap_or_else(|| panic!("gate has no operands: {line}")); + let close = rest + .find(')') + .unwrap_or_else(|| panic!("gate operands are unclosed: {line}")); + let operands: Vec<&str> = rest[open + 1..close] + .split(',') + .map(str::trim) + .filter(|operand| !operand.is_empty()) + .collect(); + let gate_name = rest + .split("gate_name = \"") + .nth(1) + .and_then(|tail| tail.split('"').next()) + .unwrap_or_else(|| panic!("gate has no name: {line}")); + let operand_qubits: Vec = operands + .iter() + .map(|operand| { + *qubits + .get(*operand) + .unwrap_or_else(|| panic!("unknown SSA {operand} in {line}\n{mlir}")) + }) + .collect(); + if gate_name == "CNOT" { + assert_eq!(operand_qubits.len(), 2, "CNOT arity in {line}"); + pairs.push((operand_qubits[0], operand_qubits[1])); + } + let result_names = expand_gate_results(result.trim()); + assert_eq!( + result_names.len(), + operand_qubits.len(), + "result/operand arity in {line}" + ); + for (name, qubit) in result_names.into_iter().zip(operand_qubits) { + qubits.insert(name, qubit); + } + } + pairs +} + +/// `%0` is one result; `%1:2` is `%1#0` and `%1#1`. +fn expand_gate_results(result: &str) -> Vec { + let Some((name, count)) = result.split_once(':') else { + return vec![result.to_string()]; + }; + let count: usize = count + .parse() + .unwrap_or_else(|_| panic!("bad result count in {result}")); + (0..count).map(|i| format!("{name}#{i}")).collect() +} + +#[test] +fn controlled_par_nested_targets_lower() { + // Surface spellings from the typechecker contract. Each controlled-H + // contributes one CNOT; the pairs are (control, arm target). + let programs = [ + ( + "nested", + "fn ctrl_layer(): Circuit<3, 3, 2, Clifford> = circuit {\n \ + (controlled(par { H @0, H @0 })) @(0, (1, 2))\n}", + ), + ( + "flat", + "fn ctrl_layer(): Circuit<3, 3, 2, Clifford> = circuit {\n \ + controlled(par { H @0, H @0 }) @(0, 1, 2)\n}", + ), + ( + "start", + "fn ctrl_layer(): Circuit<3, 3, 2, Clifford> = circuit {\n \ + controlled(par { H @0 } * 2) @(0, 1)\n}", + ), + ]; + for (label, src) in programs { + let text = lower_text(src); + assert!( + text.contains(r#"sym_name = "ctrl_layer""#), + "{label} missing ctrl_layer: {text}" + ); + assert!( + text.contains("in_qubits = 3") && text.contains("out_qubits = 3"), + "{label} expected a 3-qubit controlled par: {text}" + ); + assert_eq!( + cnot_qubit_pairs(&text), + vec![(0, 1), (0, 2)], + "{label} CNOT pairs drifted: {text}" + ); + } +} + +#[test] +fn controlled_par_multiqubit_arm_does_not_lower() { + let src = "\ +fn f(): Circuit<3, 3, 2, Clifford> = circuit { + controlled(par { CNOT @(0, 1) }) @(0, (1, 2)) +} +"; + let context = Context::new(); + let err = lower_program(&context, src).expect_err("multi-qubit arm should not lower"); + let text = err + .iter() + .map(|diag| diag.message.as_str()) + .collect::>() + .join("\n"); + assert!( + text.contains("multi-qubit"), + "expected a multi-qubit elaboration failure, got {text}" + ); +} + #[test] fn controlled_h_and_compose_lower() { // Issue #182: controlled(H) and controlled(H |> T) must elaborate under a diff --git a/website/src/content/docs/reference/support-matrix.md b/website/src/content/docs/reference/support-matrix.md index a3987e0..20b8b89 100644 --- a/website/src/content/docs/reference/support-matrix.md +++ b/website/src/content/docs/reference/support-matrix.md @@ -62,7 +62,7 @@ issue's original framing. | `adjoint(c)` | ✓ | ✓ | ✓ | ✓ | — | `qc::adjoint` of zero-arg callees | | `repeat(k, c)` (concrete & symbolic count) | ✓ | ✓ | ✓ | ✓ | — | Grover/QFT fixtures | | `controlled(named_gate)` / `controlled(Rz(θ))` | ✓ | ✓ | ✓ | ✓ | — | Distributed via `decompose_controlled` | -| `controlled(par { … })` / `controlled(par { c } * k)` | ✓ | ✗ | ✗ | ✗ | — | Typechecker rejects multi-target tuple; see [#369](#known-limitations) | +| `controlled(par { … })` / `controlled(par { c } * k)` | ✓ | ◐ | ◐ | ◐ | — | Width-1 arms with a literal start lower to MLIR only; no lit or neutral-atom fixture. Multi-qubit arms typecheck, then fail elaboration. See [#369](#known-limitations) | | `controlled(user_parametric_circuit)` | ✓ | ✓ | ✗ | ✗ | — | Elaboration not implemented; see [#374](#known-limitations) | | Parametric circuits (`Nat` params, `for`, `match`) | ✓ | ✓ | ✓ | ✓ | — | Specialized at call sites | @@ -454,14 +454,16 @@ the only documentation — the row above states the current behavior. pipeline (#397); until that lands, treat the prep gates as the stable surface and the exact-prep scheduling as in progress. -- **[#369](https://github.com/arniber21/quon/issues/369) — `controlled(par { … })` target-count mismatch.** - `controlled(c)` is typed `Circuit` (one control wire), so a - `par` body under control expects `k+1` targets, but the surface - `@(control, target)` syntax cannot spell the nested target tuple. The - typechecker rejects it with `GateTargetArity`; the elaborator's - `decompose_controlled` *can* distribute control over `Par`/`ParN`, so this - is a surface-syntax/typechecker design decision, not a lowering gap. Use - `controlled(named_gate)` / `controlled(Rz(θ))` until resolved. +- **[#369](https://github.com/arniber21/quon/issues/369) — `controlled(par { … })` past width-1 arms.** + Width-1 arms accept `@(ctrl, (t₁, …))`, the flat `@(ctrl, t₁, …)`, and a + contiguous `@(ctrl, start)` when `start` is a non-negative integer literal. + Those lower to `quantum.circ` (`frontend/tests/lower.rs`). There is no + `test/lit` OpenQASM fixture and no neutral-atom fixture, so QASM and NA stay + partial. A multi-qubit arm such as + `controlled(par { CNOT @(0, 1) }) @(0, (1, 2))` typechecks and then fails + elaboration (`controlled() of a multi-qubit gate`). A non-literal start is + rejected: implied wires would not be bounds-checked, and elaboration only + shifts integer literals. - **[#372](https://github.com/arniber21/quon/issues/372) — `//` comment diagnostic.** `//` is not a Quon comment; the lexer rejects it with a generic parse error