Skip to main content

prism_q/sim/
gradient.rs

1//! Gradients of expectation values, by the adjoint method and by parameter
2//! shift.
3//!
4//! Both compute `⟨H⟩ = ⟨0|U†HU|0⟩` and `d⟨H⟩/dθ` for a Hermitian
5//! `H = Σ c_k P_k`. The adjoint method ([`run_expectation_gradient`]) is exact
6//! in one pair of statevectors and costs one circuit evaluation regardless of
7//! the parameter count, but runs only on the statevector backend. Parameter
8//! shift ([`run_expectation_gradient_shift`]) costs two evaluations per
9//! trainable gate and reaches any backend with a native observable path,
10//! including widths past the statevector cap.
11//!
12//! The differentiated circuit must be unitary (no measurement, reset, or
13//! conditional) on both paths. Differentiable gates are `Rx`, `Ry`, `Rz`,
14//! `Rzz`, `P`, and `PauliRot` for both: those are the `Gate` variants carrying
15//! a rotation angle, so the shift rule reaches no gate the adjoint rejects.
16
17use std::borrow::Cow;
18
19use num_complex::Complex64;
20
21use crate::backend::statevector::StatevectorBackend;
22use crate::backend::{Backend, max_statevector_qubits, reserve_dense_output};
23use crate::circuit::parameter::{ParamLink, Parameters, angle_mut, angle_of};
24use crate::circuit::{Circuit, Instruction, PreparedCircuit, SmallVec, smallvec};
25use crate::error::{PrismError, Result};
26use crate::gates::{Gate, GeneratorKind, is_diagonal_2x2, pauli_rot_masks};
27
28use super::noise::NoiseModel;
29use super::unified_pauli::PauliTerm;
30use super::{BackendKind, i_pow, pauli_masks, pauli_sandwiches_from_masks};
31
32/// Expectation value and its gradient with respect to each parameter slot.
33#[derive(Debug, Clone, PartialEq)]
34pub struct ExpectationGradient {
35    /// `⟨H⟩` at the circuit's current parameter values.
36    pub value: f64,
37    /// `d⟨H⟩/dθ`, one entry per parameter slot.
38    pub gradient: Vec<f64>,
39}
40
41/// Compute `⟨H⟩` and its exact gradient with respect to the trainable
42/// parameters using the adjoint method on the statevector backend.
43///
44/// `hamiltonian` is a weighted Pauli sum `Σ c_k P_k` with real coefficients;
45/// each `P_k` is a joint Pauli string (identity factors omitted). `params`
46/// declares which gate instructions are trainable and how they map to the
47/// gradient vector. The returned gradient has length `params.num_slots()`.
48///
49/// # Examples
50///
51/// ```
52/// use prism_q::{Circuit, Gate, Parameters, PauliTerm, run_expectation_gradient};
53///
54/// let theta = 0.5_f64;
55/// let mut circuit = Circuit::new(2, 0);
56/// circuit.add_gate(Gate::Rx(theta), &[0]);
57///
58/// let hamiltonian = vec![(1.0, vec![PauliTerm::z(0)])];
59/// let params = Parameters::all_rotations(&circuit);
60/// let g = run_expectation_gradient(&circuit, &hamiltonian, &params, 42)?;
61/// // <Z0> = cos(theta), d<Z0>/dtheta = -sin(theta).
62/// assert!((g.value - theta.cos()).abs() < 1e-12);
63/// assert!((g.gradient[0] + theta.sin()).abs() < 1e-9);
64/// # Ok::<(), prism_q::PrismError>(())
65/// ```
66pub fn run_expectation_gradient(
67    circuit: &Circuit,
68    hamiltonian: &[(f64, Vec<PauliTerm>)],
69    params: &Parameters,
70    seed: u64,
71) -> Result<ExpectationGradient> {
72    if super::has_nonunitary_or_classical_ops(circuit) {
73        return Err(PrismError::IncompatibleBackend {
74            backend: "Statevector".into(),
75            reason: "adjoint gradients require a unitary circuit without measurements, resets, or conditionals".into(),
76        });
77    }
78
79    if circuit.instructions.iter().any(|inst| {
80        matches!(
81            inst,
82            Instruction::Gate {
83                gate: Gate::QftBlock { .. },
84                ..
85            }
86        )
87    }) {
88        return Err(PrismError::IncompatibleBackend {
89            backend: "Statevector".into(),
90            reason: "adjoint gradients do not support QftBlock; expand it to primitive gates first"
91                .into(),
92        });
93    }
94
95    params.validate(circuit)?;
96
97    if circuit.num_qubits > max_statevector_qubits() {
98        return Err(PrismError::ResourceLimit(Box::new(
99            crate::error::ResourceLimit {
100                backend: "Statevector".into(),
101                operation: "adjoint gradients, which hold two statevectors,".to_string(),
102                resource: crate::error::ResourceKind::Qubits,
103                required: circuit.num_qubits as u128,
104                limit: max_statevector_qubits() as u128,
105                env_var: Some("PRISM_MAX_SV_QUBITS"),
106            },
107        )));
108    }
109
110    // Validate and reduce observables before the 2^n simulation.
111    let mut masked = Vec::with_capacity(hamiltonian.len());
112    for (coeff, terms) in hamiltonian {
113        let (xmask, zmask, num_y) = pauli_masks(terms, circuit.num_qubits)?;
114        masked.push((*coeff, xmask, zmask, num_y));
115    }
116
117    // A gate outside the Hamiltonian's inverse light cone conjugates the
118    // back-propagated observable trivially, so ⟨H⟩ and every gradient entry
119    // are unchanged when it is dropped.
120    let in_cone = observable_light_cone(circuit, hamiltonian);
121    let kept: Vec<usize> = (0..circuit.instructions.len())
122        .filter(|&i| in_cone[i])
123        .collect();
124
125    // The forward pass has to land |φ⟩ = U|0...0⟩ and nothing else, so it runs
126    // the kept gates through the ordinary fusion pipeline. The sweep below
127    // rebuilds every intermediate state by inverting `circuit.instructions` one
128    // entry at a time, so its 1:1 gate-to-generator view of the trainable gates
129    // survives whatever shape the fused stream takes.
130    let mut phi = StatevectorBackend::new(seed);
131    phi.init(circuit.num_qubits, circuit.num_classical_bits)?;
132    let forward = kept_subcircuit(circuit, &kept);
133    let expanded = super::expand_for_backend(&phi, &forward);
134    let fused = super::fuse_for_backend(&phi, &expanded);
135    phi.apply_instructions(&fused.instructions)?;
136
137    let (value, lambda_state) = build_lambda_and_value(phi.state_vector(), &masked)?;
138
139    let mut gradient = vec![0.0; params.num_slots()];
140
141    // In-cone links sorted by instruction index. An out-of-cone trainable gate
142    // has a provably zero gradient, so its links carry nothing to accumulate.
143    let mut links: Vec<_> = params
144        .links()
145        .iter()
146        .filter(|l| in_cone[l.instruction])
147        .copied()
148        .collect();
149    links.sort_by_key(|l| l.instruction);
150
151    // The sweep stops at the earliest in-cone trainable gate: nothing before
152    // it contributes, so a non-trainable prefix costs no inverse applications.
153    // If no trainable gate reaches the observable, the gradient is zero
154    // everywhere.
155    let Some(earliest) = links.first().map(|l| l.instruction) else {
156        return Ok(ExpectationGradient { value, gradient });
157    };
158
159    let mut lambda = StatevectorBackend::new(seed);
160    lambda.init_from_state(lambda_state, circuit.num_classical_bits)?;
161
162    // The sweep is the in-cone tail from the earliest trainable gate. Sweep
163    // position `s` owns `links[first_link[s]..first_link[s + 1]]`.
164    let sweep = &kept[kept.partition_point(|&i| i < earliest)..];
165    let mut first_link = Vec::with_capacity(sweep.len() + 1);
166    let mut cursor = 0;
167    for &i in sweep {
168        first_link.push(cursor);
169        while cursor < links.len() && links[cursor].instruction == i {
170            cursor += 1;
171        }
172    }
173    first_link.push(cursor);
174
175    let trainable: Vec<bool> = first_link.windows(2).map(|w| w[0] < w[1]).collect();
176    let footprints: Vec<Footprint> = sweep
177        .iter()
178        .map(|&i| {
179            let Instruction::Gate { gate, targets } = &circuit.instructions[i] else {
180                unreachable!("the light cone keeps gate instructions only")
181            };
182            Footprint::of(gate, targets)
183        })
184        .collect();
185    let plan = plan_sweep(&footprints, &trainable, circuit.num_qubits);
186
187    let mut masks: Vec<(usize, usize, u32)> = Vec::new();
188    let (mut evaluated, mut inverted) = (0, 0);
189    for step in 0..=plan.last_step {
190        let evaluate = evaluated..plan.evaluate.partition_point(|&(t, _)| t <= step);
191        evaluated = evaluate.end;
192        if !evaluate.is_empty() {
193            masks.clear();
194            masks.extend(plan.evaluate[evaluate.clone()].iter().map(|&(_, s)| {
195                let f = &footprints[s];
196                (f.xmask, f.zmask, f.num_y)
197            }));
198            let values =
199                pauli_sandwiches_from_masks(lambda.state_vector(), phi.state_vector(), &masks);
200            for (&(_, s), value) in plan.evaluate[evaluate].iter().zip(&values) {
201                for link in &links[first_link[s]..first_link[s + 1]] {
202                    gradient[link.slot] += value.im;
203                }
204            }
205        }
206
207        let invert = inverted..plan.invert.partition_point(|&(t, _)| t <= step);
208        inverted = invert.end;
209        if !invert.is_empty() {
210            let stretch = circuit.with_instructions(
211                plan.invert[invert]
212                    .iter()
213                    .map(|&(_, s)| inverse_instruction(&circuit.instructions[sweep[s]]))
214                    .collect(),
215            );
216            let expanded = super::expand_for_backend(&phi, &stretch);
217            let fused = super::fuse_for_backend(&phi, &expanded);
218            phi.apply_instructions(&fused.instructions)?;
219            lambda.apply_instructions(&fused.instructions)?;
220        }
221    }
222
223    Ok(ExpectationGradient { value, gradient })
224}
225
226/// How a gate acts on one qubit: within the algebra of `I` and one Pauli, or
227/// anything. Two gates commute when their actions agree on every shared qubit
228/// and neither is `Any`.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230enum LocalAction {
231    Z,
232    X,
233    Y,
234    Any,
235}
236
237impl LocalAction {
238    fn from_bits(x: bool, z: bool) -> Option<Self> {
239        match (x, z) {
240            (false, false) => None,
241            (true, false) => Some(Self::X),
242            (false, true) => Some(Self::Z),
243            (true, true) => Some(Self::Y),
244        }
245    }
246
247    fn bit(self) -> u8 {
248        match self {
249            Self::Z => 1,
250            Self::X => 2,
251            Self::Y => 4,
252            Self::Any => 7,
253        }
254    }
255}
256
257/// What the sweep planner knows about how one gate commutes.
258///
259/// `pauli` marks a gate in the span of `I` and one Pauli string with masks
260/// `(xmask, zmask, num_y)`, the generator for a rotation. Two such gates commute
261/// exactly when their strings do, which the per-qubit `support` test misses for
262/// strings that anticommute on an even number of qubits; every other pair falls
263/// back to it.
264struct Footprint {
265    pauli: bool,
266    xmask: usize,
267    zmask: usize,
268    num_y: u32,
269    support: SmallVec<[(usize, LocalAction); 4]>,
270}
271
272impl Footprint {
273    fn pauli((xmask, zmask, num_y): (usize, usize, u32), targets: &[usize]) -> Self {
274        let support = targets
275            .iter()
276            .filter_map(|&q| {
277                LocalAction::from_bits(xmask >> q & 1 == 1, zmask >> q & 1 == 1).map(|a| (q, a))
278            })
279            .collect();
280        Self {
281            pauli: true,
282            xmask,
283            zmask,
284            num_y,
285            support,
286        }
287    }
288
289    fn of(gate: &Gate, targets: &[usize]) -> Self {
290        use LocalAction::{Any, X, Z};
291        if let Some(kind) = gate.pauli_generator() {
292            return Self::pauli(generator_masks(kind, targets), targets);
293        }
294        let bit = || 1usize << targets[0];
295        let diagonal_or_any = |diagonal: bool| if diagonal { Z } else { Any };
296        let support: SmallVec<[(usize, LocalAction); 4]> = match gate {
297            Gate::Id => SmallVec::new(),
298            Gate::X | Gate::SX | Gate::SXdg => return Self::pauli((bit(), 0, 0), targets),
299            Gate::Y => return Self::pauli((bit(), bit(), 1), targets),
300            Gate::Z | Gate::S | Gate::Sdg | Gate::T | Gate::Tdg => {
301                return Self::pauli((0, bit(), 0), targets);
302            }
303            Gate::Cx => smallvec![(targets[0], Z), (targets[1], X)],
304            Gate::Cu(mat) => smallvec![
305                (targets[0], Z),
306                (targets[1], diagonal_or_any(is_diagonal_2x2(mat)))
307            ],
308            Gate::Mcu(data) => {
309                let (&target, controls) = targets.split_last().expect("Mcu has a target");
310                controls
311                    .iter()
312                    .map(|&q| (q, Z))
313                    .chain([(target, diagonal_or_any(is_diagonal_2x2(&data.mat)))])
314                    .collect()
315            }
316            Gate::Fused(mat) => smallvec![(targets[0], diagonal_or_any(is_diagonal_2x2(mat)))],
317            Gate::BatchPhase(data) => targets
318                .iter()
319                .copied()
320                .chain(data.phases.iter().map(|&(q, _)| q))
321                .map(|q| (q, Z))
322                .collect(),
323            Gate::Cz | Gate::BatchRzz(_) | Gate::DiagonalBatch(_) => {
324                targets.iter().map(|&q| (q, Z)).collect()
325            }
326            Gate::MultiFused(data) => {
327                let action = diagonal_or_any(data.all_diagonal);
328                targets.iter().map(|&q| (q, action)).collect()
329            }
330            _ => targets.iter().map(|&q| (q, Any)).collect(),
331        };
332        Self {
333            pauli: false,
334            xmask: 0,
335            zmask: 0,
336            num_y: 0,
337            support,
338        }
339    }
340
341    fn commutes_with(&self, other: &Footprint) -> bool {
342        if self.pauli && other.pauli {
343            let anticommuting =
344                (self.xmask & other.zmask).count_ones() + (self.zmask & other.xmask).count_ones();
345            return anticommuting.is_multiple_of(2);
346        }
347        self.support.iter().all(|&(q, a)| {
348            other
349                .support
350                .iter()
351                .all(|&(p, b)| p != q || (a == b && a != LocalAction::Any))
352        })
353    }
354
355    /// Whether a conflicting action on any one shared qubit already stops this
356    /// gate commuting with another, as it does for all but a Pauli string over
357    /// several qubits.
358    fn decides_locally(&self) -> bool {
359        !self.pauli || self.support.len() == 1
360    }
361
362    fn action_on(&self, qubit: usize) -> LocalAction {
363        self.support
364            .iter()
365            .find_map(|&(q, a)| (q == qubit).then_some(a))
366            .expect("a gate is listed only under qubits it acts on")
367    }
368}
369
370/// Pauli masks `(xmask, zmask, num_y)` of a rotation's generator.
371fn generator_masks(kind: GeneratorKind<'_>, targets: &[usize]) -> (usize, usize, u32) {
372    match kind {
373        GeneratorKind::RotX => (1usize << targets[0], 0, 0),
374        GeneratorKind::RotY => {
375            let bit = 1usize << targets[0];
376            (bit, bit, 1)
377        }
378        GeneratorKind::RotZ => (0, 1usize << targets[0], 0),
379        GeneratorKind::RotZz => (0, (1usize << targets[0]) | (1usize << targets[1]), 0),
380        // The projector generator differs from Z by the identity, whose
381        // sandwich `⟨λ|φ⟩ = ⟨φ|H|φ⟩` is real and contributes nothing to the
382        // imaginary part, so `P(θ) = e^{iθ/2} Rz(θ)` differentiates as `Rz`.
383        GeneratorKind::Phase => (0, 1usize << targets[0], 0),
384        GeneratorKind::RotPauli(axes) => pauli_rot_masks(targets, axes),
385    }
386}
387
388fn inverse_instruction(instruction: &Instruction) -> Instruction {
389    let Instruction::Gate { gate, targets } = instruction else {
390        unreachable!("the light cone keeps gate instructions only")
391    };
392    Instruction::Gate {
393        gate: gate.inverse(),
394        targets: targets.clone(),
395    }
396}
397
398/// Gates the planner checks above a gate on one qubit before it counts every
399/// gate left there as blocking. The bound keeps planning near linear through a
400/// long commuting stretch; past it the plan stays exact but may take more steps.
401const SWEEP_SCAN_LIMIT: usize = 64;
402
403/// Backward-sweep schedule, as `(step, sweep position)` pairs sorted by step.
404///
405/// Step `t` reads the sandwiches of `evaluate`'s entries at `t` from one state
406/// pair, then applies the inverses of `invert`'s entries at `t`, highest
407/// position first.
408struct SweepPlan {
409    evaluate: Vec<(usize, usize)>,
410    invert: Vec<(usize, usize)>,
411    last_step: usize,
412}
413
414/// Schedule the backward sweep so every trainable gate is evaluated at the
415/// earliest step its sandwich is exact.
416///
417/// The states always sit after the gates not yet inverted, applied in order. A
418/// gate may be inverted once every gate above it that it does not commute with
419/// has been, since it then commutes past the rest to the end of the circuit. A
420/// trainable gate's sandwich at that frontier equals the one at its own place
421/// once every gate left above it commutes with its generator. A top-down pass
422/// gives each gate its earliest step, and a bottom-up pass keeps only the gates
423/// some trainable gate below transitively waits on, so the earliest trainable
424/// gate is never inverted.
425fn plan_sweep(footprints: &[Footprint], trainable: &[bool], num_qubits: usize) -> SweepPlan {
426    let len = footprints.len();
427    let mut on_qubit: Vec<Vec<usize>> = vec![Vec::new(); num_qubits];
428    let rows: Vec<SmallVec<[usize; 4]>> = footprints
429        .iter()
430        .enumerate()
431        .map(|(s, f)| {
432            f.support
433                .iter()
434                .map(|&(q, _)| {
435                    on_qubit[q].push(s);
436                    on_qubit[q].len() - 1
437                })
438                .collect()
439        })
440        .collect();
441
442    // `blockers[blocker_start[s]..blocker_start[s + 1]]` holds gates above `s`
443    // that it does not commute with. A scan on a qubit stops once its blockers
444    // there take two different actions, or one `Any`, from gates that decide
445    // locally: every later gate on the qubit then fails to commute with one of
446    // them and waits on it already. A scan that hits the limit instead leaves a
447    // `(s, qubit, row)` spill standing for every gate from `row` up.
448    let mut blocker_start = Vec::with_capacity(len + 1);
449    let mut blockers: Vec<usize> = Vec::new();
450    let mut spills: Vec<(usize, usize, usize)> = Vec::new();
451    blocker_start.push(0);
452    for (s, f) in footprints.iter().enumerate() {
453        for (&(q, _), &row) in f.support.iter().zip(&rows[s]) {
454            let list = &on_qubit[q];
455            let limit = list.len().min(row + 1 + SWEEP_SCAN_LIMIT);
456            let mut covered = 0u8;
457            let mut next = row + 1;
458            while next < limit && covered.count_ones() < 2 {
459                let above = &footprints[list[next]];
460                if !f.commutes_with(above) {
461                    blockers.push(list[next]);
462                    if above.decides_locally() {
463                        covered |= above.action_on(q).bit();
464                    }
465                }
466                next += 1;
467            }
468            if covered.count_ones() < 2 && next < list.len() {
469                spills.push((s, q, next));
470            }
471        }
472        blocker_start.push(blockers.len());
473    }
474
475    // A trainable gate is evaluated one step after the last of its blockers is
476    // inverted and inverted in the step it is evaluated; any other gate is
477    // inverted in the step its last blocker is.
478    let mut step = vec![0usize; len];
479    let mut latest_from: Vec<Vec<usize>> = on_qubit.iter().map(|l| vec![0; l.len()]).collect();
480    let mut spill = spills.len();
481    for s in (0..len).rev() {
482        let mut latest = blockers[blocker_start[s]..blocker_start[s + 1]]
483            .iter()
484            .map(|&i| step[i])
485            .max();
486        while spill > 0 && spills[spill - 1].0 == s {
487            spill -= 1;
488            let (_, q, row) = spills[spill];
489            latest = latest.max(Some(latest_from[q][row]));
490        }
491        step[s] = match latest {
492            Some(t) if trainable[s] => t + 1,
493            Some(t) => t,
494            None => 0,
495        };
496        for (&(q, _), &row) in footprints[s].support.iter().zip(&rows[s]) {
497            let above = latest_from[q].get(row + 1).copied().unwrap_or(0);
498            latest_from[q][row] = step[s].max(above);
499        }
500    }
501
502    let mut needed = vec![false; len];
503    let mut needed_from = vec![usize::MAX; num_qubits];
504    let mut spill = 0;
505    for s in 0..len {
506        needed[s] = needed[s]
507            || footprints[s]
508                .support
509                .iter()
510                .zip(&rows[s])
511                .any(|(&(q, _), &row)| row >= needed_from[q]);
512        let waits = needed[s] || trainable[s];
513        if waits {
514            for &i in &blockers[blocker_start[s]..blocker_start[s + 1]] {
515                needed[i] = true;
516            }
517        }
518        while spill < spills.len() && spills[spill].0 == s {
519            let (_, q, row) = spills[spill];
520            if waits {
521                needed_from[q] = needed_from[q].min(row);
522            }
523            spill += 1;
524        }
525    }
526
527    let last_step = (0..len)
528        .filter(|&s| trainable[s])
529        .map(|s| step[s])
530        .max()
531        .unwrap_or(0);
532    let mut evaluate: Vec<(usize, usize)> = (0..len)
533        .filter(|&s| trainable[s])
534        .map(|s| (step[s], s))
535        .collect();
536    evaluate.sort_unstable();
537    let mut invert: Vec<(usize, usize)> = (0..len)
538        .filter(|&s| needed[s] && step[s] < last_step)
539        .map(|s| (step[s], s))
540        .collect();
541    invert.sort_unstable_by_key(|&(t, s)| (t, std::cmp::Reverse(s)));
542    SweepPlan {
543        evaluate,
544        invert,
545        last_step,
546    }
547}
548
549/// The gates `kept` indexes, as a circuit of the original width so the fusion
550/// floors read the same qubit count. Borrowed when the cone keeps every
551/// instruction.
552fn kept_subcircuit<'a>(circuit: &'a Circuit, kept: &[usize]) -> Cow<'a, Circuit> {
553    if kept.len() == circuit.instructions.len() {
554        return Cow::Borrowed(circuit);
555    }
556    let instructions = kept
557        .iter()
558        .map(|&i| circuit.instructions[i].clone())
559        .collect();
560    Cow::Owned(circuit.with_instructions(instructions))
561}
562
563/// Per-instruction flag: true if the gate lies in the Hamiltonian's inverse
564/// light cone (its support is connected to some observable term through the
565/// gates that follow it).
566fn observable_light_cone(circuit: &Circuit, hamiltonian: &[(f64, Vec<PauliTerm>)]) -> Vec<bool> {
567    let union: Vec<PauliTerm> = hamiltonian
568        .iter()
569        .flat_map(|(_, terms)| terms.iter().copied())
570        .collect();
571    super::unified_pauli::inverse_light_cone(circuit, &union)
572}
573
574/// Hamiltonian terms sharing one X mask. The gather reads `phi[i ^ xmask]`
575/// once per group; each `(Zmask, factor)` pair then contributes its own sign to
576/// that one amplitude.
577struct TermGroup {
578    xmask: usize,
579    terms: Vec<(usize, Complex64)>,
580}
581
582/// Group masked terms by X mask, ascending, so the diagonal terms (X mask 0)
583/// lead and the gather's first read is the sequential stream.
584fn group_terms_by_xmask(masked: &[(f64, usize, usize, u32)]) -> Vec<TermGroup> {
585    let mut flat: Vec<(usize, usize, Complex64)> = masked
586        .iter()
587        .map(|&(coeff, xmask, zmask, num_y)| {
588            (xmask, zmask, Complex64::new(coeff, 0.0) * i_pow(num_y))
589        })
590        .collect();
591    flat.sort_by_key(|&(xmask, _, _)| xmask);
592
593    let mut groups: Vec<TermGroup> = Vec::with_capacity(flat.len());
594    for (xmask, zmask, factor) in flat {
595        match groups.last_mut() {
596            Some(group) if group.xmask == xmask => group.terms.push((zmask, factor)),
597            _ => groups.push(TermGroup {
598                xmask,
599                terms: vec![(zmask, factor)],
600            }),
601        }
602    }
603    groups
604}
605
606/// Gather `|λ⟩ = Σ c_k P_k|φ⟩` over `out`, the slice of `λ` starting at `base`,
607/// and return that slice's share of `Re⟨φ|λ⟩`.
608///
609/// Each output element is written once, from
610/// `Σ_k factor_k · (-1)^popcount((i ⊕ Xmask_k) & Zmask_k) · phi[i ⊕ Xmask_k]`, so
611/// the terms batch into one pass over the register instead of one scattering
612/// pass each.
613#[inline(always)]
614fn gather_lambda_chunk(
615    groups: &[TermGroup],
616    phi: &[Complex64],
617    base: usize,
618    out: &mut [Complex64],
619) -> f64 {
620    let mut value = 0.0;
621    for (offset, slot) in out.iter_mut().enumerate() {
622        let i = base + offset;
623        let mut acc = Complex64::new(0.0, 0.0);
624        for group in groups {
625            let j = i ^ group.xmask;
626            let mut weight = Complex64::new(0.0, 0.0);
627            for &(zmask, factor) in &group.terms {
628                let sign = if (j & zmask).count_ones() & 1 == 1 {
629                    -1.0
630                } else {
631                    1.0
632                };
633                weight += factor * sign;
634            }
635            // SAFETY: callers pass `out` as a chunk of a buffer of `phi`'s
636            // length starting at `base`, so `i < phi.len()`, and every X mask
637            // below the power-of-two `phi.len()`, so `i ^ xmask` stays in range.
638            // `build_lambda_and_value` asserts both before it fans out.
639            let amp = unsafe { *phi.get_unchecked(j) };
640            acc += weight * amp;
641        }
642        // SAFETY: same range argument with an X mask of zero.
643        let p = unsafe { *phi.get_unchecked(i) };
644        value += p.re * acc.re + p.im * acc.im;
645        *slot = acc;
646    }
647    value
648}
649
650/// Build `|λ⟩ = Σ c_k P_k|φ⟩` into a fresh buffer and return `(⟨H⟩, |λ⟩)`,
651/// where `⟨H⟩ = Re⟨φ|λ⟩`.
652///
653/// # Panics
654///
655/// If `phi`'s length is not a power of two or a term's X mask indexes past it.
656fn build_lambda_and_value(
657    phi: &[Complex64],
658    masked: &[(f64, usize, usize, u32)],
659) -> Result<(f64, Vec<Complex64>)> {
660    let dim = phi.len();
661    let mut lambda: Vec<Complex64> = Vec::new();
662    reserve_dense_output(
663        &mut lambda,
664        dim,
665        "Statevector",
666        "adjoint gradient lambda state",
667    )?;
668    lambda.resize(dim, Complex64::new(0.0, 0.0));
669
670    let groups = group_terms_by_xmask(masked);
671    assert!(
672        dim.is_power_of_two() && groups.iter().all(|g| g.xmask < dim),
673        "observable masks must index the state dimension"
674    );
675
676    #[cfg(feature = "parallel")]
677    if dim >= (1 << crate::backend::PARALLEL_THRESHOLD_QUBITS) {
678        use crate::backend::MIN_PAR_ELEMS;
679        use rayon::prelude::*;
680
681        let value = lambda
682            .par_chunks_mut(MIN_PAR_ELEMS)
683            .enumerate()
684            .map(|(chunk, out)| gather_lambda_chunk(&groups, phi, chunk * MIN_PAR_ELEMS, out))
685            .sum();
686        return Ok((value, lambda));
687    }
688
689    let value = gather_lambda_chunk(&groups, phi, 0, &mut lambda);
690    Ok((value, lambda))
691}
692
693/// Compute `⟨H⟩` and its gradient by the parameter-shift rule, routing every
694/// evaluation through automatic backend selection.
695///
696/// Unlike [`run_expectation_gradient`] this places no ceiling on the qubit
697/// count of its own: it inherits whatever the selected backend can represent,
698/// holding one backend state at a time from 17 qubits up. Below that, under the
699/// `parallel` feature, the shifted evaluations split across Rayon workers with a
700/// state each, and the gradient is bit-identical to the sequential sum. It also
701/// accepts `QftBlock`. The price is `1 + 2 * params.links().len()` circuit
702/// evaluations against the adjoint's one, so prefer the adjoint wherever it
703/// applies. Select an explicit backend with [`crate::simulate`] and
704/// `expectation_gradient_shift`.
705///
706/// # Examples
707///
708/// ```
709/// use prism_q::{Circuit, Gate, Parameters, PauliTerm, run_expectation_gradient_shift};
710///
711/// let theta = 0.5_f64;
712/// let mut circuit = Circuit::new(2, 0);
713/// circuit.add_gate(Gate::Rx(theta), &[0]);
714///
715/// let hamiltonian = vec![(1.0, vec![PauliTerm::z(0)])];
716/// let params = Parameters::all_rotations(&circuit);
717/// let g = run_expectation_gradient_shift(&circuit, &hamiltonian, &params, 42)?;
718/// assert!((g.gradient[0] + theta.sin()).abs() < 1e-9);
719/// # Ok::<(), prism_q::PrismError>(())
720/// ```
721pub fn run_expectation_gradient_shift(
722    circuit: &Circuit,
723    hamiltonian: &[(f64, Vec<PauliTerm>)],
724    params: &Parameters,
725    seed: u64,
726) -> Result<ExpectationGradient> {
727    shift_gradient(
728        &BackendKind::Auto,
729        circuit,
730        hamiltonian,
731        params,
732        None,
733        None,
734        seed,
735    )
736}
737
738/// Parameter-shift gradient on the backend `kind` selects, optionally from a
739/// start state.
740///
741/// Every differentiable gate is `exp(-iθG/2)` with `G` of eigenvalues `±1`, so
742/// `⟨H⟩` is a degree-1 trigonometric polynomial in each angle and
743/// `d⟨H⟩/dθ = (f(θ+π/2) - f(θ-π/2)) / 2` is exact. `P(θ) = diag(1, e^{iθ})` has
744/// the projector `|1⟩⟨1|` for a generator, eigenvalues `{0, 1}` rather than
745/// `{-1, +1}`, but `P(θ) = e^{iθ/2} Rz(θ)`: the θ-dependent factor is a scalar
746/// wherever the gate sits, so it cancels against its conjugate in `⟨ψ|H|ψ⟩` and
747/// the same shift applies unchanged.
748///
749/// Gates sharing a parameter slot are shifted one at a time and summed. Shifting
750/// them together is a different quantity: two `Rx(θ)` on one qubit under `⟨Z⟩`
751/// give `cos 2θ`, whose joint ±π/2 shift is zero rather than `-2 sin 2θ`.
752///
753/// Under `noise` every forward evaluation reads the exact mixture, so `kind`
754/// must be a density-matrix kind, which the caller checks. The channels do not
755/// depend on the shifted angle, so `⟨H⟩` stays a degree-1 trigonometric
756/// polynomial in it and the shift is still exact.
757pub(crate) fn shift_gradient(
758    kind: &BackendKind,
759    circuit: &Circuit,
760    hamiltonian: &[(f64, Vec<PauliTerm>)],
761    params: &Parameters,
762    noise: Option<&NoiseModel>,
763    initial_state: Option<super::StartState<'_>>,
764    seed: u64,
765) -> Result<ExpectationGradient> {
766    params.validate(circuit)?;
767    // Below the fusion floor there is no plan to replay, and the prepared copy
768    // each worker builds costs more than the route and allocation it saves.
769    if initial_state.is_none()
770        && noise.is_none()
771        && circuit.num_qubits >= crate::circuit::fusion::MIN_QUBITS_FOR_FUSION
772    {
773        return prepared_shift_gradient(kind, circuit, hamiltonian, params, seed);
774    }
775    if initial_state.is_some() || noise.is_some() {
776        super::require_unitary_circuit(kind, circuit, "expectation values require")?;
777    }
778
779    let observables: Vec<Vec<PauliTerm>> =
780        hamiltonian.iter().map(|(_, terms)| terms.clone()).collect();
781    let evaluate = |c: &Circuit| -> Result<f64> {
782        if let BackendKind::PauliPath { epsilon, max_terms } = kind {
783            let per_term =
784                super::pauli_path_expectations(c, noise, &observables, *epsilon, *max_terms)?
785                    .into_values();
786            return Ok(hamiltonian
787                .iter()
788                .zip(per_term)
789                .map(|((coeff, _), v)| coeff * v)
790                .sum());
791        }
792        let per_term = match (noise, initial_state) {
793            (Some(noise), _) => super::noise::dm_expectation_values(
794                kind,
795                c,
796                &observables,
797                Some(noise),
798                initial_state,
799                seed,
800            )?,
801            (None, Some(state)) => {
802                super::expectation_values_from_initial_state(kind, c, state, &observables, seed)?
803                    .into_values()
804            }
805            (None, None) => {
806                super::run_expectation_values_with(kind.clone(), c, &observables, seed)?
807            }
808        };
809        Ok(hamiltonian
810            .iter()
811            .zip(per_term)
812            .map(|((coeff, _), v)| coeff * v)
813            .sum())
814    };
815
816    let value = evaluate(circuit)?;
817    let mut gradient = vec![0.0; params.num_slots()];
818    if params.is_empty() {
819        return Ok(ExpectationGradient { value, gradient });
820    }
821
822    let shift = std::f64::consts::FRAC_PI_2;
823    let term = |shifted: &mut Circuit, instruction: usize| -> Result<f64> {
824        let base = *angle_mut(&mut shifted.instructions[instruction]);
825        *angle_mut(&mut shifted.instructions[instruction]) = base + shift;
826        let plus = evaluate(shifted)?;
827        *angle_mut(&mut shifted.instructions[instruction]) = base - shift;
828        let minus = evaluate(shifted)?;
829        *angle_mut(&mut shifted.instructions[instruction]) = base;
830        Ok(0.5 * (plus - minus))
831    };
832
833    // Terms are summed into their slots in link order on both paths, so the
834    // parallel gradient is bit-identical to the sequential one.
835    let links = params.links();
836    #[cfg(feature = "parallel")]
837    if links.len() > 1 && super::runs_split_across_workers(kind, circuit.num_qubits) {
838        use rayon::prelude::*;
839        let terms: Vec<Result<f64>> = links
840            .par_iter()
841            .map_init(
842                || circuit.clone(),
843                |shifted, link| term(shifted, link.instruction),
844            )
845            .collect();
846        for (link, t) in links.iter().zip(terms) {
847            gradient[link.slot] += t?;
848        }
849        return Ok(ExpectationGradient { value, gradient });
850    }
851
852    let mut shifted = circuit.clone();
853    for link in links {
854        gradient[link.slot] += term(&mut shifted, link.instruction)?;
855    }
856
857    Ok(ExpectationGradient { value, gradient })
858}
859
860/// [`shift_gradient`] with neither noise nor a start state: every evaluation binds
861/// one [`PreparedCircuit`], so the route is settled and the fusion plan captured
862/// once rather than per shifted circuit.
863fn prepared_shift_gradient(
864    kind: &BackendKind,
865    circuit: &Circuit,
866    hamiltonian: &[(f64, Vec<PauliTerm>)],
867    params: &Parameters,
868    seed: u64,
869) -> Result<ExpectationGradient> {
870    // One slot per linked instruction rather than per parameter slot, since a
871    // shift moves one gate even where its slot drives several.
872    let links = params.links();
873    let mut sites: Vec<usize> = links.iter().map(|link| link.instruction).collect();
874    sites.sort_unstable();
875    sites.dedup();
876    let base: Vec<f64> = sites
877        .iter()
878        .map(|&i| angle_of(&circuit.instructions[i]))
879        .collect();
880    let site_params = Parameters::from_links(
881        sites
882            .iter()
883            .enumerate()
884            .map(|(slot, &instruction)| ParamLink { instruction, slot })
885            .collect(),
886        sites.len(),
887    );
888
889    let observables: Vec<Vec<PauliTerm>> =
890        hamiltonian.iter().map(|(_, terms)| terms.clone()).collect();
891    let energy = |prepared: &mut PreparedCircuit, values: &[f64]| -> Result<f64> {
892        let per_term = prepared.expectation_values(values, &observables, seed)?;
893        Ok(hamiltonian
894            .iter()
895            .zip(per_term)
896            .map(|((coeff, _), v)| coeff * v)
897            .sum())
898    };
899
900    let mut prepared = PreparedCircuit::with_backend(circuit.clone(), site_params, kind.clone())?;
901    let value = energy(&mut prepared, &base)?;
902    let mut gradient = vec![0.0; params.num_slots()];
903    if params.is_empty() {
904        return Ok(ExpectationGradient { value, gradient });
905    }
906
907    // Terms are summed into their slots in link order however the links were
908    // split, so the gradient does not depend on the thread count.
909    let shift = std::f64::consts::FRAC_PI_2;
910    let terms = prepared.map_split(links, |worker, link| {
911        let site = sites
912            .binary_search(&link.instruction)
913            .expect("every link names a site");
914        let mut values = base.clone();
915        values[site] = base[site] + shift;
916        let plus = energy(worker, &values)?;
917        values[site] = base[site] - shift;
918        let minus = energy(worker, &values)?;
919        Ok(0.5 * (plus - minus))
920    })?;
921    for (link, term) in links.iter().zip(terms) {
922        gradient[link.slot] += term;
923    }
924
925    Ok(ExpectationGradient { value, gradient })
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use crate::sim::unified_pauli::PauliTerm;
932
933    fn z_obs(qubit: usize) -> Vec<(f64, Vec<PauliTerm>)> {
934        vec![(1.0, vec![PauliTerm::z(qubit)])]
935    }
936
937    #[test]
938    fn single_rx_gradient_matches_analytic() {
939        // Rx(θ)|0>, <Z> = cos θ, d/dθ = -sin θ.
940        let theta = 0.7;
941        let mut c = Circuit::new(1, 0);
942        c.add_gate(Gate::Rx(theta), &[0]);
943        let mut params = Parameters::new(1);
944        params.link(0, 0);
945
946        let g = run_expectation_gradient(&c, &z_obs(0), &params, 42).unwrap();
947        assert!((g.value - theta.cos()).abs() < 1e-12);
948        assert!((g.gradient[0] - (-theta.sin())).abs() < 1e-9);
949    }
950
951    #[test]
952    fn ry_generator_carries_num_y() {
953        // Ry(θ)|0>, <Z> = cos θ, d/dθ = -sin θ. Generator Y has num_y = 1.
954        let theta = 1.3;
955        let mut c = Circuit::new(1, 0);
956        c.add_gate(Gate::Ry(theta), &[0]);
957        let mut params = Parameters::new(1);
958        params.link(0, 0);
959
960        let g = run_expectation_gradient(&c, &z_obs(0), &params, 42).unwrap();
961        assert!((g.gradient[0] - (-theta.sin())).abs() < 1e-9);
962    }
963
964    #[test]
965    fn phase_projector_gradient() {
966        // H then P(θ): |ψ> = (|0> + e^{iθ}|1>)/√2, <X> = cos θ, d/dθ = -sin θ.
967        let theta = 0.9;
968        let mut c = Circuit::new(1, 0);
969        c.add_gate(Gate::H, &[0]);
970        c.add_gate(Gate::P(theta), &[0]);
971        let mut params = Parameters::new(1);
972        params.link(1, 0);
973
974        let obs = vec![(1.0, vec![PauliTerm::x(0)])];
975        let g = run_expectation_gradient(&c, &obs, &params, 42).unwrap();
976        assert!((g.value - theta.cos()).abs() < 1e-12);
977        assert!((g.gradient[0] - (-theta.sin())).abs() < 1e-9);
978    }
979
980    #[test]
981    fn shared_parameter_accumulates() {
982        // Two Rx gates on separate qubits sharing one slot; each contributes
983        // -sin θ to <Z0 + Z1>, so the shared gradient is -2 sin θ.
984        let theta = 0.4;
985        let mut c = Circuit::new(2, 0);
986        c.add_gate(Gate::Rx(theta), &[0]);
987        c.add_gate(Gate::Rx(theta), &[1]);
988        let mut params = Parameters::new(1);
989        params.link(0, 0);
990        params.link(1, 0);
991
992        let obs = vec![(1.0, vec![PauliTerm::z(0)]), (1.0, vec![PauliTerm::z(1)])];
993        let g = run_expectation_gradient(&c, &obs, &params, 42).unwrap();
994        assert_eq!(g.gradient.len(), 1);
995        assert!((g.gradient[0] - (-2.0 * theta.sin())).abs() < 1e-9);
996    }
997
998    fn hea_with_shared_slots(n: usize) -> (Circuit, Parameters) {
999        let circuit = crate::circuits::hardware_efficient_ansatz(n, 2, 7);
1000        let mut params = Parameters::new(3);
1001        let rotations = circuit.instructions.iter().enumerate().filter(|(_, inst)| {
1002            matches!(
1003                inst,
1004                Instruction::Gate {
1005                    gate: Gate::Ry(_) | Gate::Rz(_),
1006                    ..
1007                }
1008            )
1009        });
1010        for (k, (i, _)) in rotations.enumerate() {
1011            params.link(i, k % 3);
1012        }
1013        (circuit, params)
1014    }
1015
1016    fn slot_per_link(params: &Parameters) -> Parameters {
1017        let links = params.links();
1018        Parameters::from_links(
1019            links
1020                .iter()
1021                .enumerate()
1022                .map(|(slot, link)| ParamLink {
1023                    instruction: link.instruction,
1024                    slot,
1025                })
1026                .collect(),
1027            links.len(),
1028        )
1029    }
1030
1031    fn weighted_energy(ham: &[(f64, Vec<PauliTerm>)], per_term: Vec<f64>) -> f64 {
1032        ham.iter()
1033            .zip(per_term)
1034            .map(|((coeff, _), v)| coeff * v)
1035            .sum()
1036    }
1037
1038    // Ten qubits splits the links across workers under `parallel` and replays a
1039    // fusion plan; the reference sums the same prepared terms in link order on
1040    // one thread, so the bits must agree.
1041    #[test]
1042    fn shift_gradient_matches_a_sequential_sum_bit_for_bit() {
1043        let (circuit, params) = hea_with_shared_slots(10);
1044        let ham = vec![
1045            (0.5, vec![PauliTerm::z(0), PauliTerm::z(1)]),
1046            (1.5, vec![PauliTerm::x(3)]),
1047        ];
1048        let observables: Vec<Vec<PauliTerm>> = ham.iter().map(|(_, t)| t.clone()).collect();
1049        let links = params.links();
1050        let sites = slot_per_link(&params);
1051        let base = sites.values(&circuit).unwrap();
1052        let mut prepared = PreparedCircuit::new(circuit.clone(), sites).unwrap();
1053        assert!(prepared.reuses_fusion_plan());
1054        let mut energy = |values: &[f64]| {
1055            weighted_energy(
1056                &ham,
1057                prepared
1058                    .expectation_values(values, &observables, 42)
1059                    .unwrap(),
1060            )
1061        };
1062        let shift = std::f64::consts::FRAC_PI_2;
1063        let mut expected = vec![0.0; 3];
1064        for (k, link) in links.iter().enumerate() {
1065            let mut values = base.clone();
1066            values[k] = base[k] + shift;
1067            let plus = energy(&values);
1068            values[k] = base[k] - shift;
1069            let minus = energy(&values);
1070            expected[link.slot] += 0.5 * (plus - minus);
1071        }
1072
1073        let g = run_expectation_gradient_shift(&circuit, &ham, &params, 42).unwrap();
1074        assert_eq!(g.gradient, expected);
1075    }
1076
1077    // Angles of pi/2 on half the rotations shift to 0 and pi, which collapse a
1078    // fused block to the identity or a named gate and force the replay onto the
1079    // full pass pipeline.
1080    #[test]
1081    fn shift_gradient_matches_fresh_fusion_on_degenerate_shifts() {
1082        for n in [6, 10] {
1083            let (mut circuit, params) = hea_with_shared_slots(n);
1084            for link in params.links().iter().step_by(2) {
1085                *angle_mut(&mut circuit.instructions[link.instruction]) =
1086                    std::f64::consts::FRAC_PI_2;
1087            }
1088            let ham: Vec<(f64, Vec<PauliTerm>)> = (0..n - 1)
1089                .map(|q| (1.0, vec![PauliTerm::z(q), PauliTerm::z(q + 1)]))
1090                .chain([(0.7, vec![PauliTerm::x(2)])])
1091                .collect();
1092            let observables: Vec<Vec<PauliTerm>> = ham.iter().map(|(_, t)| t.clone()).collect();
1093            let energy = |c: &Circuit| {
1094                weighted_energy(
1095                    &ham,
1096                    super::super::run_expectation_values_with(
1097                        BackendKind::Auto,
1098                        c,
1099                        &observables,
1100                        42,
1101                    )
1102                    .unwrap(),
1103                )
1104            };
1105            let shift = std::f64::consts::FRAC_PI_2;
1106            let mut expected = vec![0.0; params.num_slots()];
1107            for link in params.links() {
1108                let mut shifted = circuit.clone();
1109                let base = *angle_mut(&mut shifted.instructions[link.instruction]);
1110                *angle_mut(&mut shifted.instructions[link.instruction]) = base + shift;
1111                let plus = energy(&shifted);
1112                *angle_mut(&mut shifted.instructions[link.instruction]) = base - shift;
1113                let minus = energy(&shifted);
1114                expected[link.slot] += 0.5 * (plus - minus);
1115            }
1116
1117            if n == 10 {
1118                let sites = slot_per_link(&params);
1119                let base = sites.values(&circuit).unwrap();
1120                let mut prepared = PreparedCircuit::new(circuit.clone(), sites).unwrap();
1121                assert!(prepared.reuses_fusion_plan());
1122                let replayed = prepared.bind_fused(&base).unwrap().instructions.len();
1123                let fell_back = (0..base.len()).any(|k| {
1124                    let mut values = base.clone();
1125                    values[k] -= shift;
1126                    prepared.bind_fused(&values).unwrap().instructions.len() != replayed
1127                });
1128                assert!(fell_back, "no shifted binding left the captured plan");
1129            }
1130
1131            let g = run_expectation_gradient_shift(&circuit, &ham, &params, 42).unwrap();
1132            assert!((g.value - energy(&circuit)).abs() < 1e-10, "{n}q value");
1133            for (slot, (got, want)) in g.gradient.iter().zip(&expected).enumerate() {
1134                assert!(
1135                    (got - want).abs() < 1e-10,
1136                    "{n}q slot {slot}: {got} vs {want}"
1137                );
1138            }
1139        }
1140    }
1141
1142    #[test]
1143    fn empty_params_returns_value_only() {
1144        let mut c = Circuit::new(1, 0);
1145        c.add_gate(Gate::Rx(0.5), &[0]);
1146        let g = run_expectation_gradient(&c, &z_obs(0), &Parameters::new(0), 42).unwrap();
1147        assert!(g.gradient.is_empty());
1148        assert!((g.value - 0.5f64.cos()).abs() < 1e-12);
1149    }
1150
1151    #[test]
1152    fn nondifferentiable_trainable_gate_is_rejected() {
1153        let mut c = Circuit::new(1, 0);
1154        c.add_gate(Gate::H, &[0]);
1155        let mut params = Parameters::new(1);
1156        params.link(0, 0);
1157        assert!(run_expectation_gradient(&c, &z_obs(0), &params, 42).is_err());
1158    }
1159
1160    #[test]
1161    fn nonunitary_circuit_is_rejected() {
1162        let mut c = Circuit::new(1, 1);
1163        c.add_gate(Gate::Rx(0.3), &[0]);
1164        c.add_measure(0, 0);
1165        assert!(run_expectation_gradient(&c, &z_obs(0), &Parameters::new(0), 42).is_err());
1166    }
1167
1168    #[test]
1169    fn single_term_hamiltonian_matches_analytic() {
1170        // Ry(theta)|0>, <X> = sin theta, d/dtheta = cos theta.
1171        let theta = 0.6;
1172        let mut c = Circuit::new(1, 0);
1173        c.add_gate(Gate::Ry(theta), &[0]);
1174        let params = Parameters::all_rotations(&c);
1175
1176        let obs = vec![(1.0, vec![PauliTerm::x(0)])];
1177        let g = run_expectation_gradient(&c, &obs, &params, 42).unwrap();
1178        assert!((g.value - theta.sin()).abs() < 1e-12);
1179        assert!((g.gradient[0] - theta.cos()).abs() < 1e-12);
1180    }
1181
1182    #[test]
1183    fn diagonal_hamiltonian_matches_analytic() {
1184        // Two independent Ry rotations under Z0, Z1 and Z0Z1: every term is
1185        // diagonal, so nothing moves an amplitude.
1186        let (a, b) = (0.4, 1.1);
1187        let mut c = Circuit::new(2, 0);
1188        c.add_gate(Gate::Ry(a), &[0]);
1189        c.add_gate(Gate::Ry(b), &[1]);
1190        let params = Parameters::all_rotations(&c);
1191
1192        let obs = vec![
1193            (1.0, vec![PauliTerm::z(0)]),
1194            (0.5, vec![PauliTerm::z(1)]),
1195            (0.25, vec![PauliTerm::z(0), PauliTerm::z(1)]),
1196        ];
1197        let g = run_expectation_gradient(&c, &obs, &params, 42).unwrap();
1198        let value = a.cos() + 0.5 * b.cos() + 0.25 * a.cos() * b.cos();
1199        assert!((g.value - value).abs() < 1e-12);
1200        assert!((g.gradient[0] - (-a.sin() - 0.25 * a.sin() * b.cos())).abs() < 1e-12);
1201        assert!((g.gradient[1] - (-0.5 * b.sin() - 0.25 * a.cos() * b.sin())).abs() < 1e-12);
1202    }
1203
1204    #[test]
1205    fn grouped_x_mask_terms_match_parameter_shift() {
1206        // X0, Y0, X0Z1 and Y0Z2 all carry the X mask of qubit 0, four terms
1207        // reading the same amplitude.
1208        let mut c = Circuit::new(3, 0);
1209        c.add_gate(Gate::Ry(0.4), &[0]);
1210        c.add_gate(Gate::Cx, &[0, 1]);
1211        c.add_gate(Gate::Rx(0.9), &[1]);
1212        c.add_gate(Gate::Cx, &[1, 2]);
1213        c.add_gate(Gate::Rz(0.3), &[2]);
1214        let params = Parameters::all_rotations(&c);
1215
1216        let obs = vec![
1217            (1.0, vec![PauliTerm::x(0)]),
1218            (-0.5, vec![PauliTerm::y(0)]),
1219            (0.75, vec![PauliTerm::x(0), PauliTerm::z(1)]),
1220            (0.25, vec![PauliTerm::y(0), PauliTerm::z(2)]),
1221        ];
1222        let adjoint = run_expectation_gradient(&c, &obs, &params, 42).unwrap();
1223        let shift = run_expectation_gradient_shift(&c, &obs, &params, 42).unwrap();
1224        assert!((adjoint.value - shift.value).abs() < 1e-12);
1225        for (slot, (&got, &want)) in adjoint.gradient.iter().zip(&shift.gradient).enumerate() {
1226            assert!((got - want).abs() < 1e-12, "slot {slot}: {got} vs {want}");
1227        }
1228    }
1229
1230    #[test]
1231    fn parallel_threshold_width_matches_parameter_shift() {
1232        // 2^15 amplitudes, the width at which the gather fans out to Rayon.
1233        let mut c = Circuit::new(15, 0);
1234        c.add_gate(Gate::Ry(0.3), &[0]);
1235        c.add_gate(Gate::Cx, &[0, 7]);
1236        c.add_gate(Gate::Rx(0.8), &[7]);
1237        c.add_gate(Gate::Cx, &[7, 14]);
1238        c.add_gate(Gate::Rz(0.5), &[14]);
1239        let params = Parameters::all_rotations(&c);
1240
1241        let obs = vec![
1242            (1.0, vec![PauliTerm::z(0)]),
1243            (0.5, vec![PauliTerm::x(7)]),
1244            (-0.25, vec![PauliTerm::y(7), PauliTerm::z(14)]),
1245            (0.75, vec![PauliTerm::x(7), PauliTerm::z(0)]),
1246        ];
1247        let adjoint = run_expectation_gradient(&c, &obs, &params, 42).unwrap();
1248        let shift = run_expectation_gradient_shift(&c, &obs, &params, 42).unwrap();
1249        assert!((adjoint.value - shift.value).abs() < 1e-12);
1250        for (slot, (&got, &want)) in adjoint.gradient.iter().zip(&shift.gradient).enumerate() {
1251            assert!((got - want).abs() < 1e-12, "slot {slot}: {got} vs {want}");
1252        }
1253    }
1254
1255    #[test]
1256    fn the_gather_matches_a_term_by_term_reference() {
1257        // Sum the terms one at a time, the shape the gather replaces, and
1258        // compare on one register. Split across two chunks so the gather's base
1259        // arithmetic is covered as well.
1260        let dim = 1usize << 6;
1261        let phi: Vec<Complex64> = (0..dim)
1262            .map(|i| Complex64::new((i as f64 * 0.37).sin(), (i as f64 * 0.11).cos()))
1263            .collect();
1264        let masked = vec![
1265            (1.0, 0usize, 0b101usize, 0u32),
1266            (-0.5, 0b010, 0b100, 1),
1267            (0.75, 0b010, 0b001, 0),
1268            (0.25, 0b110, 0b011, 2),
1269        ];
1270
1271        let mut want = vec![Complex64::new(0.0, 0.0); dim];
1272        for &(coeff, xmask, zmask, num_y) in &masked {
1273            let factor = Complex64::new(coeff, 0.0) * i_pow(num_y);
1274            for (j, &amp) in phi.iter().enumerate() {
1275                let sign = if (j & zmask).count_ones() & 1 == 1 {
1276                    -1.0
1277                } else {
1278                    1.0
1279                };
1280                want[j ^ xmask] += factor * sign * amp;
1281            }
1282        }
1283        let want_value: f64 = phi.iter().zip(&want).map(|(p, l)| (p.conj() * l).re).sum();
1284
1285        let groups = group_terms_by_xmask(&masked);
1286        let mut got = vec![Complex64::new(0.0, 0.0); dim];
1287        let (lo, hi) = got.split_at_mut(dim / 2);
1288        let got_value = gather_lambda_chunk(&groups, &phi, 0, lo)
1289            + gather_lambda_chunk(&groups, &phi, dim / 2, hi);
1290
1291        assert!((want_value - got_value).abs() < 1e-12);
1292        for (i, (w, g)) in want.iter().zip(&got).enumerate() {
1293            assert!((w - g).norm() < 1e-12, "slot {i}: {w} vs {g}");
1294        }
1295    }
1296
1297    #[test]
1298    fn pauli_rot_generators_commute_on_an_even_anticommuting_count() {
1299        use crate::sim::unified_pauli::PauliAxis;
1300        let gate = |axes: &[PauliAxis], targets: &[usize]| {
1301            Footprint::pauli(
1302                generator_masks(GeneratorKind::RotPauli(axes), targets),
1303                targets,
1304            )
1305        };
1306        let x0y1 = gate(&[PauliAxis::X, PauliAxis::Y], &[0, 1]);
1307        let y0x1 = gate(&[PauliAxis::Y, PauliAxis::X], &[0, 1]);
1308        let wide = gate(
1309            &[PauliAxis::Y, PauliAxis::X, PauliAxis::X, PauliAxis::X],
1310            &[0, 1, 2, 3],
1311        );
1312        let x1y2 = gate(&[PauliAxis::X, PauliAxis::Y], &[1, 2]);
1313
1314        assert!(x0y1.commutes_with(&y0x1));
1315        assert!(x0y1.commutes_with(&x0y1));
1316        assert!(!wide.commutes_with(&x1y2));
1317        assert!(wide.commutes_with(&y0x1));
1318    }
1319
1320    #[test]
1321    fn footprints_commute_through_control_and_target_actions() {
1322        let cx = Footprint::of(&Gate::Cx, &[0, 1]);
1323        let commutes = |gate: Gate, targets: &[usize]| {
1324            let f = Footprint::of(&gate, targets);
1325            assert_eq!(f.commutes_with(&cx), cx.commutes_with(&f));
1326            f.commutes_with(&cx)
1327        };
1328        assert!(commutes(Gate::Rz(0.3), &[0]));
1329        assert!(commutes(Gate::Rx(0.3), &[1]));
1330        assert!(commutes(Gate::Cz, &[0, 2]));
1331        assert!(commutes(Gate::H, &[2]));
1332        assert!(!commutes(Gate::Rz(0.3), &[1]));
1333        assert!(!commutes(Gate::Ry(0.3), &[0]));
1334        assert!(!commutes(Gate::Rzz(0.3), &[0, 1]));
1335        assert!(!commutes(Gate::H, &[0]));
1336        assert!(!commutes(Gate::Cx, &[1, 0]));
1337    }
1338
1339    fn plan_for(circuit: &Circuit, params: &Parameters) -> SweepPlan {
1340        let mut linked: Vec<usize> = params.links().iter().map(|l| l.instruction).collect();
1341        linked.sort_unstable();
1342        let start = linked[0];
1343        let sweep: Vec<usize> = (start..circuit.instructions.len()).collect();
1344        let trainable: Vec<bool> = sweep
1345            .iter()
1346            .map(|i| linked.binary_search(i).is_ok())
1347            .collect();
1348        let footprints: Vec<Footprint> = sweep
1349            .iter()
1350            .map(|&i| {
1351                let Instruction::Gate { gate, targets } = &circuit.instructions[i] else {
1352                    unreachable!()
1353                };
1354                Footprint::of(gate, targets)
1355            })
1356            .collect();
1357        plan_sweep(&footprints, &trainable, circuit.num_qubits)
1358    }
1359
1360    // Interleaved Ry and Rz on each qubit evaluate as one Rz step and one Ry
1361    // step per layer, where a run that must commute internally splits at every
1362    // qubit.
1363    #[test]
1364    fn a_hardware_efficient_layer_takes_two_steps() {
1365        let (n, layers) = (8, 3);
1366        let circuit = crate::circuits::hardware_efficient_ansatz(n, layers, 7);
1367        let plan = plan_for(&circuit, &Parameters::all_rotations(&circuit));
1368        assert_eq!(plan.evaluate.len(), 2 * n * layers);
1369        assert!(
1370            plan.last_step <= 2 * layers,
1371            "{} steps for {layers} layers",
1372            plan.last_step + 1
1373        );
1374        let stretches = plan
1375            .invert
1376            .iter()
1377            .map(|&(t, _)| t)
1378            .collect::<std::collections::BTreeSet<_>>();
1379        assert!(stretches.len() <= 2 * layers);
1380    }
1381
1382    #[test]
1383    fn a_qaoa_layer_takes_two_steps() {
1384        let circuit = crate::circuits::qaoa_circuit(8, 3, 7);
1385        let plan = plan_for(&circuit, &Parameters::all_rotations(&circuit));
1386        assert_eq!(plan.last_step, 5);
1387    }
1388
1389    #[test]
1390    fn the_plan_never_inverts_the_earliest_trainable_gate_or_an_idle_one() {
1391        let mut c = Circuit::new(3, 0);
1392        c.add_gate(Gate::Ry(0.4), &[0]);
1393        c.add_gate(Gate::H, &[2]);
1394        c.add_gate(Gate::Cx, &[0, 1]);
1395        c.add_gate(Gate::Rz(0.7), &[1]);
1396        let mut params = Parameters::new(2);
1397        params.link(0, 0);
1398        params.link(3, 1);
1399
1400        let plan = plan_for(&c, &params);
1401        let inverted: Vec<usize> = plan.invert.iter().map(|&(_, s)| s).collect();
1402        assert_eq!(inverted, [3, 2]);
1403        assert_eq!(plan.last_step, 1);
1404    }
1405
1406    fn assert_adjoint_matches_shift(
1407        circuit: &Circuit,
1408        ham: &[(f64, Vec<PauliTerm>)],
1409        params: &Parameters,
1410    ) {
1411        let adjoint = run_expectation_gradient(circuit, ham, params, 42).unwrap();
1412        let shift = run_expectation_gradient_shift(circuit, ham, params, 42).unwrap();
1413        let n = circuit.num_qubits;
1414        assert!((adjoint.value - shift.value).abs() < 1e-10, "{n}q value");
1415        for (slot, (got, want)) in adjoint.gradient.iter().zip(&shift.gradient).enumerate() {
1416            assert!(
1417                (got - want).abs() < 1e-10,
1418                "{n}q slot {slot}: {got} vs {want}"
1419            );
1420        }
1421    }
1422
1423    fn mixed_hamiltonian(n: usize) -> Vec<(f64, Vec<PauliTerm>)> {
1424        (0..n - 1)
1425            .map(|q| {
1426                (
1427                    0.5 + 0.1 * q as f64,
1428                    vec![PauliTerm::z(q), PauliTerm::z(q + 1)],
1429                )
1430            })
1431            .chain([
1432                (0.7, vec![PauliTerm::x(1)]),
1433                (-0.4, vec![PauliTerm::y(0), PauliTerm::x(n - 1)]),
1434            ])
1435            .collect()
1436    }
1437
1438    // Widths on both sides of the fusion floors, so the inverted stretches run
1439    // both unfused and through the 1q, 2q, and batch passes.
1440    #[test]
1441    fn frontier_sweep_matches_parameter_shift_on_hea_and_qaoa() {
1442        for n in [5, 12, 16] {
1443            let ham = mixed_hamiltonian(n);
1444            let (hea, shared) = hea_with_shared_slots(n);
1445            assert_adjoint_matches_shift(&hea, &ham, &shared);
1446            assert_adjoint_matches_shift(&hea, &ham, &Parameters::all_rotations(&hea));
1447
1448            let qaoa = crate::circuits::qaoa_circuit(n, 2, 11);
1449            let mut params = Parameters::new(4);
1450            for (k, link) in Parameters::all_rotations(&qaoa).links().iter().enumerate() {
1451                let layer = k / (2 * n - 1);
1452                let mixer = k % (2 * n - 1) >= n - 1;
1453                params.link(link.instruction, 2 * layer + usize::from(mixer));
1454            }
1455            assert_adjoint_matches_shift(&qaoa, &ham, &params);
1456        }
1457    }
1458
1459    // Fixed gates of every commutation kind sit between trainable ones, some
1460    // trainable gates share a slot, one rotation stays fixed, and a fixed
1461    // prefix precedes the earliest trainable gate.
1462    #[test]
1463    fn frontier_sweep_matches_parameter_shift_with_fixed_gates_between() {
1464        use crate::sim::unified_pauli::PauliAxis;
1465        for n in [4, 12, 16] {
1466            let mut c = Circuit::new(n, 0);
1467            for q in 0..n {
1468                c.add_gate(Gate::H, &[q]);
1469            }
1470            for layer in 0..2 {
1471                for q in 0..n {
1472                    let angle = 0.3 + 0.17 * (q + layer * n) as f64;
1473                    c.add_gate(Gate::Ry(angle), &[q]);
1474                    c.add_gate(if q % 3 == 0 { Gate::T } else { Gate::SX }, &[q]);
1475                    c.add_gate(Gate::Rz(angle * 0.5), &[q]);
1476                }
1477                for q in (layer..n - 1).step_by(2) {
1478                    c.add_gate(Gate::Cx, &[q, q + 1]);
1479                    c.add_gate(Gate::Rzz(0.2 + 0.05 * q as f64), &[q, q + 1]);
1480                }
1481                c.add_gate(Gate::Cz, &[0, n - 1]);
1482                c.add_gate(Gate::Swap, &[1, 2]);
1483                c.add_gate(Gate::P(0.6), &[n - 1]);
1484                c.add_pauli_rotation(
1485                    0.45,
1486                    &[
1487                        PauliTerm::new(0, PauliAxis::X),
1488                        PauliTerm::new(2, PauliAxis::Y),
1489                        PauliTerm::new(3, PauliAxis::Z),
1490                    ],
1491                );
1492                c.add_gate(Gate::Rx(0.9), &[n / 2]);
1493            }
1494
1495            let mut params = Parameters::new(5);
1496            let rotations = c
1497                .instructions
1498                .iter()
1499                .enumerate()
1500                .filter(|(_, inst)| {
1501                    matches!(inst, Instruction::Gate { gate, .. } if gate.pauli_generator().is_some())
1502                })
1503                .map(|(i, _)| i)
1504                .collect::<Vec<_>>();
1505            for (k, &i) in rotations.iter().enumerate() {
1506                if k % 7 != 3 {
1507                    params.link(i, k % 5);
1508                }
1509            }
1510            assert_adjoint_matches_shift(&c, &mixed_hamiltonian(n), &params);
1511        }
1512    }
1513
1514    #[test]
1515    fn a_full_light_cone_borrows_the_circuit_it_was_cut_from() {
1516        let mut c = Circuit::new(2, 1);
1517        c.add_gate(Gate::Rx(0.3), &[0]);
1518        c.add_gate(Gate::Cx, &[0, 1]);
1519        c.add_gate(Gate::Ry(0.7), &[1]);
1520
1521        let all: Vec<usize> = (0..c.instructions.len()).collect();
1522        assert!(matches!(kept_subcircuit(&c, &all), Cow::Borrowed(_)));
1523
1524        let pruned = kept_subcircuit(&c, &[0, 2]);
1525        assert!(matches!(pruned, Cow::Owned(_)));
1526        assert_eq!(pruned.num_qubits, c.num_qubits);
1527        assert_eq!(pruned.num_classical_bits, c.num_classical_bits);
1528        let gates: Vec<&Gate> = pruned
1529            .instructions
1530            .iter()
1531            .map(|inst| {
1532                let Instruction::Gate { gate, .. } = inst else {
1533                    unreachable!()
1534                };
1535                gate
1536            })
1537            .collect();
1538        assert!(matches!(gates.as_slice(), [Gate::Rx(_), Gate::Ry(_)]));
1539    }
1540}