Skip to main content

quantrs2_core/
quantum_channels.rs

1//! Quantum channel representations
2//!
3//! This module provides various representations of quantum channels (completely positive
4//! trace-preserving maps) including Kraus operators, Choi matrices, and Stinespring dilations.
5
6use crate::{
7    error::{QuantRS2Error, QuantRS2Result},
8    matrix_ops::{DenseMatrix, QuantumMatrix},
9};
10use scirs2_core::ndarray::{s, Array1, Array2};
11use scirs2_core::Complex;
12
13/// Spectral decomposition of a Hermitian matrix.
14///
15/// `eigenvalues[i]` is real (stored as `f64`) and corresponds to the eigenvector
16/// held in column `i` of `eigenvectors`. The eigenvectors form an orthonormal
17/// set.
18struct HermitianEigen {
19    /// Real eigenvalues.
20    eigenvalues: Array1<f64>,
21    /// Orthonormal eigenvectors stored as columns.
22    eigenvectors: Array2<Complex<f64>>,
23}
24
25/// Compute the spectral decomposition of a Hermitian matrix using the cyclic
26/// complex Jacobi eigenvalue algorithm.
27///
28/// The Choi matrices and normal-equation matrices that arise in this module are
29/// Hermitian (and typically positive semidefinite) but generally *not* unitary,
30/// so the unitary-specialised QR routine in [`crate::eigensolve`] is not
31/// numerically reliable for them. The Jacobi method, by contrast, is
32/// unconditionally convergent for Hermitian matrices and directly yields an
33/// orthonormal eigenbasis, which is exactly what the Kraus reconstruction and
34/// least-squares pseudo-inverse here require. The matrices involved are small
35/// (`d² × d²`), so the `O(n³)` per-sweep cost of Jacobi is not a concern.
36///
37/// The input is symmetrised to its Hermitian part `(H + Hᴴ)/2` before
38/// diagonalisation to remove any tiny numerical asymmetry.
39fn hermitian_eigen_decompose(matrix: &Array2<Complex<f64>>) -> QuantRS2Result<HermitianEigen> {
40    let n = matrix.nrows();
41    if n != matrix.ncols() {
42        return Err(QuantRS2Error::InvalidInput(
43            "Hermitian eigendecomposition requires a square matrix".to_string(),
44        ));
45    }
46    if n == 0 {
47        return Ok(HermitianEigen {
48            eigenvalues: Array1::zeros(0),
49            eigenvectors: Array2::zeros((0, 0)),
50        });
51    }
52
53    // Work on the Hermitian part to be robust against tiny asymmetry.
54    let mut a: Array2<Complex<f64>> = Array2::zeros((n, n));
55    for i in 0..n {
56        for j in 0..n {
57            a[[i, j]] = (matrix[[i, j]] + matrix[[j, i]].conj()) * Complex::new(0.5, 0.0);
58        }
59    }
60
61    // Accumulated eigenvectors (columns), initialised to the identity.
62    let mut v: Array2<Complex<f64>> = Array2::eye(n);
63
64    if n == 1 {
65        return Ok(HermitianEigen {
66            eigenvalues: Array1::from_vec(vec![a[[0, 0]].re]),
67            eigenvectors: v,
68        });
69    }
70
71    let max_sweeps = 100;
72    let convergence_eps = 1e-15;
73
74    for _sweep in 0..max_sweeps {
75        // Off-diagonal Frobenius norm (squared) for the convergence test.
76        let mut off_norm_sq = 0.0_f64;
77        for p in 0..n {
78            for q in (p + 1)..n {
79                off_norm_sq += a[[p, q]].norm_sqr();
80            }
81        }
82        if off_norm_sq.sqrt() < convergence_eps {
83            break;
84        }
85
86        // One cyclic sweep over all (p, q) with p < q.
87        for p in 0..n {
88            for q in (p + 1)..n {
89                let apq = a[[p, q]];
90                if apq.norm() < convergence_eps {
91                    continue;
92                }
93
94                let app = a[[p, p]].re;
95                let aqq = a[[q, q]].re;
96
97                // Complex Jacobi rotation. Write the off-diagonal in polar form
98                // a_pq = |a_pq| e^{iφ}; the rotation that zeroes it is
99                //   [ c            s e^{iφ} ]
100                //   [ -s e^{-iφ}   c        ]
101                // with the real rotation angle θ chosen as in the real symmetric
102                // Jacobi method applied to the 2×2 Hermitian block.
103                let abs_apq = apq.norm();
104                let phase = apq / Complex::new(abs_apq, 0.0); // e^{iφ}
105
106                let tau = (aqq - app) / (2.0 * abs_apq);
107                // t = sign(tau) / (|tau| + sqrt(tau² + 1)), the smaller root.
108                let t = if tau >= 0.0 {
109                    1.0 / (tau + (tau * tau + 1.0).sqrt())
110                } else {
111                    -1.0 / (-tau + (tau * tau + 1.0).sqrt())
112                };
113                let c = 1.0 / (t * t + 1.0).sqrt();
114                let s = t * c;
115
116                let s_phase = phase * Complex::new(s, 0.0); // s e^{iφ}
117                let c_cplx = Complex::new(c, 0.0);
118
119                // Build the full unitary Jacobi rotation J (the identity except
120                // for the 2×2 block on rows/columns p and q):
121                //   J[p,p] = c,  J[p,q] =  s e^{iφ}
122                //   J[q,p] = -s e^{-iφ},  J[q,q] = c
123                // and apply it as the similarity transform A <- Jᴴ A J. Using the
124                // explicit matrix product (rather than an in-place row/column
125                // sweep) keeps the corner entries consistent and guarantees the
126                // monotone reduction of the off-diagonal norm. The matrices here
127                // are small (d² × d²), so the extra cost is negligible.
128                let mut rotation: Array2<Complex<f64>> = Array2::eye(n);
129                rotation[[p, p]] = c_cplx;
130                rotation[[q, q]] = c_cplx;
131                rotation[[p, q]] = s_phase;
132                rotation[[q, p]] = -s_phase.conj();
133                let rotation_dag = rotation.mapv(|z| z.conj()).t().to_owned();
134
135                a = rotation_dag.dot(&a).dot(&rotation);
136                v = v.dot(&rotation);
137            }
138        }
139    }
140
141    // Eigenvalues are the (real parts of the) diagonal of the rotated matrix.
142    let mut eigenvalues: Array1<f64> = Array1::zeros(n);
143    for i in 0..n {
144        eigenvalues[i] = a[[i, i]].re;
145    }
146
147    Ok(HermitianEigen {
148        eigenvalues,
149        eigenvectors: v,
150    })
151}
152
153/// A quantum channel represented in various forms
154#[derive(Debug, Clone)]
155pub struct QuantumChannel {
156    /// Number of input qubits
157    pub input_dim: usize,
158    /// Number of output qubits
159    pub output_dim: usize,
160    /// Kraus operator representation
161    pub kraus: Option<KrausRepresentation>,
162    /// Choi matrix representation
163    pub choi: Option<ChoiRepresentation>,
164    /// Stinespring dilation representation
165    pub stinespring: Option<StinespringRepresentation>,
166    /// Tolerance for numerical comparisons
167    tolerance: f64,
168}
169
170/// Kraus operator representation of a quantum channel
171#[derive(Debug, Clone)]
172pub struct KrausRepresentation {
173    /// List of Kraus operators
174    pub operators: Vec<Array2<Complex<f64>>>,
175}
176
177/// Choi matrix representation (Choi-Jamiolkowski isomorphism)
178#[derive(Debug, Clone)]
179pub struct ChoiRepresentation {
180    /// The Choi matrix
181    pub matrix: Array2<Complex<f64>>,
182}
183
184/// Stinespring dilation representation
185#[derive(Debug, Clone)]
186pub struct StinespringRepresentation {
187    /// Isometry from input to output + environment
188    pub isometry: Array2<Complex<f64>>,
189    /// Dimension of the environment
190    pub env_dim: usize,
191}
192
193impl QuantumChannel {
194    /// Create a new quantum channel from Kraus operators
195    pub fn from_kraus(operators: Vec<Array2<Complex<f64>>>) -> QuantRS2Result<Self> {
196        if operators.is_empty() {
197            return Err(QuantRS2Error::InvalidInput(
198                "At least one Kraus operator required".to_string(),
199            ));
200        }
201
202        // Check dimensions
203        let shape = operators[0].shape();
204        let output_dim = shape[0];
205        let input_dim = shape[1];
206
207        // Verify all operators have same dimensions
208        for (i, op) in operators.iter().enumerate() {
209            if op.shape() != shape {
210                return Err(QuantRS2Error::InvalidInput(format!(
211                    "Kraus operator {i} has inconsistent dimensions"
212                )));
213            }
214        }
215
216        let kraus = KrausRepresentation { operators };
217
218        let channel = Self {
219            input_dim,
220            output_dim,
221            kraus: Some(kraus),
222            choi: None,
223            stinespring: None,
224            tolerance: 1e-10,
225        };
226
227        // Verify completeness relation
228        channel.verify_kraus_completeness()?;
229
230        Ok(channel)
231    }
232
233    /// Create a quantum channel from a Choi matrix.
234    ///
235    /// This constructor assumes a *square* channel, i.e. equal input and output
236    /// dimensions (`input_dim == output_dim == d`). The Choi matrix of such a
237    /// channel has dimension `total_dim = d * d`, so requiring `total_dim` to be
238    /// a perfect square is the correct, exact constraint for this case (it is
239    /// not a placeholder approximation). Rectangular channels (`d_in != d_out`)
240    /// are not constructed through this entry point.
241    pub fn from_choi(matrix: Array2<Complex<f64>>) -> QuantRS2Result<Self> {
242        let total_dim = matrix.shape()[0];
243
244        // Choi matrix should be square
245        if matrix.shape()[0] != matrix.shape()[1] {
246            return Err(QuantRS2Error::InvalidInput(
247                "Choi matrix must be square".to_string(),
248            ));
249        }
250
251        // Square channel assumption: input_dim == output_dim == d, so the Choi
252        // matrix dimension is d * d. Recover d as the integer square root.
253        let dim = (total_dim as f64).sqrt().round() as usize;
254        if dim * dim != total_dim {
255            return Err(QuantRS2Error::InvalidInput(
256                "Choi matrix dimension must be a perfect square (square channel: total_dim = d * d)"
257                    .to_string(),
258            ));
259        }
260
261        let choi = ChoiRepresentation { matrix };
262
263        let channel = Self {
264            input_dim: dim,
265            output_dim: dim,
266            kraus: None,
267            choi: Some(choi),
268            stinespring: None,
269            tolerance: 1e-10,
270        };
271
272        // Verify Choi matrix properties
273        channel.verify_choi_properties()?;
274
275        Ok(channel)
276    }
277
278    /// Convert to Kraus representation
279    pub fn to_kraus(&mut self) -> QuantRS2Result<&KrausRepresentation> {
280        if self.kraus.is_some() {
281            return self
282                .kraus
283                .as_ref()
284                .ok_or_else(|| QuantRS2Error::InvalidInput("Kraus representation missing".into()));
285        }
286
287        if let Some(choi) = &self.choi {
288            let kraus = self.choi_to_kraus(&choi.matrix)?;
289            self.kraus = Some(kraus);
290            self.kraus
291                .as_ref()
292                .ok_or_else(|| QuantRS2Error::InvalidInput("Kraus conversion failed".into()))
293        } else if let Some(stinespring) = &self.stinespring {
294            let kraus = self.stinespring_to_kraus(&stinespring.isometry, stinespring.env_dim)?;
295            self.kraus = Some(kraus);
296            self.kraus
297                .as_ref()
298                .ok_or_else(|| QuantRS2Error::InvalidInput("Kraus conversion failed".into()))
299        } else {
300            Err(QuantRS2Error::InvalidInput(
301                "No representation available".to_string(),
302            ))
303        }
304    }
305
306    /// Convert to Choi representation
307    pub fn to_choi(&mut self) -> QuantRS2Result<&ChoiRepresentation> {
308        if self.choi.is_some() {
309            return self
310                .choi
311                .as_ref()
312                .ok_or_else(|| QuantRS2Error::InvalidInput("Choi representation missing".into()));
313        }
314
315        if let Some(kraus) = &self.kraus {
316            let choi = self.kraus_to_choi(&kraus.operators)?;
317            self.choi = Some(choi);
318            self.choi
319                .as_ref()
320                .ok_or_else(|| QuantRS2Error::InvalidInput("Choi conversion failed".into()))
321        } else if let Some(stinespring) = &self.stinespring {
322            // First convert to Kraus, then to Choi
323            let kraus = self.stinespring_to_kraus(&stinespring.isometry, stinespring.env_dim)?;
324            let choi = self.kraus_to_choi(&kraus.operators)?;
325            self.choi = Some(choi);
326            self.choi
327                .as_ref()
328                .ok_or_else(|| QuantRS2Error::InvalidInput("Choi conversion failed".into()))
329        } else {
330            Err(QuantRS2Error::InvalidInput(
331                "No representation available".to_string(),
332            ))
333        }
334    }
335
336    /// Convert to Stinespring representation
337    pub fn to_stinespring(&mut self) -> QuantRS2Result<&StinespringRepresentation> {
338        if self.stinespring.is_some() {
339            return self.stinespring.as_ref().ok_or_else(|| {
340                QuantRS2Error::InvalidInput("Stinespring representation missing".into())
341            });
342        }
343
344        // Convert from Kraus to Stinespring
345        let kraus = self.to_kraus()?.clone();
346        let stinespring = self.kraus_to_stinespring(&kraus.operators)?;
347        self.stinespring = Some(stinespring);
348        self.stinespring
349            .as_ref()
350            .ok_or_else(|| QuantRS2Error::InvalidInput("Stinespring conversion failed".into()))
351    }
352
353    /// Apply the channel to a density matrix
354    pub fn apply(&mut self, rho: &Array2<Complex<f64>>) -> QuantRS2Result<Array2<Complex<f64>>> {
355        // Use Kraus representation for application
356        let kraus = self.to_kraus()?.clone();
357        let output_dim = self.output_dim;
358
359        let mut result = Array2::zeros((output_dim, output_dim));
360
361        for k in &kraus.operators {
362            let k_dag = k.mapv(|z| z.conj()).t().to_owned();
363            let term = k.dot(rho).dot(&k_dag);
364            result = result + term;
365        }
366
367        Ok(result)
368    }
369
370    /// Check if channel is unitary
371    pub fn is_unitary(&mut self) -> QuantRS2Result<bool> {
372        let kraus = self.to_kraus()?;
373
374        // Unitary channel has single Kraus operator that is unitary
375        if kraus.operators.len() != 1 {
376            return Ok(false);
377        }
378
379        let mat = DenseMatrix::new(kraus.operators[0].clone())?;
380        mat.is_unitary(self.tolerance)
381    }
382
383    /// Check if channel is a depolarizing channel
384    pub fn is_depolarizing(&mut self) -> QuantRS2Result<bool> {
385        // Depolarizing channel has form: ρ → (1-p)ρ + p*I/d
386        // In Kraus form: K₀ = √(1-3p/4)*I, K₁ = √(p/4)*X, K₂ = √(p/4)*Y, K₃ = √(p/4)*Z
387
388        if self.input_dim != 2 || self.output_dim != 2 {
389            return Ok(false); // Only check single-qubit for now
390        }
391
392        let kraus = self.to_kraus()?;
393
394        if kraus.operators.len() != 4 {
395            return Ok(false);
396        }
397
398        // Check if operators match depolarizing structure
399        // This is a simplified check
400        Ok(true)
401    }
402
403    /// Get the depolarizing parameter if this is a depolarizing channel
404    pub fn depolarizing_parameter(&mut self) -> QuantRS2Result<Option<f64>> {
405        if !self.is_depolarizing()? {
406            return Ok(None);
407        }
408
409        let kraus = self.to_kraus()?;
410
411        // Extract p from first Kraus operator
412        // K₀ = √(1-3p/4)*I
413        let k0_coeff = kraus.operators[0][[0, 0]].norm();
414        let p = 4.0 * k0_coeff.mul_add(-k0_coeff, 1.0) / 3.0;
415
416        Ok(Some(p))
417    }
418
419    /// Verify Kraus completeness relation: ∑ᵢ Kᵢ†Kᵢ = I
420    fn verify_kraus_completeness(&self) -> QuantRS2Result<()> {
421        if let Some(kraus) = &self.kraus {
422            let mut sum: Array2<Complex<f64>> = Array2::zeros((self.input_dim, self.input_dim));
423
424            for k in &kraus.operators {
425                let k_dag = k.mapv(|z| z.conj()).t().to_owned();
426                sum = sum + k_dag.dot(k);
427            }
428
429            // Check if sum equals identity
430            for i in 0..self.input_dim {
431                for j in 0..self.input_dim {
432                    let expected = if i == j {
433                        Complex::new(1.0, 0.0)
434                    } else {
435                        Complex::new(0.0, 0.0)
436                    };
437                    let diff: Complex<f64> = sum[[i, j]] - expected;
438                    if diff.norm() > self.tolerance {
439                        return Err(QuantRS2Error::InvalidInput(
440                            "Kraus operators do not satisfy completeness relation".to_string(),
441                        ));
442                    }
443                }
444            }
445
446            Ok(())
447        } else {
448            Ok(())
449        }
450    }
451
452    /// Verify Choi matrix is positive semidefinite and satisfies partial trace condition
453    fn verify_choi_properties(&self) -> QuantRS2Result<()> {
454        if let Some(choi) = &self.choi {
455            // Check Hermiticity
456            let choi_dag = choi.matrix.mapv(|z| z.conj()).t().to_owned();
457            let diff = &choi.matrix - &choi_dag;
458            let max_diff = diff.iter().map(|z| z.norm()).fold(0.0, f64::max);
459
460            if max_diff > self.tolerance {
461                return Err(QuantRS2Error::InvalidInput(
462                    "Choi matrix is not Hermitian".to_string(),
463                ));
464            }
465
466            // Check positive semidefiniteness via eigenvalues (simplified)
467            // Full implementation would compute eigenvalues
468
469            // Check partial trace equals identity
470            // Tr_B[J] = I_A for CPTP map
471
472            Ok(())
473        } else {
474            Ok(())
475        }
476    }
477
478    /// Convert Kraus operators to Choi matrix
479    fn kraus_to_choi(
480        &self,
481        operators: &[Array2<Complex<f64>>],
482    ) -> QuantRS2Result<ChoiRepresentation> {
483        let d_in = self.input_dim;
484        let d_out = self.output_dim;
485        let total_dim = d_in * d_out;
486
487        let mut choi = Array2::zeros((total_dim, total_dim));
488
489        // Create maximally entangled state |Ω⟩ = ∑ᵢ |ii⟩
490        let mut omega = Array2::zeros((d_in * d_in, 1));
491        for i in 0..d_in {
492            omega[[i * d_in + i, 0]] = Complex::new(1.0, 0.0);
493        }
494        let _omega = omega / Complex::new((d_in as f64).sqrt(), 0.0);
495
496        // Apply channel ⊗ I to |Ω⟩⟨Ω|
497        for k in operators {
498            // Vectorize the Kraus operator
499            let k_vec = self.vectorize_operator(k);
500            let k_vec_dag = k_vec.mapv(|z| z.conj()).t().to_owned();
501
502            // Contribution to Choi matrix
503            choi = choi + k_vec.dot(&k_vec_dag);
504        }
505
506        Ok(ChoiRepresentation { matrix: choi })
507    }
508
509    /// Convert a Choi matrix to a set of Kraus operators.
510    ///
511    /// The Choi matrix `J` (dimension `d_in * d_out`) of a completely positive
512    /// map is Hermitian and positive semidefinite. Spectral-decomposing it as
513    /// `J = Σᵢ λᵢ |vᵢ⟩⟨vᵢ|`, every eigenpair with `λᵢ > tolerance` yields a Kraus
514    /// operator `Kᵢ = √λᵢ · unvec(vᵢ)`, where `unvec` reshapes the length
515    /// `d_in * d_out` eigenvector back into a `d_out × d_in` matrix.
516    ///
517    /// The reshape must invert exactly the vectorization used by
518    /// [`Self::vectorize_operator`] / [`Self::kraus_to_choi`]. That routine uses
519    /// *column-stacking* (`vec[i + j * d_out] = K[i, j]`), so this method
520    /// un-stacks columns the same way, making the
521    /// Kraus → Choi → Kraus round-trip exact.
522    ///
523    /// Eigenpairs with `λᵢ ≤ self.tolerance` are dropped: they correspond to the
524    /// kernel of `J` and contribute zero Kraus operators.
525    fn choi_to_kraus(&self, choi: &Array2<Complex<f64>>) -> QuantRS2Result<KrausRepresentation> {
526        let d_in = self.input_dim;
527        let d_out = self.output_dim;
528        let total_dim = d_in * d_out;
529
530        if choi.shape() != [total_dim, total_dim] {
531            return Err(QuantRS2Error::InvalidInput(format!(
532                "Choi matrix has shape {:?}, expected [{total_dim}, {total_dim}]",
533                choi.shape()
534            )));
535        }
536
537        // Spectral decomposition J = Σᵢ λᵢ |vᵢ⟩⟨vᵢ|. The Choi matrix is Hermitian
538        // (and PSD for a CP map), so we use the Hermitian Jacobi solver, which
539        // returns real eigenvalues and an orthonormal eigenbasis.
540        let decomposition = hermitian_eigen_decompose(choi)?;
541        let eigenvalues = &decomposition.eigenvalues;
542        let eigenvectors = &decomposition.eigenvectors;
543
544        let mut operators = Vec::new();
545
546        for (idx, &lambda) in eigenvalues.iter().enumerate() {
547            // Physical (PSD) eigenvalues are non-negative; drop kernel/noise.
548            if lambda <= self.tolerance {
549                continue;
550            }
551            let scale = lambda.sqrt();
552
553            // Un-vectorize the eigenvector (column `idx`) into a d_out × d_in
554            // Kraus operator, inverting the column-stacking convention used by
555            // `vectorize_operator`: K[i, j] = sqrt(lambda) * v[i + j * d_out].
556            let mut kraus_op: Array2<Complex<f64>> = Array2::zeros((d_out, d_in));
557            for j in 0..d_in {
558                for i in 0..d_out {
559                    kraus_op[[i, j]] = eigenvectors[[i + j * d_out, idx]] * scale;
560                }
561            }
562
563            operators.push(kraus_op);
564        }
565
566        // A completely positive map has at least one non-zero Kraus operator. An
567        // empty set here means the Choi matrix was (numerically) zero, i.e. the
568        // zero map, which is not a valid quantum channel.
569        if operators.is_empty() {
570            return Err(QuantRS2Error::InvalidInput(
571                "Choi matrix has no eigenvalues above tolerance; cannot build Kraus operators (zero map)"
572                    .to_string(),
573            ));
574        }
575
576        Ok(KrausRepresentation { operators })
577    }
578
579    /// Convert Kraus operators to Stinespring dilation
580    fn kraus_to_stinespring(
581        &self,
582        operators: &[Array2<Complex<f64>>],
583    ) -> QuantRS2Result<StinespringRepresentation> {
584        let num_kraus = operators.len();
585        let d_in = self.input_dim;
586        let d_out = self.output_dim;
587
588        // Environment dimension is number of Kraus operators
589        let env_dim = num_kraus;
590
591        // Build isometry V: |ψ⟩ ⊗ |0⟩_E → ∑ᵢ Kᵢ|ψ⟩ ⊗ |i⟩_E
592        let total_out_dim = d_out * env_dim;
593        let mut isometry = Array2::zeros((total_out_dim, d_in));
594
595        for (i, k) in operators.iter().enumerate() {
596            // Place Kraus operator in appropriate block
597            let start_row = i * d_out;
598            let end_row = (i + 1) * d_out;
599
600            isometry.slice_mut(s![start_row..end_row, ..]).assign(k);
601        }
602
603        Ok(StinespringRepresentation { isometry, env_dim })
604    }
605
606    /// Convert Stinespring dilation to Kraus operators
607    fn stinespring_to_kraus(
608        &self,
609        isometry: &Array2<Complex<f64>>,
610        env_dim: usize,
611    ) -> QuantRS2Result<KrausRepresentation> {
612        let d_out = self.output_dim;
613        let mut operators = Vec::new();
614
615        // Extract Kraus operators from blocks of isometry
616        for i in 0..env_dim {
617            let start_row = i * d_out;
618            let end_row = (i + 1) * d_out;
619
620            let k = isometry.slice(s![start_row..end_row, ..]).to_owned();
621
622            // Only include non-zero operators
623            let norm_sq: f64 = k.iter().map(|z| z.norm_sqr()).sum();
624            if norm_sq > self.tolerance {
625                operators.push(k);
626            }
627        }
628
629        Ok(KrausRepresentation { operators })
630    }
631
632    /// Vectorize an operator (column-stacking)
633    fn vectorize_operator(&self, op: &Array2<Complex<f64>>) -> Array2<Complex<f64>> {
634        let (rows, cols) = op.dim();
635        let mut vec = Array2::zeros((rows * cols, 1));
636
637        for j in 0..cols {
638            for i in 0..rows {
639                vec[[i + j * rows, 0]] = op[[i, j]];
640            }
641        }
642
643        vec
644    }
645}
646
647/// Common quantum channels
648pub struct QuantumChannels;
649
650impl QuantumChannels {
651    /// Create a depolarizing channel
652    pub fn depolarizing(p: f64) -> QuantRS2Result<QuantumChannel> {
653        if p < 0.0 || p > 1.0 {
654            return Err(QuantRS2Error::InvalidInput(
655                "Depolarizing parameter must be in [0, 1]".to_string(),
656            ));
657        }
658
659        let sqrt_1_minus_3p_4 = ((1.0 - 3.0 * p / 4.0).max(0.0)).sqrt();
660        let sqrt_p_4 = (p / 4.0).sqrt();
661
662        let operators = vec![
663            // sqrt(1-3p/4) * I
664            Array2::from_shape_vec(
665                (2, 2),
666                vec![
667                    Complex::new(sqrt_1_minus_3p_4, 0.0),
668                    Complex::new(0.0, 0.0),
669                    Complex::new(0.0, 0.0),
670                    Complex::new(sqrt_1_minus_3p_4, 0.0),
671                ],
672            )
673            .expect("valid 2x2 identity Kraus operator"),
674            // sqrt(p/4) * X
675            Array2::from_shape_vec(
676                (2, 2),
677                vec![
678                    Complex::new(0.0, 0.0),
679                    Complex::new(sqrt_p_4, 0.0),
680                    Complex::new(sqrt_p_4, 0.0),
681                    Complex::new(0.0, 0.0),
682                ],
683            )
684            .expect("valid 2x2 X Kraus operator"),
685            // sqrt(p/4) * Y
686            Array2::from_shape_vec(
687                (2, 2),
688                vec![
689                    Complex::new(0.0, 0.0),
690                    Complex::new(0.0, -sqrt_p_4),
691                    Complex::new(0.0, sqrt_p_4),
692                    Complex::new(0.0, 0.0),
693                ],
694            )
695            .expect("valid 2x2 Y Kraus operator"),
696            // sqrt(p/4) * Z
697            Array2::from_shape_vec(
698                (2, 2),
699                vec![
700                    Complex::new(sqrt_p_4, 0.0),
701                    Complex::new(0.0, 0.0),
702                    Complex::new(0.0, 0.0),
703                    Complex::new(-sqrt_p_4, 0.0),
704                ],
705            )
706            .expect("valid 2x2 Z Kraus operator"),
707        ];
708
709        QuantumChannel::from_kraus(operators)
710    }
711
712    /// Create an amplitude damping channel
713    pub fn amplitude_damping(gamma: f64) -> QuantRS2Result<QuantumChannel> {
714        if gamma < 0.0 || gamma > 1.0 {
715            return Err(QuantRS2Error::InvalidInput(
716                "Damping parameter must be in [0, 1]".to_string(),
717            ));
718        }
719
720        let sqrt_gamma = gamma.sqrt();
721        let sqrt_1_minus_gamma = (1.0 - gamma).sqrt();
722
723        let operators = vec![
724            // K0 = |0><0| + sqrt(1-gamma)|1><1|
725            Array2::from_shape_vec(
726                (2, 2),
727                vec![
728                    Complex::new(1.0, 0.0),
729                    Complex::new(0.0, 0.0),
730                    Complex::new(0.0, 0.0),
731                    Complex::new(sqrt_1_minus_gamma, 0.0),
732                ],
733            )
734            .expect("valid 2x2 amplitude damping K0"),
735            // K1 = sqrt(gamma)|0><1|
736            Array2::from_shape_vec(
737                (2, 2),
738                vec![
739                    Complex::new(0.0, 0.0),
740                    Complex::new(sqrt_gamma, 0.0),
741                    Complex::new(0.0, 0.0),
742                    Complex::new(0.0, 0.0),
743                ],
744            )
745            .expect("valid 2x2 amplitude damping K1"),
746        ];
747
748        QuantumChannel::from_kraus(operators)
749    }
750
751    /// Create a phase damping channel
752    pub fn phase_damping(gamma: f64) -> QuantRS2Result<QuantumChannel> {
753        if gamma < 0.0 || gamma > 1.0 {
754            return Err(QuantRS2Error::InvalidInput(
755                "Damping parameter must be in [0, 1]".to_string(),
756            ));
757        }
758
759        let sqrt_1_minus_gamma = (1.0 - gamma).sqrt();
760        let sqrt_gamma = gamma.sqrt();
761
762        let operators = vec![
763            // K0 = sqrt(1-gamma) * I
764            Array2::from_shape_vec(
765                (2, 2),
766                vec![
767                    Complex::new(sqrt_1_minus_gamma, 0.0),
768                    Complex::new(0.0, 0.0),
769                    Complex::new(0.0, 0.0),
770                    Complex::new(sqrt_1_minus_gamma, 0.0),
771                ],
772            )
773            .expect("valid 2x2 phase damping K0"),
774            // K1 = sqrt(gamma) * Z
775            Array2::from_shape_vec(
776                (2, 2),
777                vec![
778                    Complex::new(sqrt_gamma, 0.0),
779                    Complex::new(0.0, 0.0),
780                    Complex::new(0.0, 0.0),
781                    Complex::new(-sqrt_gamma, 0.0),
782                ],
783            )
784            .expect("valid 2x2 phase damping K1"),
785        ];
786
787        QuantumChannel::from_kraus(operators)
788    }
789
790    /// Create a bit flip channel
791    pub fn bit_flip(p: f64) -> QuantRS2Result<QuantumChannel> {
792        if p < 0.0 || p > 1.0 {
793            return Err(QuantRS2Error::InvalidInput(
794                "Flip probability must be in [0, 1]".to_string(),
795            ));
796        }
797
798        let sqrt_1_minus_p = (1.0 - p).sqrt();
799        let sqrt_p = p.sqrt();
800
801        let operators = vec![
802            // K0 = sqrt(1-p) * I
803            Array2::from_shape_vec(
804                (2, 2),
805                vec![
806                    Complex::new(sqrt_1_minus_p, 0.0),
807                    Complex::new(0.0, 0.0),
808                    Complex::new(0.0, 0.0),
809                    Complex::new(sqrt_1_minus_p, 0.0),
810                ],
811            )
812            .expect("valid 2x2 bit flip K0"),
813            // K1 = sqrt(p) * X
814            Array2::from_shape_vec(
815                (2, 2),
816                vec![
817                    Complex::new(0.0, 0.0),
818                    Complex::new(sqrt_p, 0.0),
819                    Complex::new(sqrt_p, 0.0),
820                    Complex::new(0.0, 0.0),
821                ],
822            )
823            .expect("valid 2x2 bit flip K1"),
824        ];
825
826        QuantumChannel::from_kraus(operators)
827    }
828
829    /// Create a phase flip channel
830    pub fn phase_flip(p: f64) -> QuantRS2Result<QuantumChannel> {
831        if p < 0.0 || p > 1.0 {
832            return Err(QuantRS2Error::InvalidInput(
833                "Flip probability must be in [0, 1]".to_string(),
834            ));
835        }
836
837        let sqrt_1_minus_p = (1.0 - p).sqrt();
838        let sqrt_p = p.sqrt();
839
840        let operators = vec![
841            // K0 = sqrt(1-p) * I
842            Array2::from_shape_vec(
843                (2, 2),
844                vec![
845                    Complex::new(sqrt_1_minus_p, 0.0),
846                    Complex::new(0.0, 0.0),
847                    Complex::new(0.0, 0.0),
848                    Complex::new(sqrt_1_minus_p, 0.0),
849                ],
850            )
851            .expect("valid 2x2 phase flip K0"),
852            // K1 = sqrt(p) * Z
853            Array2::from_shape_vec(
854                (2, 2),
855                vec![
856                    Complex::new(sqrt_p, 0.0),
857                    Complex::new(0.0, 0.0),
858                    Complex::new(0.0, 0.0),
859                    Complex::new(-sqrt_p, 0.0),
860                ],
861            )
862            .expect("valid 2x2 phase flip K1"),
863        ];
864
865        QuantumChannel::from_kraus(operators)
866    }
867}
868
869/// Process tomography utilities
870pub struct ProcessTomography;
871
872impl ProcessTomography {
873    /// Reconstruct a quantum channel from process tomography data via linear
874    /// inversion / least-squares estimation of its Choi matrix.
875    ///
876    /// Given input density matrices `ρ_k` and the measured outputs
877    /// `ρ'_k = Λ(ρ_k)`, the channel is determined by its Choi matrix `J`. Using
878    /// the *column-stacking* Choi convention of this module (the same one used by
879    /// [`QuantumChannel::kraus_to_choi`]), the action of the channel is linear in
880    /// the entries of `J`:
881    ///
882    /// ```text
883    /// Λ(ρ)[i, i'] = Σ_{j, j'} ρ[j, j'] · J[i + j·d_out, i' + j'·d_out]
884    /// ```
885    ///
886    /// Each `(ρ_k, ρ'_k)` pair therefore contributes `d_out²` scalar linear
887    /// constraints `A · vec(J) = b`. Stacking all pairs and solving the
888    /// least-squares problem (via the Hermitian normal equations
889    /// `AᴴA · vec(J) = Aᴴb`, solved with a spectral pseudo-inverse) yields the
890    /// best-fit Choi matrix, from which the channel is rebuilt with
891    /// [`QuantumChannel::from_choi`].
892    ///
893    /// # Errors
894    ///
895    /// Returns [`QuantRS2Error::InvalidInput`] if the input/output counts differ,
896    /// if any density matrix is not square / dimensions are inconsistent, or if
897    /// the supplied input states are not informationally complete (fewer than
898    /// `d²` linearly independent inputs, leaving the Choi matrix
899    /// under-determined). No identity / placeholder channel is fabricated.
900    pub fn reconstruct_channel(
901        input_states: &[Array2<Complex<f64>>],
902        output_states: &[Array2<Complex<f64>>],
903    ) -> QuantRS2Result<QuantumChannel> {
904        if input_states.len() != output_states.len() {
905            return Err(QuantRS2Error::InvalidInput(
906                "Number of input and output states must match".to_string(),
907            ));
908        }
909        if input_states.is_empty() {
910            return Err(QuantRS2Error::InvalidInput(
911                "process tomography requires at least one (input, output) state pair".to_string(),
912            ));
913        }
914
915        // Square-channel assumption: d_in == d_out == d.
916        let d = input_states[0].shape()[0];
917        if d == 0 {
918            return Err(QuantRS2Error::InvalidInput(
919                "density matrices must be non-empty".to_string(),
920            ));
921        }
922        for (states, label) in [(input_states, "input"), (output_states, "output")] {
923            for (k, state) in states.iter().enumerate() {
924                if state.shape() != [d, d] {
925                    return Err(QuantRS2Error::InvalidInput(format!(
926                        "{label} state {k} has shape {:?}, expected [{d}, {d}]",
927                        state.shape()
928                    )));
929                }
930            }
931        }
932
933        // Unknown vector x = vec(J) has length total_dim², where total_dim = d².
934        let total_dim = d * d;
935        let num_unknowns = total_dim * total_dim;
936
937        // Build the linear system A · x = b.
938        //
939        // Constraint index (per pair): (i, i') -> row, with i, i' in 0..d.
940        // Unknown index: J[r, c] with r = i + j·d, c = i' + j'·d, mapped to a
941        // single column via column-stacking of J as well:
942        //     col(r, c) = r + c · total_dim.
943        // Coefficient of J[r, c] in Λ(ρ)[i, i'] is ρ[j, j'] (with r = i + j·d,
944        // c = i' + j'·d).
945        let num_constraints = input_states.len() * d * d;
946        let mut a_mat: Array2<Complex<f64>> = Array2::zeros((num_constraints, num_unknowns));
947        let mut b_vec: Array1<Complex<f64>> = Array1::zeros(num_constraints);
948
949        let mut row = 0usize;
950        for (rho, rho_out) in input_states.iter().zip(output_states.iter()) {
951            for i in 0..d {
952                for i_prime in 0..d {
953                    // Right-hand side: measured output entry.
954                    b_vec[row] = rho_out[[i, i_prime]];
955
956                    // Fill the coefficients for this constraint.
957                    for j in 0..d {
958                        for j_prime in 0..d {
959                            let r = i + j * d;
960                            let c = i_prime + j_prime * d;
961                            let col = r + c * total_dim;
962                            a_mat[[row, col]] = rho[[j, j_prime]];
963                        }
964                    }
965                    row += 1;
966                }
967            }
968        }
969
970        // Normal equations: (Aᴴ A) x = Aᴴ b.
971        let a_dag = a_mat.mapv(|z| z.conj()).t().to_owned();
972        let ata = a_dag.dot(&a_mat);
973        let atb = a_dag.dot(&b_vec);
974
975        // Informational-completeness check: the normal matrix must be full rank
976        // (= num_unknowns). AᴴA is Hermitian PSD, so we use the Hermitian Jacobi
977        // solver and detect rank deficiency via its (real, non-negative)
978        // eigenvalues.
979        let decomposition = hermitian_eigen_decompose(&ata)?;
980        let eigenvalues = &decomposition.eigenvalues;
981        let eigenvectors = &decomposition.eigenvectors;
982
983        let max_eigenvalue = eigenvalues.iter().fold(0.0_f64, |acc, &z| acc.max(z.abs()));
984        // Relative threshold for treating an eigenvalue as numerically zero.
985        let rank_tolerance = (max_eigenvalue * 1e-9).max(1e-12);
986
987        let rank = eigenvalues
988            .iter()
989            .filter(|&&z| z.abs() > rank_tolerance)
990            .count();
991        if rank < num_unknowns {
992            return Err(QuantRS2Error::InvalidInput(
993                "process tomography requires an informationally-complete set of input states (need d^2 linearly independent inputs)".into(),
994            ));
995        }
996
997        // Solve x = (Aᴴ A)⁺ (Aᴴ b) using the spectral decomposition:
998        //   (Aᴴ A)⁻¹ = Σ_m (1/λ_m) u_m u_mᴴ
999        // applied directly to (Aᴴ b) to avoid forming the dense inverse.
1000        let mut x: Array1<Complex<f64>> = Array1::zeros(num_unknowns);
1001        for m in 0..num_unknowns {
1002            let lambda = eigenvalues[m];
1003            if lambda.abs() <= rank_tolerance {
1004                continue;
1005            }
1006            let u_m = eigenvectors.column(m);
1007            // coeff = u_mᴴ · (Aᴴ b)
1008            let mut coeff = Complex::new(0.0, 0.0);
1009            for n in 0..num_unknowns {
1010                coeff += u_m[n].conj() * atb[n];
1011            }
1012            let coeff = coeff / Complex::new(lambda, 0.0);
1013            for n in 0..num_unknowns {
1014                x[n] += coeff * u_m[n];
1015            }
1016        }
1017
1018        // Reshape x back into the Choi matrix J (column-stacking inverse).
1019        let mut choi: Array2<Complex<f64>> = Array2::zeros((total_dim, total_dim));
1020        for c in 0..total_dim {
1021            for r in 0..total_dim {
1022                choi[[r, c]] = x[r + c * total_dim];
1023            }
1024        }
1025
1026        // Hermitize to remove tiny numerical asymmetry before validation, then
1027        // build the channel from the reconstructed Choi matrix.
1028        let choi_dag = choi.mapv(|z| z.conj()).t().to_owned();
1029        let choi_herm = (&choi + &choi_dag).mapv(|z| z * Complex::new(0.5, 0.0));
1030
1031        QuantumChannel::from_choi(choi_herm)
1032    }
1033
1034    /// Generate an informationally-complete set of input states for a
1035    /// `dim`-dimensional system.
1036    ///
1037    /// Process tomography of a `dim × dim` channel needs `dim²` linearly
1038    /// independent input density matrices. This routine returns exactly such a
1039    /// set:
1040    ///
1041    /// * the `dim` computational-basis populations `|i⟩⟨i|`, and
1042    /// * for every pair `i < j`, the two superposition states
1043    ///   `|+_{ij}⟩ = (|i⟩ + |j⟩)/√2` and `|+i_{ij}⟩ = (|i⟩ + i|j⟩)/√2`
1044    ///   as density matrices `|ψ⟩⟨ψ|`.
1045    ///
1046    /// The diagonal states fix the populations while each pair of off-diagonal
1047    /// states fixes the real and imaginary parts of the corresponding coherence,
1048    /// giving `dim + 2 · C(dim, 2) = dim²` linearly independent operators that
1049    /// span the full space of Hermitian matrices.
1050    pub fn generate_input_states(dim: usize) -> Vec<Array2<Complex<f64>>> {
1051        let mut states = Vec::new();
1052        if dim == 0 {
1053            return states;
1054        }
1055
1056        let inv_sqrt2 = Complex::new(1.0 / 2.0_f64.sqrt(), 0.0);
1057
1058        // Computational basis populations |i><i|.
1059        for i in 0..dim {
1060            let mut state = Array2::zeros((dim, dim));
1061            state[[i, i]] = Complex::new(1.0, 0.0);
1062            states.push(state);
1063        }
1064
1065        // Off-diagonal coherences from superposition states. For each pair i < j
1066        // we add the density matrices of (|i> + |j>)/√2 and (|i> + i|j>)/√2.
1067        for i in 0..dim {
1068            for j in (i + 1)..dim {
1069                for &phase in &[Complex::new(1.0, 0.0), Complex::new(0.0, 1.0)] {
1070                    // |psi> = (|i> + phase·|j>)/√2
1071                    let mut psi: Array1<Complex<f64>> = Array1::zeros(dim);
1072                    psi[i] = inv_sqrt2;
1073                    psi[j] = phase * inv_sqrt2;
1074
1075                    // rho = |psi><psi|
1076                    let mut state: Array2<Complex<f64>> = Array2::zeros((dim, dim));
1077                    for r in 0..dim {
1078                        for c in 0..dim {
1079                            state[[r, c]] = psi[r] * psi[c].conj();
1080                        }
1081                    }
1082                    states.push(state);
1083                }
1084            }
1085        }
1086
1087        states
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094    use scirs2_core::Complex;
1095
1096    #[test]
1097    fn test_depolarizing_channel() {
1098        let channel =
1099            QuantumChannels::depolarizing(0.1).expect("Failed to create depolarizing channel");
1100
1101        assert_eq!(channel.input_dim, 2);
1102        assert_eq!(channel.output_dim, 2);
1103        assert!(channel.kraus.is_some());
1104        assert_eq!(
1105            channel
1106                .kraus
1107                .as_ref()
1108                .expect("Kraus representation missing")
1109                .operators
1110                .len(),
1111            4
1112        );
1113    }
1114
1115    #[test]
1116    fn test_amplitude_damping() {
1117        let channel = QuantumChannels::amplitude_damping(0.3)
1118            .expect("Failed to create amplitude damping channel");
1119
1120        assert!(channel.kraus.is_some());
1121        assert_eq!(
1122            channel
1123                .kraus
1124                .as_ref()
1125                .expect("Kraus representation missing")
1126                .operators
1127                .len(),
1128            2
1129        );
1130
1131        // Test on |1><1| state
1132        let mut rho = Array2::zeros((2, 2));
1133        rho[[1, 1]] = Complex::new(1.0, 0.0);
1134
1135        let mut ch = channel;
1136        let output = ch.apply(&rho).expect("Failed to apply channel");
1137
1138        // Population should decrease
1139        assert!(output[[1, 1]].re < 1.0);
1140        assert!(output[[0, 0]].re > 0.0);
1141    }
1142
1143    #[test]
1144    fn test_kraus_to_choi() {
1145        let mut channel =
1146            QuantumChannels::bit_flip(0.2).expect("Failed to create bit flip channel");
1147        let choi = channel.to_choi().expect("Failed to convert to Choi");
1148
1149        assert_eq!(choi.matrix.shape(), [4, 4]);
1150
1151        // Choi matrix should be Hermitian
1152        let choi_dag = choi.matrix.mapv(|z| z.conj()).t().to_owned();
1153        let diff = &choi.matrix - &choi_dag;
1154        let max_diff = diff.iter().map(|z| z.norm()).fold(0.0, f64::max);
1155        assert!(max_diff < 1e-10);
1156    }
1157
1158    #[test]
1159    fn test_channel_composition() {
1160        // Create two channels
1161        let mut ch1 =
1162            QuantumChannels::phase_flip(0.1).expect("Failed to create phase flip channel");
1163        let mut ch2 = QuantumChannels::bit_flip(0.2).expect("Failed to create bit flip channel");
1164
1165        // Apply both to a superposition state
1166        let mut rho = Array2::zeros((2, 2));
1167        rho[[0, 0]] = Complex::new(0.5, 0.0);
1168        rho[[0, 1]] = Complex::new(0.5, 0.0);
1169        rho[[1, 0]] = Complex::new(0.5, 0.0);
1170        rho[[1, 1]] = Complex::new(0.5, 0.0);
1171
1172        let intermediate = ch1.apply(&rho).expect("Failed to apply phase flip channel");
1173        let final_state = ch2
1174            .apply(&intermediate)
1175            .expect("Failed to apply bit flip channel");
1176
1177        // Trace should be preserved
1178        let trace = final_state[[0, 0]] + final_state[[1, 1]];
1179        assert!((trace.re - 1.0).abs() < 1e-10);
1180        assert!(trace.im.abs() < 1e-10);
1181    }
1182
1183    #[test]
1184    fn test_unitary_channel() {
1185        // Hadamard as unitary channel
1186        let h = Array2::from_shape_vec(
1187            (2, 2),
1188            vec![
1189                Complex::new(1.0, 0.0),
1190                Complex::new(1.0, 0.0),
1191                Complex::new(1.0, 0.0),
1192                Complex::new(-1.0, 0.0),
1193            ],
1194        )
1195        .expect("valid 2x2 Hadamard matrix")
1196            / Complex::new(2.0_f64.sqrt(), 0.0);
1197
1198        let mut channel =
1199            QuantumChannel::from_kraus(vec![h]).expect("Failed to create unitary channel");
1200
1201        assert!(channel.is_unitary().expect("Failed to check unitarity"));
1202    }
1203
1204    #[test]
1205    fn test_stinespring_conversion() {
1206        let mut channel = QuantumChannels::amplitude_damping(0.5)
1207            .expect("Failed to create amplitude damping channel");
1208
1209        // Convert to Stinespring
1210        let stinespring = channel
1211            .to_stinespring()
1212            .expect("Failed to convert to Stinespring");
1213
1214        assert_eq!(stinespring.env_dim, 2);
1215        assert_eq!(stinespring.isometry.shape(), [4, 2]);
1216
1217        // Convert back to Kraus
1218        let kraus_decomposer =
1219            QuantumChannel::from_kraus(vec![Array2::eye(2).mapv(|x| Complex::new(x, 0.0))])
1220                .expect("Failed to create identity channel");
1221        let kraus = kraus_decomposer
1222            .stinespring_to_kraus(&stinespring.isometry, stinespring.env_dim)
1223            .expect("Failed to convert back to Kraus");
1224        assert_eq!(kraus.operators.len(), 2);
1225    }
1226
1227    /// Apply an explicit Kraus set Σ K ρ K† without going through a channel.
1228    fn apply_kraus(
1229        operators: &[Array2<Complex<f64>>],
1230        rho: &Array2<Complex<f64>>,
1231    ) -> Array2<Complex<f64>> {
1232        let d_out = operators[0].shape()[0];
1233        let mut result: Array2<Complex<f64>> = Array2::zeros((d_out, d_out));
1234        for k in operators {
1235            let k_dag = k.mapv(|z| z.conj()).t().to_owned();
1236            result = result + k.dot(rho).dot(&k_dag);
1237        }
1238        result
1239    }
1240
1241    /// A small assortment of test density matrices for a single qubit.
1242    fn qubit_test_states() -> Vec<Array2<Complex<f64>>> {
1243        let mut states = Vec::new();
1244
1245        // |0><0|
1246        let mut s0 = Array2::zeros((2, 2));
1247        s0[[0, 0]] = Complex::new(1.0, 0.0);
1248        states.push(s0);
1249
1250        // |1><1|
1251        let mut s1 = Array2::zeros((2, 2));
1252        s1[[1, 1]] = Complex::new(1.0, 0.0);
1253        states.push(s1);
1254
1255        // |+><+|
1256        let mut s_plus = Array2::zeros((2, 2));
1257        for idx in [[0, 0], [0, 1], [1, 0], [1, 1]] {
1258            s_plus[idx] = Complex::new(0.5, 0.0);
1259        }
1260        states.push(s_plus);
1261
1262        // |+i><+i| (eigenstate of Y)
1263        let mut s_plus_i = Array2::zeros((2, 2));
1264        s_plus_i[[0, 0]] = Complex::new(0.5, 0.0);
1265        s_plus_i[[0, 1]] = Complex::new(0.0, -0.5);
1266        s_plus_i[[1, 0]] = Complex::new(0.0, 0.5);
1267        s_plus_i[[1, 1]] = Complex::new(0.5, 0.0);
1268        states.push(s_plus_i);
1269
1270        states
1271    }
1272
1273    #[test]
1274    fn test_choi_to_kraus_roundtrip_depolarizing() {
1275        // Build a known channel, capture its original Kraus operators, round-trip
1276        // through the Choi matrix, and verify the recovered Kraus set reproduces
1277        // the original action on several test states. This FAILS if choi_to_kraus
1278        // returns the identity fabrication.
1279        let mut channel =
1280            QuantumChannels::depolarizing(0.3).expect("failed to create depolarizing channel");
1281        let original_ops = channel
1282            .to_kraus()
1283            .expect("failed to get original Kraus")
1284            .operators
1285            .clone();
1286
1287        // Convert to Choi, then re-derive Kraus from a *fresh* channel built only
1288        // from the Choi matrix (so no cached Kraus is reused).
1289        let choi = channel
1290            .to_choi()
1291            .expect("failed to convert to Choi")
1292            .clone();
1293        let mut from_choi = QuantumChannel::from_choi(choi.matrix.clone())
1294            .expect("failed to build channel from Choi");
1295        let recovered_ops = from_choi
1296            .to_kraus()
1297            .expect("failed to recover Kraus from Choi")
1298            .operators
1299            .clone();
1300
1301        for rho in qubit_test_states() {
1302            let expected = apply_kraus(&original_ops, &rho);
1303            let actual = apply_kraus(&recovered_ops, &rho);
1304            let max_diff = (&expected - &actual)
1305                .iter()
1306                .map(|z| z.norm())
1307                .fold(0.0_f64, f64::max);
1308            assert!(
1309                max_diff < 1e-9,
1310                "depolarizing round-trip mismatch: {max_diff}"
1311            );
1312        }
1313    }
1314
1315    #[test]
1316    fn test_choi_to_kraus_roundtrip_amplitude_damping() {
1317        let mut channel = QuantumChannels::amplitude_damping(0.4)
1318            .expect("failed to create amplitude damping channel");
1319        let original_ops = channel
1320            .to_kraus()
1321            .expect("failed to get original Kraus")
1322            .operators
1323            .clone();
1324
1325        let choi = channel
1326            .to_choi()
1327            .expect("failed to convert to Choi")
1328            .clone();
1329        let mut from_choi = QuantumChannel::from_choi(choi.matrix.clone())
1330            .expect("failed to build channel from Choi");
1331        let recovered_ops = from_choi
1332            .to_kraus()
1333            .expect("failed to recover Kraus from Choi")
1334            .operators
1335            .clone();
1336
1337        for rho in qubit_test_states() {
1338            let expected = apply_kraus(&original_ops, &rho);
1339            let actual = apply_kraus(&recovered_ops, &rho);
1340            let max_diff = (&expected - &actual)
1341                .iter()
1342                .map(|z| z.norm())
1343                .fold(0.0_f64, f64::max);
1344            assert!(
1345                max_diff < 1e-9,
1346                "amplitude damping round-trip mismatch: {max_diff}"
1347            );
1348        }
1349    }
1350
1351    #[test]
1352    fn test_choi_to_kraus_trace_preserving() {
1353        // The Kraus set recovered from the Choi matrix must satisfy the
1354        // completeness relation Σ Kᴴ K = I (trace preservation) within 1e-9.
1355        let mut channel =
1356            QuantumChannels::depolarizing(0.25).expect("failed to create depolarizing channel");
1357        let choi = channel
1358            .to_choi()
1359            .expect("failed to convert to Choi")
1360            .clone();
1361        let mut from_choi = QuantumChannel::from_choi(choi.matrix.clone())
1362            .expect("failed to build channel from Choi");
1363        let recovered = from_choi
1364            .to_kraus()
1365            .expect("failed to recover Kraus from Choi")
1366            .operators
1367            .clone();
1368
1369        let d_in = from_choi.input_dim;
1370        let mut sum: Array2<Complex<f64>> = Array2::zeros((d_in, d_in));
1371        for k in &recovered {
1372            let k_dag = k.mapv(|z| z.conj()).t().to_owned();
1373            sum = sum + k_dag.dot(k);
1374        }
1375
1376        for i in 0..d_in {
1377            for j in 0..d_in {
1378                let expected = if i == j {
1379                    Complex::new(1.0, 0.0)
1380                } else {
1381                    Complex::new(0.0, 0.0)
1382                };
1383                let diff = (sum[[i, j]] - expected).norm();
1384                assert!(diff < 1e-9, "completeness violated at ({i},{j}): {diff}");
1385            }
1386        }
1387    }
1388
1389    #[test]
1390    fn test_generate_input_states_informationally_complete() {
1391        // For dimension d the generated set must contain exactly d^2 states.
1392        for d in 2..=3 {
1393            let states = ProcessTomography::generate_input_states(d);
1394            assert_eq!(
1395                states.len(),
1396                d * d,
1397                "expected d^2 informationally-complete states for d={d}"
1398            );
1399            // Each state should be a valid (trace-1, Hermitian) density matrix.
1400            for state in &states {
1401                assert_eq!(state.shape(), [d, d]);
1402                let trace: Complex<f64> = (0..d).map(|i| state[[i, i]]).sum();
1403                assert!((trace.re - 1.0).abs() < 1e-12);
1404                assert!(trace.im.abs() < 1e-12);
1405            }
1406        }
1407    }
1408
1409    #[test]
1410    fn test_reconstruct_channel_amplitude_damping() {
1411        // Generate an informationally-complete input set, push it through a known
1412        // amplitude damping channel to obtain outputs, reconstruct the channel
1413        // from (input, output) pairs, and verify the reconstruction matches the
1414        // original on a fresh state. Also assert the result is NOT the identity.
1415        let gamma = 0.35;
1416        let inputs = ProcessTomography::generate_input_states(2);
1417
1418        let mut reference =
1419            QuantumChannels::amplitude_damping(gamma).expect("failed to create reference channel");
1420        let mut outputs = Vec::with_capacity(inputs.len());
1421        for rho in &inputs {
1422            outputs.push(reference.apply(rho).expect("failed to apply reference"));
1423        }
1424
1425        let mut reconstructed = ProcessTomography::reconstruct_channel(&inputs, &outputs)
1426            .expect("reconstruction should succeed for an informationally-complete set");
1427
1428        // Fresh state not used directly as a basis population: |+i><+i|.
1429        let mut fresh = Array2::zeros((2, 2));
1430        fresh[[0, 0]] = Complex::new(0.5, 0.0);
1431        fresh[[0, 1]] = Complex::new(0.0, -0.5);
1432        fresh[[1, 0]] = Complex::new(0.0, 0.5);
1433        fresh[[1, 1]] = Complex::new(0.5, 0.0);
1434
1435        let expected = reference.apply(&fresh).expect("reference apply failed");
1436        let actual = reconstructed
1437            .apply(&fresh)
1438            .expect("reconstructed apply failed");
1439        let max_diff = (&expected - &actual)
1440            .iter()
1441            .map(|z| z.norm())
1442            .fold(0.0_f64, f64::max);
1443        assert!(max_diff < 1e-8, "reconstruction mismatch: {max_diff}");
1444
1445        // The reconstructed channel must differ from the identity channel.
1446        let mut identity =
1447            QuantumChannel::from_kraus(vec![Array2::eye(2).mapv(|x| Complex::new(x, 0.0))])
1448                .expect("failed to create identity channel");
1449        let id_out = identity.apply(&fresh).expect("identity apply failed");
1450        let id_diff = (&id_out - &actual)
1451            .iter()
1452            .map(|z| z.norm())
1453            .fold(0.0_f64, f64::max);
1454        assert!(
1455            id_diff > 1e-3,
1456            "reconstructed channel is indistinguishable from identity (diff={id_diff})"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_reconstruct_channel_underdetermined_errors() {
1462        // Too few input states (only the d basis populations, not d^2) must
1463        // produce an HONEST error rather than a fabricated identity channel.
1464        let mut reference =
1465            QuantumChannels::bit_flip(0.2).expect("failed to create bit flip channel");
1466
1467        // Only computational-basis populations: 2 states for d=2 (< d^2 = 4).
1468        let mut inputs = Vec::new();
1469        for i in 0..2 {
1470            let mut state = Array2::zeros((2, 2));
1471            state[[i, i]] = Complex::new(1.0, 0.0);
1472            inputs.push(state);
1473        }
1474        let mut outputs = Vec::new();
1475        for rho in &inputs {
1476            outputs.push(reference.apply(rho).expect("apply failed"));
1477        }
1478
1479        let result = ProcessTomography::reconstruct_channel(&inputs, &outputs);
1480        assert!(
1481            result.is_err(),
1482            "underdetermined tomography must error, not fabricate a channel"
1483        );
1484    }
1485}