Skip to main content

prism_q/sim/
dispatch.rs

1//! Backend selection and execution planning.
2//!
3//! Resolves a [`BackendKind`] against a circuit into a buildable plan,
4//! including the CPU-vs-GPU accel verdict and the temporal-Clifford split.
5
6use crate::backend::density_matrix::DensityMatrixBackend;
7use crate::backend::mps::MpsBackend;
8use crate::backend::product::ProductStateBackend;
9use crate::backend::sparse::{MAX_SPARSE_INDEX_QUBITS, SparseBackend};
10use crate::backend::stabilizer::StabilizerBackend;
11use crate::backend::statevector::StatevectorBackend;
12use crate::backend::tensornetwork::TensorNetworkBackend;
13use crate::backend::{
14    Backend, DM_QUBIT_CAP_ENV, check_state_allocation, max_density_matrix_qubits,
15    max_statevector_qubits,
16};
17use crate::circuit::{Circuit, Instruction};
18use crate::error::{PrismError, Result};
19
20#[cfg(any(feature = "gpu", feature = "distributed"))]
21use std::sync::Arc;
22
23#[cfg(feature = "gpu")]
24use crate::gpu::GpuContext;
25
26#[cfg(feature = "distributed")]
27use crate::backend::distributed_statevector::DistributedStatevectorBackend;
28#[cfg(feature = "distributed")]
29use crate::distributed::DistributedContext;
30
31use super::metadata::ResolvedBackend;
32use super::{RunOutcome, try_backend_probabilities};
33
34pub(super) const AUTO_MPS_BOND_DIM: usize = 256;
35
36pub(super) const MAX_AUTO_T_COUNT_EXACT: usize = 18;
37
38pub(super) const MAX_AUTO_T_COUNT_SHOTS: usize = 40;
39
40pub(super) const MAX_STABILIZER_RANK_QUBITS: usize = 25;
41
42pub(super) const MIN_QUBITS_FOR_SPD_AUTO: usize = 12;
43
44pub(super) const AUTO_SPD_MAX_TERMS: usize = 65536;
45
46pub(super) const MIN_FACTORED_STABILIZER_QUBITS: usize = 128;
47
48pub(super) const MIN_BLOCK_FOR_FACTORED_STAB: usize = 16;
49
50#[inline]
51pub(super) fn stabilizer_rank_budget(num_qubits: usize) -> usize {
52    let log2n = if num_qubits >= 2 {
53        (num_qubits as f64).log2().ceil() as usize * 2
54    } else {
55        0
56    };
57    num_qubits.saturating_sub(log2n)
58}
59
60// GPU crossover threshold and its env override live in `crate::gpu` so users
61// can introspect them without depending on internal dispatch plumbing. The
62// dispatch layer calls `crate::gpu::min_qubits()` directly; there is no
63// private duplicate.
64
65/// Backend selection for a simulation run.
66///
67/// `Auto` resolves per call from circuit shape. Two routes run before the
68/// family tree: circuits that decompose into independent blocks run per block
69/// (Clifford-only circuits at 128 qubits and above with a 16+ qubit block use
70/// FactoredStabilizer), and Clifford+T circuits up to 25 qubits whose T count
71/// fits the size-derived stabilizer-rank budget run the exact StabilizerRank
72/// expansion (shot paths to 40 T gates; `MAX_AUTO_T_COUNT_EXACT` and
73/// `MAX_AUTO_T_COUNT_SHOTS` above). The pruned expansion is reachable only
74/// through [`run_stabilizer_rank_approx`], never from `Auto`; marginal queries
75/// on Clifford+T circuits at 12 qubits and above answer via Sparse Pauli
76/// Dynamics. The remaining tree:
77///
78/// 1. No entangling gates        → ProductState (O(n))
79/// 2. All Clifford gates         → Stabilizer (O(n²))
80/// 3. Above the statevector memory cap:
81///    a. Sparse-friendly         → Sparse (O(k) where k = non-zero amplitudes)
82///    b. Otherwise               → MPS (bounded bond dimension)
83/// 4. Partial independence       → Factored (per-group dense sub-states)
84/// 5. Otherwise                  → Statevector (exact, general-purpose)
85///
86/// [`run_stabilizer_rank_approx`]: crate::run_stabilizer_rank_approx
87#[derive(Debug, Clone)]
88pub enum BackendKind {
89    Auto,
90    Statevector,
91    /// Tableau simulation for Clifford-only circuits.
92    Stabilizer,
93    /// Sparse state vector holding only nonzero amplitudes.
94    Sparse,
95    /// Matrix Product State simulation with a bounded bond dimension.
96    Mps {
97        max_bond_dim: usize,
98    },
99    /// Per-qubit product state for circuits without entangling gates.
100    ProductState,
101    /// Deferred-contraction tensor network for low-treewidth circuits.
102    TensorNetwork,
103    /// Dynamic split-state simulation for sparse-entanglement circuits.
104    Factored,
105    /// Clifford+T decomposition into weighted stabilizer branches.
106    StabilizerRank,
107    /// Independent Clifford blocks, each on its own tableau.
108    FactoredStabilizer,
109    /// Exact density-matrix backend for mixed-state evolution.
110    ///
111    /// Explicit-dispatch only: [`BackendKind::Auto`] never selects it. Stores
112    /// `4^n` `Complex64` amplitudes, so the qubit ceiling is roughly half the
113    /// statevector cap (14 on a 16 GiB host); `PRISM_MAX_DM_QUBITS` moves it
114    /// within that bound. Fused payloads are accepted, with every fusion floor
115    /// gated on the `2n`-qubit buffer the backend sweeps rather than on the
116    /// circuit width.
117    ///
118    /// Under an attached noise model this is the exact route: the mixture is
119    /// evolved once and every terminal reads it, so shot counts carry sampling
120    /// noise only and observables carry none.
121    DensityMatrix,
122    /// Stochastic Pauli propagation (SPP); serves marginal and observable
123    /// queries only.
124    StochasticPauli {
125        num_samples: usize,
126    },
127    /// Deterministic sparse Pauli dynamics (SPD); serves marginal and
128    /// observable queries only. Terms below `epsilon` are dropped once the
129    /// weighted sum exceeds `max_terms` (0 disables truncation).
130    DeterministicPauli {
131        epsilon: f64,
132        max_terms: usize,
133    },
134    /// Heisenberg Pauli propagation through a noise model; serves expectation
135    /// values and observable expectations only.
136    ///
137    /// Explicit-dispatch only: [`BackendKind::Auto`] never selects it. Noise
138    /// enters as the channel's action on the Pauli basis, which shrinks
139    /// coefficients while the circuit's rotations grow the term count, so the
140    /// weighted sum stays small exactly when noise outpaces the branching
141    /// rotations. Terms below `epsilon` are dropped once the sum exceeds
142    /// `max_terms` (0 disables truncation and makes the run exact).
143    ///
144    /// A channel with no Pauli-basis form (custom Kraus, two-qubit Kraus,
145    /// readout error) is rejected naming the density matrix.
146    PauliPath {
147        epsilon: f64,
148        max_terms: usize,
149    },
150    /// Automatic backend selection with GPU acceleration opted in.
151    ///
152    /// Makes the same shape-based routing decisions as [`BackendKind::Auto`],
153    /// but when the selected family (per sub-block, after subsystem
154    /// decomposition) has a device capability row and the block clears the
155    /// qubit-count crossover with VRAM to spare, that block runs on the
156    /// supplied context. Every other choice, and every block that fails the
157    /// crossover or VRAM check, runs on the identical CPU path Auto would
158    /// take. Device paths resolve soft: an allocation that fails after the
159    /// VRAM check degrades to the host, so a missing, unfit, or racing device
160    /// stays on CPU rather than erroring.
161    ///
162    /// Acceleration reaches every entry point through one resolution
163    /// mechanism: single runs, terminal shot and counts sampling, expectation
164    /// values, temporal-Clifford tails, and non-Pauli noisy trajectories.
165    ///
166    /// The context is user-supplied and is never acquired implicitly.
167    #[cfg(feature = "gpu")]
168    AutoGpu {
169        context: Arc<GpuContext>,
170    },
171    /// Statevector backed by a CUDA GPU execution context.
172    ///
173    /// Circuits (or decomposed sub-blocks) with fewer than
174    /// [`crate::gpu::min_qubits()`] qubits (tunable via
175    /// `PRISM_GPU_MIN_QUBITS`, default [`crate::gpu::MIN_QUBITS_DEFAULT`])
176    /// transparently fall back to the host statevector path, since
177    /// small states do not survive PCIe and launch-latency overhead.
178    /// Larger circuits allocate a device-resident state and route gate
179    /// application through GPU kernels.
180    ///
181    /// Compose with `simulate(...).backend(...).seed(...).run()` to get fusion
182    /// plus independent-subsystem decomposition; each sub-block is evaluated
183    /// against the crossover independently.
184    #[cfg(feature = "gpu")]
185    StatevectorGpu {
186        context: Arc<GpuContext>,
187    },
188    /// Density matrix held in device memory on the supplied context.
189    ///
190    /// Explicit-dispatch only: neither [`BackendKind::Auto`] nor
191    /// [`BackendKind::AutoGpu`] selects it. There is no crossover and no host
192    /// fallback: every run allocates the `4^n` mixture on the device, after a
193    /// budget check against the free VRAM that errors before allocating. An
194    /// 11 GiB card holds 13 qubits (1 GiB at 13, 4 GiB at 14 plus scratch).
195    /// Every channel, measurement, and readout sweep runs as a kernel; the
196    /// noisy terminals answer from the exact mixture as
197    /// [`BackendKind::DensityMatrix`] does.
198    #[cfg(feature = "gpu")]
199    DensityMatrixGpu {
200        context: Arc<GpuContext>,
201    },
202    /// Stabilizer backend backed by a CUDA GPU tableau.
203    ///
204    /// Circuits (or decomposed sub-blocks) with fewer than
205    /// [`crate::gpu::stabilizer_min_qubits()`] qubits (tunable via
206    /// `PRISM_STABILIZER_GPU_MIN_QUBITS`, default
207    /// [`crate::gpu::STABILIZER_MIN_QUBITS_DEFAULT`]) fall back to the CPU
208    /// stabilizer path. The GPU path routes gate application to device
209    /// kernels. Measurement and reset stay on device, while probabilities and
210    /// export-style helpers still read back to the CPU algorithms.
211    ///
212    /// Compose with `simulate(...).backend(...).seed(...).run()` to pick up
213    /// independent-subsystem decomposition; non-Clifford circuits are rejected
214    /// at dispatch time with the same error shape as [`BackendKind::Stabilizer`].
215    #[cfg(feature = "gpu")]
216    StabilizerGpu {
217        context: Arc<GpuContext>,
218    },
219    /// Exact state vector distributed across `2^p` ranks via a
220    /// [`DistributedContext`]. The low `n - p` qubits are simulated locally with
221    /// the standard SIMD kernels; the top `p` qubits select the rank.
222    ///
223    /// Results are independent of the rank count. With a single rank the path is
224    /// identical to [`BackendKind::Statevector`].
225    #[cfg(feature = "distributed")]
226    StatevectorDistributed {
227        context: Arc<DistributedContext>,
228    },
229}
230
231impl BackendKind {
232    /// Whether this kind uses automatic shape-based routing. True for
233    /// [`BackendKind::Auto`] and its GPU-accelerated sibling
234    /// [`BackendKind::AutoGpu`], which make identical routing decisions and
235    /// differ only in whether a cleared statevector or stabilizer block runs on
236    /// the device.
237    #[inline]
238    pub(crate) fn is_auto(&self) -> bool {
239        match self {
240            BackendKind::Auto => true,
241            #[cfg(feature = "gpu")]
242            BackendKind::AutoGpu { .. } => true,
243            _ => false,
244        }
245    }
246
247    /// False for the engines without a per-shot pure state: stabilizer rank,
248    /// Pauli propagation, density matrix. The density matrix still serves a
249    /// noise model, by evolving the mixture once and sampling the exact
250    /// distribution, so its noisy terminals route around the trajectory engine
251    /// rather than through it.
252    pub fn supports_noisy_per_shot(&self) -> bool {
253        !matches!(
254            self,
255            BackendKind::StabilizerRank
256                | BackendKind::StochasticPauli { .. }
257                | BackendKind::DeterministicPauli { .. }
258                | BackendKind::PauliPath { .. }
259        ) && !self.is_density_matrix()
260    }
261
262    /// True for the two kinds that hold the exact mixture, host or device.
263    pub(crate) fn is_density_matrix(&self) -> bool {
264        match self {
265            BackendKind::DensityMatrix => true,
266            #[cfg(feature = "gpu")]
267            BackendKind::DensityMatrixGpu { .. } => true,
268            _ => false,
269        }
270    }
271
272    /// True for kinds that can run non-Pauli channels (damping, thermal
273    /// relaxation, custom Kraus): the trajectory engine everywhere except the
274    /// density matrix, which applies the channel to the mixture instead.
275    pub fn supports_general_noise(&self) -> bool {
276        match self {
277            BackendKind::Auto
278            | BackendKind::Statevector
279            | BackendKind::Sparse
280            | BackendKind::Mps { .. }
281            | BackendKind::ProductState
282            | BackendKind::Factored
283            | BackendKind::TensorNetwork
284            | BackendKind::DensityMatrix => true,
285            #[cfg(feature = "gpu")]
286            BackendKind::AutoGpu { .. }
287            | BackendKind::StatevectorGpu { .. }
288            | BackendKind::DensityMatrixGpu { .. } => true,
289            _ => false,
290        }
291    }
292
293    pub(crate) fn is_stabilizer_family(&self) -> bool {
294        matches!(
295            self,
296            BackendKind::Stabilizer | BackendKind::FactoredStabilizer
297        ) || {
298            #[cfg(feature = "gpu")]
299            {
300                matches!(self, BackendKind::StabilizerGpu { .. })
301            }
302            #[cfg(not(feature = "gpu"))]
303            {
304                false
305            }
306        }
307    }
308
309    pub(crate) fn general_noise_backend_names() -> &'static str {
310        #[cfg(feature = "gpu")]
311        {
312            "Auto, Statevector, StatevectorGpu, Sparse, Mps, ProductState, Factored, TensorNetwork, DensityMatrix, or DensityMatrixGpu"
313        }
314        #[cfg(not(feature = "gpu"))]
315        {
316            "Auto, Statevector, Sparse, Mps, ProductState, Factored, TensorNetwork, or DensityMatrix"
317        }
318    }
319}
320
321pub(super) fn validate_explicit_backend(kind: &BackendKind, circuit: &Circuit) -> Result<()> {
322    if kind.is_stabilizer_family() && !circuit.is_clifford_only() {
323        return Err(PrismError::IncompatibleBackend {
324            backend: "stabilizer".into(),
325            reason: "circuit contains non-Clifford gates".into(),
326        });
327    }
328    match kind {
329        BackendKind::ProductState if circuit.has_entangling_gates() => {
330            return Err(PrismError::IncompatibleBackend {
331                backend: "productstate".into(),
332                reason: "circuit contains entangling gates".into(),
333            });
334        }
335        BackendKind::StabilizerRank if !circuit.has_t_gates() => {
336            return Err(PrismError::IncompatibleBackend {
337                backend: "stabilizer_rank".into(),
338                reason: "circuit has no T gates; use Stabilizer instead".into(),
339            });
340        }
341        BackendKind::DensityMatrix => {
342            check_state_allocation(
343                "density_matrix",
344                circuit.num_qubits,
345                max_density_matrix_qubits(),
346                DM_QUBIT_CAP_ENV,
347            )?;
348        }
349        _ => {}
350    }
351    Ok(())
352}
353
354/// Simulator family, independent of how it was selected (auto routing or an
355/// explicit kind) and of where it executes (host or device).
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub(super) enum Family {
358    ProductState,
359    Stabilizer,
360    Sparse,
361    Mps,
362    Factored,
363    FactoredStabilizer,
364    TensorNetwork,
365    Statevector,
366    DensityMatrix,
367}
368
369fn select_auto_backend_choice(circuit: &Circuit, has_partial_independence: bool) -> Family {
370    if !circuit.has_entangling_gates() {
371        Family::ProductState
372    } else if circuit.is_clifford_only() {
373        Family::Stabilizer
374    } else if circuit.num_qubits > max_statevector_qubits() {
375        if circuit.is_sparse_friendly() && circuit.num_qubits <= MAX_SPARSE_INDEX_QUBITS {
376            Family::Sparse
377        } else {
378            Family::Mps
379        }
380    } else if has_partial_independence {
381        Family::Factored
382    } else {
383        Family::Statevector
384    }
385}
386
387pub(super) fn auto_selects_cpu_statevector(
388    circuit: &Circuit,
389    has_partial_independence: bool,
390) -> bool {
391    matches!(
392        select_auto_backend_choice(circuit, has_partial_independence),
393        Family::Statevector
394    )
395}
396
397/// Execution target for a resolved family: host, or a device context with a
398/// resolved failure mode. `soft` builds fall back to the host if device
399/// allocation fails at `init`; hard builds surface the error.
400#[derive(Clone)]
401pub(super) enum Accel {
402    Cpu,
403    #[cfg(feature = "gpu")]
404    Gpu {
405        context: Arc<GpuContext>,
406        soft: bool,
407    },
408}
409
410/// Device eligibility for one family: the qubit crossover and the VRAM-fit
411/// predicate, owned together. Families without device kernels are `None` rows
412/// in [`gpu_capability`]; adding a device path for a family means adding its
413/// row there, not touching dispatch.
414#[cfg(feature = "gpu")]
415struct GpuCapability {
416    min_qubits: fn() -> usize,
417    fits: fn(&Arc<GpuContext>, usize) -> bool,
418}
419
420/// Families with a `gpu_capability` row. Keep in sync with the match below;
421/// a new device family needs an entry in both.
422#[cfg(feature = "gpu")]
423const GPU_CAPABLE_FAMILIES: [Family; 3] = [
424    Family::Statevector,
425    Family::Stabilizer,
426    Family::DensityMatrix,
427];
428
429#[cfg(feature = "gpu")]
430fn gpu_capability(family: Family) -> Option<GpuCapability> {
431    match family {
432        Family::Statevector => Some(GpuCapability {
433            min_qubits: crate::gpu::min_qubits,
434            fits: |ctx, n| ctx.fits_statevector_with_scratch(n).unwrap_or(false),
435        }),
436        Family::Stabilizer => Some(GpuCapability {
437            min_qubits: crate::gpu::stabilizer_min_qubits,
438            fits: |ctx, n| ctx.fits_tableau(n).unwrap_or(false),
439        }),
440        // Explicit-only: no crossover, and the `Auto` tree never selects the
441        // family, so the soft fit gate is never consulted. The hard path budgets
442        // the `4^n` buffer at `init`.
443        Family::DensityMatrix => Some(GpuCapability {
444            min_qubits: || 0,
445            fits: |ctx, n| ctx.fits_statevector_with_scratch(2 * n).unwrap_or(false),
446        }),
447        Family::ProductState
448        | Family::Sparse
449        | Family::Mps
450        | Family::Factored
451        | Family::FactoredStabilizer
452        | Family::TensorNetwork => None,
453    }
454}
455
456/// The one decision point for CPU vs GPU execution of a family.
457///
458/// `AutoGpu` requires the family's capability row, the qubit crossover, and the
459/// VRAM-fit gate, and resolves soft so an allocation race still degrades to the
460/// host. An explicit GPU kind applies only the crossover for its own family and
461/// resolves hard: the user asked for the device, so an unfit device errors
462/// loudly. Every other kind, and every family without a capability row,
463/// resolves to the host.
464///
465/// The verdict (including the VRAM query) is intended to be taken once per
466/// user-level call and reused across shots; soft-mode init fallback covers
467/// VRAM shrinking after the fact.
468#[cfg(feature = "gpu")]
469pub(super) fn accel_for(kind: &BackendKind, family: Family, num_qubits: usize) -> Accel {
470    let Some((context, soft)) = gpu_request(kind, family) else {
471        return Accel::Cpu;
472    };
473    let Some(cap) = gpu_capability(family) else {
474        return Accel::Cpu;
475    };
476    if num_qubits < (cap.min_qubits)() {
477        return Accel::Cpu;
478    }
479    if soft && !(cap.fits)(context, num_qubits) {
480        return Accel::Cpu;
481    }
482    Accel::Gpu {
483        context: context.clone(),
484        soft,
485    }
486}
487
488/// Which (kind, family) pairs request device execution, and whether the
489/// request resolves soft. Shared by [`accel_for`] and [`may_resolve_to_gpu`]
490/// so the kind-to-family matching lives in one place.
491#[cfg(feature = "gpu")]
492fn gpu_request(kind: &BackendKind, family: Family) -> Option<(&Arc<GpuContext>, bool)> {
493    match (kind, family) {
494        (BackendKind::AutoGpu { context }, _) => Some((context, true)),
495        (BackendKind::StatevectorGpu { context }, Family::Statevector) => Some((context, false)),
496        (BackendKind::StabilizerGpu { context }, Family::Stabilizer) => Some((context, false)),
497        (BackendKind::DensityMatrixGpu { context }, Family::DensityMatrix) => {
498            Some((context, false))
499        }
500        _ => None,
501    }
502}
503
504/// Whether this kind could resolve any family to the device at the given
505/// width. Over-approximates [`accel_for`]: it applies only the qubit
506/// crossover and skips the VRAM-fit gate, so the verdict cannot flip between
507/// a scheduling decision and the later per-block resolution. Multi-block
508/// drivers consult it before spreading work across threads; GPU backends
509/// share one CUDA stream per context and must not execute concurrently.
510#[cfg(feature = "gpu")]
511pub(super) fn may_resolve_to_gpu(kind: &BackendKind, num_qubits: usize) -> bool {
512    GPU_CAPABLE_FAMILIES.into_iter().any(|family| {
513        gpu_request(kind, family).is_some()
514            && gpu_capability(family).is_some_and(|cap| num_qubits >= (cap.min_qubits)())
515    })
516}
517
518#[cfg(not(feature = "gpu"))]
519pub(super) fn accel_for(_kind: &BackendKind, _family: Family, _num_qubits: usize) -> Accel {
520    Accel::Cpu
521}
522
523pub(super) fn build_statevector(accel: &Accel, seed: u64) -> StatevectorBackend {
524    match accel {
525        Accel::Cpu => StatevectorBackend::new(seed),
526        #[cfg(feature = "gpu")]
527        Accel::Gpu {
528            context,
529            soft: true,
530        } => StatevectorBackend::new(seed).with_gpu_auto(context.clone()),
531        #[cfg(feature = "gpu")]
532        Accel::Gpu {
533            context,
534            soft: false,
535        } => StatevectorBackend::new(seed).with_gpu(context.clone()),
536    }
537}
538
539pub(super) fn build_density_matrix(accel: &Accel, seed: u64) -> DensityMatrixBackend {
540    match accel {
541        Accel::Cpu => DensityMatrixBackend::new(seed),
542        #[cfg(feature = "gpu")]
543        Accel::Gpu { context, .. } => DensityMatrixBackend::new(seed).with_gpu(context.clone()),
544    }
545}
546
547/// True when no instruction can force destabilizer materialization: gates,
548/// satisfied conditionals, and barriers only. Measurements, resets, and
549/// guarded regions (whose bodies can hold either) all disqualify.
550fn stabilizer_can_run_lazy(circuit: &Circuit) -> bool {
551    circuit.instructions.iter().all(|inst| {
552        matches!(
553            inst,
554            Instruction::Gate { .. }
555                | Instruction::Conditional { .. }
556                | Instruction::Barrier { .. }
557        )
558    })
559}
560
561fn build_stabilizer(accel: &Accel, lazy: bool, seed: u64) -> StabilizerBackend {
562    // Lazy destabilizers: gates touch half the tableau. The plan sets `lazy`
563    // only for circuits that never measure or reset, so the Gaussian
564    // elimination that materialization costs is never paid; on a measuring
565    // circuit the reconstruction can exceed the gate savings (the GHZ
566    // measure-all shape loses outright, since its SGI gate cost is already
567    // near zero). GPU init overrides the flag; the soft-fallback host path
568    // keeps it.
569    let stab = if lazy {
570        StabilizerBackend::new_lazy(seed)
571    } else {
572        StabilizerBackend::new(seed)
573    };
574    match accel {
575        Accel::Cpu => stab,
576        #[cfg(feature = "gpu")]
577        Accel::Gpu {
578            context,
579            soft: true,
580        } => stab.with_gpu_auto(context.clone()),
581        #[cfg(feature = "gpu")]
582        Accel::Gpu {
583            context,
584            soft: false,
585        } => stab.with_gpu(context.clone()),
586    }
587}
588
589/// Resolved family plus acceleration for one user-level call. `build` is
590/// cheap enough to call once per shot or per trajectory: a constructor, a
591/// `Box::new`, and at most one `Arc` clone. All circuit analysis and every
592/// driver VRAM query happen earlier, in [`resolve`].
593#[derive(Clone)]
594pub(super) enum BackendPlan {
595    ProductState,
596    Sparse,
597    TensorNetwork,
598    Factored,
599    FactoredStabilizer,
600    Mps {
601        max_bond_dim: usize,
602    },
603    Stabilizer {
604        accel: Accel,
605        lazy: bool,
606    },
607    Statevector {
608        accel: Accel,
609    },
610    DensityMatrix {
611        accel: Accel,
612    },
613    #[cfg(feature = "distributed")]
614    Distributed(Arc<DistributedContext>),
615}
616
617impl BackendPlan {
618    /// Engine this plan builds, for a shot request that runs the plan zero
619    /// times and so has no backend to read provenance off.
620    pub(super) fn resolved(&self) -> ResolvedBackend {
621        match self {
622            BackendPlan::ProductState => ResolvedBackend::ProductState,
623            BackendPlan::Sparse => ResolvedBackend::Sparse,
624            BackendPlan::TensorNetwork => ResolvedBackend::TensorNetwork,
625            BackendPlan::Factored => ResolvedBackend::Factored,
626            BackendPlan::FactoredStabilizer => ResolvedBackend::FactoredStabilizer,
627            BackendPlan::Mps { .. } => ResolvedBackend::Mps,
628            BackendPlan::Stabilizer { .. } => ResolvedBackend::Stabilizer,
629            BackendPlan::Statevector { .. } => ResolvedBackend::Statevector,
630            BackendPlan::DensityMatrix { .. } => ResolvedBackend::DensityMatrix,
631            #[cfg(feature = "distributed")]
632            BackendPlan::Distributed(_) => ResolvedBackend::Distributed,
633        }
634    }
635
636    pub(super) fn build(&self, seed: u64) -> Box<dyn Backend + Send> {
637        match self {
638            BackendPlan::ProductState => Box::new(ProductStateBackend::new(seed)),
639            BackendPlan::Sparse => Box::new(SparseBackend::new(seed)),
640            BackendPlan::TensorNetwork => Box::new(TensorNetworkBackend::new(seed)),
641            BackendPlan::Factored => Box::new(crate::backend::factored::FactoredBackend::new(seed)),
642            BackendPlan::FactoredStabilizer => {
643                Box::new(crate::backend::factored_stabilizer::FactoredStabilizerBackend::new(seed))
644            }
645            BackendPlan::Mps { max_bond_dim } => Box::new(MpsBackend::new(seed, *max_bond_dim)),
646            BackendPlan::Stabilizer { accel, lazy } => {
647                Box::new(build_stabilizer(accel, *lazy, seed))
648            }
649            BackendPlan::Statevector { accel } => Box::new(build_statevector(accel, seed)),
650            BackendPlan::DensityMatrix { accel } => Box::new(build_density_matrix(accel, seed)),
651            #[cfg(feature = "distributed")]
652            BackendPlan::Distributed(context) => {
653                Box::new(DistributedStatevectorBackend::new(context.clone(), seed))
654            }
655        }
656    }
657
658    pub(super) fn is_gpu(&self) -> bool {
659        match self {
660            BackendPlan::Stabilizer { accel, .. }
661            | BackendPlan::Statevector { accel }
662            | BackendPlan::DensityMatrix { accel } => !matches!(accel, Accel::Cpu),
663            _ => false,
664        }
665    }
666
667    #[cfg(test)]
668    pub(super) fn family(&self) -> Family {
669        match self {
670            BackendPlan::ProductState => Family::ProductState,
671            BackendPlan::Sparse => Family::Sparse,
672            BackendPlan::TensorNetwork => Family::TensorNetwork,
673            BackendPlan::Factored => Family::Factored,
674            BackendPlan::FactoredStabilizer => Family::FactoredStabilizer,
675            BackendPlan::Mps { .. } => Family::Mps,
676            BackendPlan::Stabilizer { .. } => Family::Stabilizer,
677            BackendPlan::Statevector { .. } => Family::Statevector,
678            BackendPlan::DensityMatrix { .. } => Family::DensityMatrix,
679            #[cfg(feature = "distributed")]
680            BackendPlan::Distributed(_) => Family::Statevector,
681        }
682    }
683
684    #[cfg(test)]
685    pub(super) fn accel(&self) -> &Accel {
686        match self {
687            BackendPlan::Stabilizer { accel, .. }
688            | BackendPlan::Statevector { accel }
689            | BackendPlan::DensityMatrix { accel } => accel,
690            _ => &Accel::Cpu,
691        }
692    }
693}
694
695/// Whole-call routing decision produced by [`resolve`]. Backend execution is
696/// described by a buildable [`BackendPlan`]; the remaining variants are the
697/// non-backend engines (stabilizer rank, Pauli propagation) that callers
698/// handle outside the `Backend` world.
699pub(super) enum ExecutionPlan {
700    Backend(BackendPlan),
701    StabilizerRank,
702    StochasticPauli { num_samples: usize },
703    DeterministicPauli { epsilon: f64, max_terms: usize },
704    PauliPath,
705}
706
707/// Name the engine `kind` resolves to when that engine can discard state
708/// weight or estimate by sampling, `None` when the route is exact.
709///
710/// Resolved from the circuit rather than read off a finished run, so
711/// [`Simulate::require_exact`] rejects before paying for the state it would
712/// throw away.
713///
714/// [`Simulate::require_exact`]: crate::sim::Simulate::require_exact
715pub(super) fn approximate_route_name(
716    kind: &BackendKind,
717    circuit: &Circuit,
718) -> Option<&'static str> {
719    match kind {
720        BackendKind::Mps { .. } => return Some("Mps"),
721        BackendKind::StochasticPauli { .. } => return Some("StochasticPauli"),
722        BackendKind::DeterministicPauli { epsilon, max_terms } => {
723            if *epsilon > 0.0 || *max_terms > 0 {
724                return Some("DeterministicPauli");
725            }
726            return None;
727        }
728        BackendKind::PauliPath { epsilon, max_terms } => {
729            if *epsilon > 0.0 || *max_terms > 0 {
730                return Some("PauliPath");
731            }
732            return None;
733        }
734        _ => {}
735    }
736    if !kind.is_auto() {
737        return None;
738    }
739    let (_, has_partial_independence) = crate::sim::analyze_independence(circuit);
740    match select_auto_backend_choice(circuit, has_partial_independence) {
741        Family::Mps => Some("Mps"),
742        _ => None,
743    }
744}
745
746pub(super) fn plan_for_family(
747    kind: &BackendKind,
748    family: Family,
749    num_qubits: usize,
750) -> BackendPlan {
751    match family {
752        Family::ProductState => BackendPlan::ProductState,
753        Family::Sparse => BackendPlan::Sparse,
754        Family::TensorNetwork => BackendPlan::TensorNetwork,
755        Family::Factored => BackendPlan::Factored,
756        Family::FactoredStabilizer => BackendPlan::FactoredStabilizer,
757        Family::Mps => BackendPlan::Mps {
758            max_bond_dim: AUTO_MPS_BOND_DIM,
759        },
760        Family::Stabilizer => BackendPlan::Stabilizer {
761            accel: accel_for(kind, Family::Stabilizer, num_qubits),
762            lazy: false,
763        },
764        Family::Statevector => BackendPlan::Statevector {
765            accel: accel_for(kind, Family::Statevector, num_qubits),
766        },
767        Family::DensityMatrix => BackendPlan::DensityMatrix {
768            accel: accel_for(kind, Family::DensityMatrix, num_qubits),
769        },
770    }
771}
772
773/// Resolve `kind` against `circuit` into an [`ExecutionPlan`], exactly once
774/// per user-level call. Auto kinds run the shape-based decision tree; explicit
775/// kinds map 1:1 onto their family. The CPU-vs-GPU verdict, including the
776/// VRAM-fit query, is taken here through [`accel_for`] and reused across
777/// shots; soft-mode init fallback covers VRAM shrinking after resolution.
778pub(super) fn resolve(
779    kind: &BackendKind,
780    circuit: &Circuit,
781    has_partial_independence: bool,
782) -> ExecutionPlan {
783    let family = match kind {
784        BackendKind::Auto => select_auto_backend_choice(circuit, has_partial_independence),
785        #[cfg(feature = "gpu")]
786        BackendKind::AutoGpu { .. } => {
787            select_auto_backend_choice(circuit, has_partial_independence)
788        }
789        BackendKind::Statevector => Family::Statevector,
790        BackendKind::Stabilizer => Family::Stabilizer,
791        BackendKind::Sparse => Family::Sparse,
792        BackendKind::Mps { max_bond_dim } => {
793            return ExecutionPlan::Backend(BackendPlan::Mps {
794                max_bond_dim: *max_bond_dim,
795            });
796        }
797        BackendKind::ProductState => Family::ProductState,
798        BackendKind::TensorNetwork => Family::TensorNetwork,
799        BackendKind::Factored => Family::Factored,
800        BackendKind::FactoredStabilizer => Family::FactoredStabilizer,
801        BackendKind::DensityMatrix => Family::DensityMatrix,
802        BackendKind::StabilizerRank => return ExecutionPlan::StabilizerRank,
803        BackendKind::StochasticPauli { num_samples } => {
804            return ExecutionPlan::StochasticPauli {
805                num_samples: *num_samples,
806            };
807        }
808        BackendKind::PauliPath { .. } => return ExecutionPlan::PauliPath,
809        BackendKind::DeterministicPauli { epsilon, max_terms } => {
810            return ExecutionPlan::DeterministicPauli {
811                epsilon: *epsilon,
812                max_terms: *max_terms,
813            };
814        }
815        #[cfg(feature = "gpu")]
816        BackendKind::StatevectorGpu { .. } => Family::Statevector,
817        #[cfg(feature = "gpu")]
818        BackendKind::StabilizerGpu { .. } => Family::Stabilizer,
819        #[cfg(feature = "gpu")]
820        BackendKind::DensityMatrixGpu { .. } => Family::DensityMatrix,
821        #[cfg(feature = "distributed")]
822        BackendKind::StatevectorDistributed { context } => {
823            return ExecutionPlan::Backend(BackendPlan::Distributed(context.clone()));
824        }
825    };
826    let mut plan = plan_for_family(kind, family, circuit.num_qubits);
827    if let BackendPlan::Stabilizer { lazy, .. } = &mut plan {
828        *lazy = stabilizer_can_run_lazy(circuit);
829    }
830    ExecutionPlan::Backend(plan)
831}
832
833/// Backend plan for a run that starts from a caller-supplied state.
834///
835/// Routing is constrained here rather than consulted. Every shortcut the
836/// shape-based tree can pick reads the circuit alone and is only valid from the
837/// |0...0⟩ start: a Clifford circuit is a stabilizer state only when its input
838/// is one, a product state and a subsystem split assume unentangled inputs, and
839/// the Pauli engines propagate observables back to |0...0⟩. Selecting one of
840/// them for an arbitrary start state is a wrong answer rather than an error, so
841/// only the representations that can hold an arbitrary state are reachable: the
842/// statevector, on the host, on a device, or sharded across ranks, and the
843/// density matrix.
844/// `Auto` lands on the statevector unconditionally: the caller already holds
845/// `2^n` amplitudes, so the dense state is affordable by construction.
846pub(super) fn initial_state_plan(kind: &BackendKind, num_qubits: usize) -> Result<BackendPlan> {
847    match kind {
848        BackendKind::Auto | BackendKind::Statevector => {
849            Ok(plan_for_family(kind, Family::Statevector, num_qubits))
850        }
851        #[cfg(feature = "gpu")]
852        BackendKind::AutoGpu { .. } | BackendKind::StatevectorGpu { .. } => {
853            Ok(plan_for_family(kind, Family::Statevector, num_qubits))
854        }
855        BackendKind::DensityMatrix => Ok(plan_for_family(kind, Family::DensityMatrix, num_qubits)),
856        #[cfg(feature = "gpu")]
857        BackendKind::DensityMatrixGpu { .. } => {
858            Ok(plan_for_family(kind, Family::DensityMatrix, num_qubits))
859        }
860        #[cfg(feature = "distributed")]
861        BackendKind::StatevectorDistributed { context } => {
862            Ok(BackendPlan::Distributed(context.clone()))
863        }
864        other => Err(PrismError::IncompatibleBackend {
865            backend: format!("{other:?}"),
866            reason: "a start state other than |0...0> runs on the statevector or the density \
867                     matrix; every other representation is derived from that start"
868                .into(),
869        }),
870    }
871}
872
873pub(super) fn resolve_backend(
874    kind: &BackendKind,
875    circuit: &Circuit,
876    has_partial_independence: bool,
877) -> BackendPlan {
878    match resolve(kind, circuit, has_partial_independence) {
879        ExecutionPlan::Backend(plan) => plan,
880        _ => unreachable!("non-backend dispatch should be handled by caller"),
881    }
882}
883
884#[inline]
885pub(super) fn min_clifford_prefix_gates(num_qubits: usize) -> usize {
886    (num_qubits * 2).max(16)
887}
888
889pub(super) fn has_temporal_clifford_opportunity(kind: &BackendKind, circuit: &Circuit) -> bool {
890    if !kind.is_auto() {
891        return false;
892    }
893    if circuit.num_qubits > max_statevector_qubits() {
894        return false;
895    }
896    // The split pays for itself only when the tail needs a dense state. A
897    // Clifford-only circuit has no such tail, and exporting the tableau to run
898    // measurements or a guarded region densely is strictly a loss.
899    if circuit.is_clifford_only() {
900        return false;
901    }
902    let min_gates = min_clifford_prefix_gates(circuit.num_qubits);
903    let mut prefix_gates = 0;
904    for inst in &circuit.instructions {
905        match inst {
906            Instruction::Gate { gate, .. } => {
907                if !gate.is_clifford() {
908                    break;
909                }
910                prefix_gates += 1;
911            }
912            Instruction::Measure { .. }
913            | Instruction::Reset { .. }
914            | Instruction::Conditional { .. }
915            | Instruction::Region(_) => break,
916            Instruction::Barrier { .. } => {}
917        }
918    }
919    prefix_gates >= min_gates && prefix_gates < circuit.instructions.len()
920}
921
922/// Temporal-Clifford execution split into a seed-independent plan and a
923/// per-seed run, so shot loops split and fuse the circuit once instead of
924/// once per shot. The stabilizer prefix runs on the host tableau; the
925/// crossover data in [`gpu_capability`] makes a device prefix unreachable
926/// here (temporal-Clifford requires fitting the dense statevector, far below
927/// the stabilizer crossover).
928pub(super) struct TemporalCliffordPlan {
929    prefix: Circuit,
930    fused_tail: Circuit,
931    tail_num_classical_bits: usize,
932    tail_accel: Accel,
933}
934
935pub(super) fn plan_temporal_clifford(
936    kind: &BackendKind,
937    circuit: &Circuit,
938) -> Option<TemporalCliffordPlan> {
939    if !kind.is_auto() {
940        return None;
941    }
942    if circuit.num_qubits > max_statevector_qubits() {
943        return None;
944    }
945    if circuit.is_clifford_only() {
946        return None;
947    }
948    let (prefix, tail) = circuit.clifford_prefix_split()?;
949    if prefix.gate_count() < min_clifford_prefix_gates(circuit.num_qubits) {
950        return None;
951    }
952    let tail_accel = accel_for(kind, Family::Statevector, circuit.num_qubits);
953    let tail_num_classical_bits = tail.num_classical_bits;
954    let fused_tail = match &tail_accel {
955        Accel::Cpu => crate::circuit::fusion::fuse_circuit(&tail, true).into_owned(),
956        #[cfg(feature = "gpu")]
957        Accel::Gpu { .. } => {
958            let expanded = crate::circuit::expand_qft_blocks(&tail);
959            let expanded = crate::circuit::expand_pauli_rotations(&expanded).into_owned();
960            crate::circuit::fusion::fuse_circuit(&expanded, true).into_owned()
961        }
962    };
963    Some(TemporalCliffordPlan {
964        prefix,
965        fused_tail,
966        tail_num_classical_bits,
967        tail_accel,
968    })
969}
970
971pub(super) fn run_temporal_clifford(
972    plan: &TemporalCliffordPlan,
973    seed: u64,
974    want_probabilities: bool,
975) -> Result<RunOutcome> {
976    let mut stab = StabilizerBackend::new(seed);
977    stab.init(plan.prefix.num_qubits, plan.prefix.num_classical_bits)?;
978    stab.enable_lazy_destab();
979    for inst in &plan.prefix.instructions {
980        stab.apply(inst)?;
981    }
982
983    let state = stab.export_statevector()?;
984
985    let mut sv = build_statevector(&plan.tail_accel, seed);
986    sv.init_from_state(state, plan.tail_num_classical_bits)?;
987    for inst in &plan.fused_tail.instructions {
988        sv.apply(inst)?;
989    }
990
991    let probabilities = if want_probabilities {
992        try_backend_probabilities(&sv)?
993    } else {
994        None
995    };
996
997    Ok(RunOutcome {
998        classical_bits: sv.classical_results().to_vec(),
999        probabilities,
1000        metadata: crate::sim::backend_metadata(&sv),
1001    })
1002}
1003
1004#[cfg(all(test, feature = "gpu"))]
1005mod gpu_crossover_tests {
1006    use super::*;
1007    use crate::gates::Gate;
1008
1009    fn stub_kind() -> BackendKind {
1010        BackendKind::StatevectorGpu {
1011            context: GpuContext::stub_for_tests(),
1012        }
1013    }
1014
1015    fn run_query(kind: BackendKind, circuit: &Circuit, seed: u64) -> Result<RunOutcome> {
1016        crate::sim::simulate(circuit).backend(kind).seed(seed).run()
1017    }
1018
1019    // The builder GPU shortcut must compose identically to constructing the
1020    // variant manually. Uses the stub context at a small circuit so crossover
1021    // fires and proves the composition is side-effect equivalent.
1022    #[test]
1023    fn builder_gpu_wraps_statevector_gpu_variant() {
1024        let ctx = GpuContext::stub_for_tests();
1025        let mut circuit = Circuit::new(4, 0);
1026        circuit.add_gate(Gate::H, &[0]);
1027        circuit.add_gate(Gate::Cx, &[0, 1]);
1028
1029        let direct = crate::sim::simulate(&circuit)
1030            .gpu(ctx.clone())
1031            .seed(42)
1032            .run()
1033            .expect("builder GPU shortcut must honor crossover and route to CPU");
1034        let manual = crate::sim::simulate(&circuit)
1035            .backend(stub_kind())
1036            .seed(42)
1037            .run()
1038            .expect("manual variant reference");
1039
1040        let dp = direct.probabilities.expect("direct probs").to_vec();
1041        let mp = manual.probabilities.expect("manual probs").to_vec();
1042        assert_eq!(dp, mp);
1043    }
1044
1045    // A 4q circuit is far below the default 14q threshold. If the dispatch
1046    // layer were to build a GPU backend anyway, `GpuState::new` on the stub
1047    // context would return `BackendUnsupported`. Success proves the
1048    // crossover in `select_dispatch` is routing small circuits to the host
1049    // path.
1050    #[test]
1051    fn small_circuit_routes_to_cpu() {
1052        let mut circuit = Circuit::new(4, 0);
1053        circuit.add_gate(Gate::H, &[0]);
1054        circuit.add_gate(Gate::Cx, &[0, 1]);
1055        circuit.add_gate(Gate::H, &[2]);
1056        circuit.add_gate(Gate::Cx, &[2, 3]);
1057
1058        let result = run_query(stub_kind(), &circuit, 42)
1059            .expect("stub context must not be touched for a 4q circuit");
1060        let probs = result
1061            .probabilities
1062            .expect("probabilities missing")
1063            .to_vec();
1064
1065        let mut expected = [0.0_f64; 16];
1066        expected[0b0000] = 0.25;
1067        expected[0b0011] = 0.25;
1068        expected[0b1100] = 0.25;
1069        expected[0b1111] = 0.25;
1070        for (i, (p, e)) in probs.iter().zip(&expected).enumerate() {
1071            assert!((p - e).abs() < 1e-10, "p[{i}] = {p}, expected {e}");
1072        }
1073    }
1074
1075    // `independent_bell_pairs(8)` spans 16 qubits but decomposes into 8
1076    // independent 2q blocks. With `BackendKind::StatevectorGpu`, each
1077    // sub-block is below the 14q threshold and must route to CPU. If
1078    // decomposition failed to fire, the 16q monolithic path would attempt
1079    // `GpuState::new` through the stub and return `BackendUnsupported`.
1080    // Success here proves decomposition survives across the GPU dispatch.
1081    #[test]
1082    fn decomposable_16q_circuit_runs_per_block_on_cpu() {
1083        let circuit = crate::circuits::independent_bell_pairs(8);
1084        assert_eq!(circuit.num_qubits, 16);
1085
1086        let cpu = run_query(BackendKind::Statevector, &circuit, 42).expect("cpu baseline");
1087        let gpu = run_query(stub_kind(), &circuit, 42).expect("stub must stay out of the way");
1088
1089        let cpu_p = cpu.probabilities.expect("cpu probs").to_vec();
1090        let gpu_p = gpu.probabilities.expect("gpu probs").to_vec();
1091        assert_eq!(cpu_p.len(), gpu_p.len());
1092        for (i, (c, g)) in cpu_p.iter().zip(gpu_p.iter()).enumerate() {
1093            assert!(
1094                (c - g).abs() < 1e-10,
1095                "prob[{i}] cpu={c}, gpu={g}, diff={}",
1096                (c - g).abs()
1097            );
1098        }
1099    }
1100
1101    fn stabilizer_stub_kind() -> BackendKind {
1102        BackendKind::StabilizerGpu {
1103            context: GpuContext::stub_for_tests(),
1104        }
1105    }
1106
1107    // A 4q Clifford circuit is far below the stabilizer GPU threshold, so the
1108    // stub context must never be touched. Produces the same measurement bits
1109    // as a plain CPU stabilizer run.
1110    #[test]
1111    fn stabilizer_gpu_small_circuit_routes_to_cpu() {
1112        let mut circuit = Circuit::new(4, 4);
1113        circuit.add_gate(Gate::H, &[0]);
1114        circuit.add_gate(Gate::Cx, &[0, 1]);
1115        circuit.add_gate(Gate::Cx, &[1, 2]);
1116        circuit.add_gate(Gate::Cx, &[2, 3]);
1117        circuit.add_measure(0, 0);
1118        circuit.add_measure(1, 1);
1119        circuit.add_measure(2, 2);
1120        circuit.add_measure(3, 3);
1121
1122        let cpu_run = run_query(BackendKind::Stabilizer, &circuit, 42).expect("cpu baseline");
1123        let gpu_run = run_query(stabilizer_stub_kind(), &circuit, 42)
1124            .expect("stub must stay out of the way for small circuits");
1125        assert_eq!(cpu_run.classical_bits, gpu_run.classical_bits);
1126    }
1127
1128    // Non-Clifford circuits are rejected at dispatch time with the same error
1129    // shape as `BackendKind::Stabilizer`.
1130    #[test]
1131    fn stabilizer_gpu_rejects_non_clifford_at_dispatch() {
1132        let mut circuit = Circuit::new(2, 0);
1133        circuit.add_gate(Gate::T, &[0]);
1134        let err = run_query(stabilizer_stub_kind(), &circuit, 42).unwrap_err();
1135        assert!(matches!(err, PrismError::IncompatibleBackend { .. }));
1136    }
1137
1138    fn auto_gpu_stub_kind() -> BackendKind {
1139        BackendKind::AutoGpu {
1140            context: GpuContext::stub_for_tests(),
1141        }
1142    }
1143
1144    fn assert_probs_match(a: &RunOutcome, b: &RunOutcome) {
1145        let ap = a.probabilities.as_ref().expect("probs a").to_vec();
1146        let bp = b.probabilities.as_ref().expect("probs b").to_vec();
1147        assert_eq!(ap.len(), bp.len());
1148        for (i, (x, y)) in ap.iter().zip(bp.iter()).enumerate() {
1149            assert!((x - y).abs() < 1e-10, "prob[{i}]: {x} vs {y}");
1150        }
1151    }
1152
1153    // A small Clifford circuit selects the stabilizer choice, which sits below
1154    // the stabilizer GPU crossover, so `AutoGpu` builds a CPU stabilizer and
1155    // never touches the stub. Results match the plain `Auto` path.
1156    #[test]
1157    fn auto_gpu_small_clifford_routes_to_cpu() {
1158        let mut circuit = Circuit::new(4, 0);
1159        circuit.add_gate(Gate::H, &[0]);
1160        circuit.add_gate(Gate::Cx, &[0, 1]);
1161        circuit.add_gate(Gate::Cx, &[1, 2]);
1162        circuit.add_gate(Gate::Cx, &[2, 3]);
1163
1164        let cpu = run_query(BackendKind::Auto, &circuit, 42).expect("cpu auto baseline");
1165        let gpu = run_query(auto_gpu_stub_kind(), &circuit, 42)
1166            .expect("stub must stay out of the way below the stabilizer crossover");
1167        assert_probs_match(&cpu, &gpu);
1168    }
1169
1170    // `independent_bell_pairs(8)` spans 16 qubits but decomposes into 8
1171    // independent 2q blocks, each below the statevector crossover. Every block
1172    // stays on CPU under `AutoGpu`; if decomposition failed, the monolithic 16q
1173    // path would still hit the VRAM gate and fall back rather than error.
1174    #[test]
1175    fn auto_gpu_decomposable_16q_runs_per_block_on_cpu() {
1176        let circuit = crate::circuits::independent_bell_pairs(8);
1177        assert_eq!(circuit.num_qubits, 16);
1178
1179        let cpu = run_query(BackendKind::Auto, &circuit, 42).expect("cpu auto baseline");
1180        let gpu = run_query(auto_gpu_stub_kind(), &circuit, 42).expect("stub stays out of the way");
1181        assert_probs_match(&cpu, &gpu);
1182    }
1183
1184    // A 16q entangled non-Clifford circuit selects the statevector choice and
1185    // clears the 14q crossover, so `AutoGpu` reaches the GPU decision. Because
1186    // the stub cannot report VRAM, the fits check fails closed and the block
1187    // runs on CPU: same results as `Auto`, no error. The explicit
1188    // `StatevectorGpu` path has no VRAM gate, so the same circuit touches the
1189    // stub and surfaces `BackendUnsupported`, isolating the added fallback.
1190    #[test]
1191    fn auto_gpu_large_block_falls_back_to_cpu_on_stub() {
1192        let mut circuit = Circuit::new(16, 0);
1193        for q in 0..16 {
1194            circuit.add_gate(Gate::Rx(0.3), &[q]);
1195        }
1196        for q in 0..15 {
1197            circuit.add_gate(Gate::Cx, &[q, q + 1]);
1198        }
1199
1200        let cpu = run_query(BackendKind::Auto, &circuit, 42).expect("cpu auto baseline");
1201        let gpu = run_query(auto_gpu_stub_kind(), &circuit, 42)
1202            .expect("stub VRAM query fails closed, so AutoGpu must fall back to CPU without error");
1203        assert_probs_match(&cpu, &gpu);
1204
1205        let explicit = BackendKind::StatevectorGpu {
1206            context: GpuContext::stub_for_tests(),
1207        };
1208        assert!(matches!(
1209            run_query(explicit, &circuit, 42).unwrap_err(),
1210            PrismError::BackendUnsupported { .. }
1211        ));
1212    }
1213}
1214
1215#[cfg(all(test, feature = "gpu"))]
1216mod accel_tests {
1217    use super::*;
1218
1219    fn stub() -> Arc<GpuContext> {
1220        GpuContext::stub_for_tests()
1221    }
1222
1223    fn is_cpu(accel: &Accel) -> bool {
1224        matches!(accel, Accel::Cpu)
1225    }
1226
1227    #[test]
1228    fn auto_gpu_statevector_below_crossover_is_cpu() {
1229        let kind = BackendKind::AutoGpu { context: stub() };
1230        let n = crate::gpu::min_qubits() - 1;
1231        assert!(is_cpu(&accel_for(&kind, Family::Statevector, n)));
1232    }
1233
1234    // Above the crossover the stub cannot report VRAM, so the fit gate fails
1235    // closed and the soft path resolves to the host.
1236    #[test]
1237    fn auto_gpu_statevector_fits_fails_closed_on_stub() {
1238        let kind = BackendKind::AutoGpu { context: stub() };
1239        let n = crate::gpu::min_qubits() + 2;
1240        assert!(is_cpu(&accel_for(&kind, Family::Statevector, n)));
1241    }
1242
1243    #[test]
1244    fn auto_gpu_cpu_only_families_stay_cpu() {
1245        let kind = BackendKind::AutoGpu { context: stub() };
1246        for family in [
1247            Family::ProductState,
1248            Family::Sparse,
1249            Family::Mps,
1250            Family::Factored,
1251            Family::FactoredStabilizer,
1252            Family::TensorNetwork,
1253        ] {
1254            assert!(is_cpu(&accel_for(&kind, family, 1 << 10)), "{family:?}");
1255        }
1256    }
1257
1258    #[test]
1259    fn explicit_statevector_gpu_is_hard_at_crossover_and_cpu_below() {
1260        let kind = BackendKind::StatevectorGpu { context: stub() };
1261        let n = crate::gpu::min_qubits();
1262        assert!(matches!(
1263            accel_for(&kind, Family::Statevector, n),
1264            Accel::Gpu { soft: false, .. }
1265        ));
1266        assert!(is_cpu(&accel_for(&kind, Family::Statevector, n - 1)));
1267    }
1268
1269    #[test]
1270    fn explicit_stabilizer_gpu_is_hard_at_crossover_and_cpu_below() {
1271        let kind = BackendKind::StabilizerGpu { context: stub() };
1272        let n = crate::gpu::stabilizer_min_qubits();
1273        assert!(matches!(
1274            accel_for(&kind, Family::Stabilizer, n),
1275            Accel::Gpu { soft: false, .. }
1276        ));
1277        assert!(is_cpu(&accel_for(&kind, Family::Stabilizer, n - 1)));
1278    }
1279
1280    // An explicit GPU kind accelerates only its own family; any other family
1281    // resolves to the host regardless of size.
1282    #[test]
1283    fn explicit_kind_other_family_is_cpu() {
1284        let sv = BackendKind::StatevectorGpu { context: stub() };
1285        assert!(is_cpu(&accel_for(&sv, Family::Stabilizer, 1 << 20)));
1286        let stab = BackendKind::StabilizerGpu { context: stub() };
1287        assert!(is_cpu(&accel_for(&stab, Family::Statevector, 1 << 20)));
1288    }
1289
1290    #[test]
1291    fn cpu_kinds_are_cpu_everywhere() {
1292        for family in [Family::Statevector, Family::Stabilizer] {
1293            assert!(is_cpu(&accel_for(&BackendKind::Auto, family, 1 << 20)));
1294            assert!(is_cpu(&accel_for(
1295                &BackendKind::Statevector,
1296                family,
1297                1 << 20
1298            )));
1299        }
1300    }
1301
1302    // `init_from_state` on a soft GPU backend degrades to the host when the
1303    // device upload fails, preserving the supplied amplitudes.
1304    #[test]
1305    fn init_from_state_soft_falls_back_to_host_on_stub() {
1306        use num_complex::Complex64;
1307        let mut sv = StatevectorBackend::new(42).with_gpu_auto(stub());
1308        let amp = Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0);
1309        let state = vec![amp, Complex64::new(0.0, 0.0), Complex64::new(0.0, 0.0), amp];
1310        sv.init_from_state(state.clone(), 0).unwrap();
1311        let exported = sv.export_statevector().unwrap();
1312        for (e, s) in exported.iter().zip(&state) {
1313            assert!((e - s).norm() < 1e-12);
1314        }
1315    }
1316
1317    #[test]
1318    fn init_from_state_hard_errors_on_stub() {
1319        use num_complex::Complex64;
1320        let mut sv = StatevectorBackend::new(42).with_gpu(stub());
1321        let err = sv
1322            .init_from_state(vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)], 0)
1323            .unwrap_err();
1324        assert!(matches!(err, PrismError::BackendUnsupported { .. }));
1325    }
1326}
1327
1328/// Table-driven coverage of [`resolve`]: every backend kind against every
1329/// circuit shape class, asserting the resolved family and execution target.
1330/// This is the contract that all simulator families dispatch uniformly on
1331/// both targets; extend it when a family gains a capability row.
1332#[cfg(test)]
1333mod dispatch_matrix_tests {
1334    use super::*;
1335    use crate::gates::Gate;
1336
1337    fn product(n: usize) -> Circuit {
1338        let mut c = Circuit::new(n, 0);
1339        for q in 0..n {
1340            c.add_gate(Gate::Rx(0.3), &[q]);
1341        }
1342        c
1343    }
1344
1345    fn clifford(n: usize) -> Circuit {
1346        let mut c = Circuit::new(n, 0);
1347        c.add_gate(Gate::H, &[0]);
1348        for q in 0..n - 1 {
1349            c.add_gate(Gate::Cx, &[q, q + 1]);
1350        }
1351        c
1352    }
1353
1354    fn dense(n: usize) -> Circuit {
1355        let mut c = Circuit::new(n, 0);
1356        for q in 0..n {
1357            c.add_gate(Gate::Rx(0.3), &[q]);
1358        }
1359        for q in 0..n - 1 {
1360            c.add_gate(Gate::Cx, &[q, q + 1]);
1361        }
1362        c
1363    }
1364
1365    /// One qubit past the statevector memory cap, or `None` when memory
1366    /// detection failed and the cap is disabled (no width is oversize then).
1367    fn oversize_qubits() -> Option<usize> {
1368        let cap = max_statevector_qubits();
1369        if cap >= usize::BITS as usize {
1370            eprintln!(
1371                "SKIP: statevector qubit cap disabled on this host; skipping oversize checks"
1372            );
1373            return None;
1374        }
1375        Some(cap + 1)
1376    }
1377
1378    fn oversize_sparse(n: usize) -> Circuit {
1379        let mut c = Circuit::new(n, 0);
1380        for q in 0..n {
1381            c.add_gate(Gate::T, &[q]);
1382        }
1383        for q in 0..n - 1 {
1384            c.add_gate(Gate::Cx, &[q, q + 1]);
1385        }
1386        c
1387    }
1388
1389    fn oversize_dense(n: usize) -> Circuit {
1390        let mut c = Circuit::new(n, 0);
1391        for q in 0..n {
1392            c.add_gate(Gate::Rx(0.3), &[q]);
1393        }
1394        for q in 0..n - 1 {
1395            c.add_gate(Gate::Cx, &[q, q + 1]);
1396        }
1397        c
1398    }
1399
1400    fn resolved(kind: &BackendKind, circuit: &Circuit, hpi: bool) -> BackendPlan {
1401        match resolve(kind, circuit, hpi) {
1402            ExecutionPlan::Backend(plan) => plan,
1403            _ => panic!("expected a backend plan"),
1404        }
1405    }
1406
1407    fn assert_cpu_family(kind: &BackendKind, circuit: &Circuit, hpi: bool, family: Family) {
1408        let plan = resolved(kind, circuit, hpi);
1409        assert_eq!(plan.family(), family, "kind {kind:?}");
1410        assert!(
1411            matches!(plan.accel(), Accel::Cpu),
1412            "kind {kind:?} family {family:?} must resolve to the host"
1413        );
1414    }
1415
1416    fn auto_kinds() -> Vec<BackendKind> {
1417        #[cfg(feature = "gpu")]
1418        {
1419            vec![
1420                BackendKind::Auto,
1421                BackendKind::AutoGpu {
1422                    context: GpuContext::stub_for_tests(),
1423                },
1424            ]
1425        }
1426        #[cfg(not(feature = "gpu"))]
1427        {
1428            vec![BackendKind::Auto]
1429        }
1430    }
1431
1432    // Auto and AutoGpu make identical family choices across every circuit
1433    // shape class; on the stub context every choice resolves to the host
1434    // (small circuits by crossover, large ones by the fail-closed VRAM gate).
1435    #[test]
1436    fn auto_family_matrix() {
1437        let oversize = oversize_qubits();
1438        for kind in auto_kinds() {
1439            assert_cpu_family(&kind, &product(6), false, Family::ProductState);
1440            assert_cpu_family(&kind, &clifford(6), false, Family::Stabilizer);
1441            assert_cpu_family(&kind, &clifford(16), false, Family::Stabilizer);
1442            assert_cpu_family(&kind, &dense(8), false, Family::Statevector);
1443            assert_cpu_family(&kind, &dense(16), false, Family::Statevector);
1444            assert_cpu_family(&kind, &dense(8), true, Family::Factored);
1445            if let Some(n) = oversize {
1446                assert_cpu_family(&kind, &oversize_sparse(n), false, Family::Sparse);
1447                assert_cpu_family(&kind, &oversize_dense(n), false, Family::Mps);
1448            }
1449        }
1450    }
1451
1452    #[test]
1453    fn auto_oversize_mps_uses_auto_bond_dim() {
1454        let Some(n) = oversize_qubits() else { return };
1455        for kind in auto_kinds() {
1456            let plan = resolved(&kind, &oversize_dense(n), false);
1457            assert!(matches!(
1458                plan,
1459                BackendPlan::Mps {
1460                    max_bond_dim: AUTO_MPS_BOND_DIM
1461                }
1462            ));
1463        }
1464    }
1465
1466    // Explicit CPU kinds map 1:1 onto their family regardless of circuit
1467    // shape, always on the host.
1468    #[test]
1469    fn explicit_cpu_kind_matrix() {
1470        let circuit = dense(6);
1471        let cases = [
1472            (BackendKind::Statevector, Family::Statevector),
1473            (BackendKind::Stabilizer, Family::Stabilizer),
1474            (BackendKind::Sparse, Family::Sparse),
1475            (BackendKind::ProductState, Family::ProductState),
1476            (BackendKind::TensorNetwork, Family::TensorNetwork),
1477            (BackendKind::Factored, Family::Factored),
1478            (BackendKind::FactoredStabilizer, Family::FactoredStabilizer),
1479        ];
1480        for (kind, family) in cases {
1481            assert_cpu_family(&kind, &circuit, false, family);
1482        }
1483
1484        let plan = resolved(&BackendKind::Mps { max_bond_dim: 77 }, &circuit, false);
1485        assert!(matches!(plan, BackendPlan::Mps { max_bond_dim: 77 }));
1486    }
1487
1488    #[test]
1489    fn non_backend_kinds_resolve_to_their_engines() {
1490        let circuit = dense(6);
1491        assert!(matches!(
1492            resolve(&BackendKind::StabilizerRank, &circuit, false),
1493            ExecutionPlan::StabilizerRank
1494        ));
1495        assert!(matches!(
1496            resolve(
1497                &BackendKind::StochasticPauli { num_samples: 9 },
1498                &circuit,
1499                false
1500            ),
1501            ExecutionPlan::StochasticPauli { num_samples: 9 }
1502        ));
1503        assert!(matches!(
1504            resolve(
1505                &BackendKind::DeterministicPauli {
1506                    epsilon: 0.5,
1507                    max_terms: 3
1508                },
1509                &circuit,
1510                false
1511            ),
1512            ExecutionPlan::DeterministicPauli {
1513                epsilon: e,
1514                max_terms: 3
1515            } if e == 0.5
1516        ));
1517    }
1518
1519    #[test]
1520    fn density_matrix_is_explicit_only() {
1521        // Auto never routes to the density-matrix family, whatever the shape.
1522        for circuit in [product(6), clifford(6), dense(6)] {
1523            for hpi in [false, true] {
1524                assert_ne!(
1525                    resolved(&BackendKind::Auto, &circuit, hpi).family(),
1526                    Family::DensityMatrix
1527                );
1528            }
1529        }
1530        // Explicit selection maps 1:1 onto the density-matrix plan, and the
1531        // backend it builds accepts fused payloads on every terminal.
1532        let plan = resolved(&BackendKind::DensityMatrix, &dense(6), false);
1533        assert_eq!(plan.family(), Family::DensityMatrix);
1534        assert!(plan.build(42).supports_fused_gates());
1535        assert!(matches!(plan.accel(), Accel::Cpu));
1536    }
1537
1538    #[test]
1539    fn density_matrix_rejects_over_cap() {
1540        let cap = max_density_matrix_qubits();
1541        if cap >= usize::BITS as usize {
1542            eprintln!("SKIP: density-matrix cap disabled on this host");
1543            return;
1544        }
1545        let circuit = dense(cap + 1);
1546        let err = validate_explicit_backend(&BackendKind::DensityMatrix, &circuit).unwrap_err();
1547        assert!(matches!(err, PrismError::IncompatibleBackend { .. }));
1548        // At or below the cap the same shape validates.
1549        assert!(validate_explicit_backend(&BackendKind::DensityMatrix, &dense(cap)).is_ok());
1550    }
1551
1552    // Explicit GPU kinds resolve hard device execution at their crossover
1553    // (no VRAM gate) and host execution below it; the family choice never
1554    // changes with the target.
1555    #[cfg(feature = "gpu")]
1556    #[test]
1557    fn explicit_gpu_kind_matrix() {
1558        let sv_kind = BackendKind::StatevectorGpu {
1559            context: GpuContext::stub_for_tests(),
1560        };
1561        let large = dense(crate::gpu::min_qubits());
1562        let plan = resolved(&sv_kind, &large, false);
1563        assert_eq!(plan.family(), Family::Statevector);
1564        assert!(matches!(plan.accel(), Accel::Gpu { soft: false, .. }));
1565        assert_cpu_family(
1566            &sv_kind,
1567            &dense(crate::gpu::min_qubits() - 1),
1568            false,
1569            Family::Statevector,
1570        );
1571
1572        let stab_kind = BackendKind::StabilizerGpu {
1573            context: GpuContext::stub_for_tests(),
1574        };
1575        let huge = Circuit::new(crate::gpu::stabilizer_min_qubits(), 0);
1576        let plan = resolved(&stab_kind, &huge, false);
1577        assert_eq!(plan.family(), Family::Stabilizer);
1578        assert!(matches!(plan.accel(), Accel::Gpu { soft: false, .. }));
1579        assert_cpu_family(&stab_kind, &clifford(8), false, Family::Stabilizer);
1580    }
1581
1582    // The auto probability route runs the exact stabilizer-rank expansion and
1583    // nothing else: at every width it admits, the size-derived budget sits at
1584    // or below the exact ceiling, so a T count that fits the budget fits the
1585    // ceiling. A budget change that breaks this re-opens the question of a
1586    // pruned route under Auto.
1587    #[test]
1588    fn auto_stabilizer_rank_budget_stays_inside_the_exact_ceiling() {
1589        for n in 1..=MAX_STABILIZER_RANK_QUBITS {
1590            let budget = stabilizer_rank_budget(n);
1591            assert!(
1592                budget <= MAX_AUTO_T_COUNT_EXACT,
1593                "width {n}: budget {budget} exceeds the exact ceiling {MAX_AUTO_T_COUNT_EXACT}"
1594            );
1595        }
1596    }
1597}