Skip to main content

cqlib_core/compile/knowledge/
rule_equivalence.rs

1// This code is part of Cqlib.
2//
3// (C) Copyright China Telecom Quantum Group 2025-2026
4//
5// This code is licensed under the Apache License, Version 2.0. You may
6// obtain a copy of this license in the LICENSE.txt file in the root directory
7// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8//
9// Any modifications or derivative works of this code must retain this
10// copyright notice, and modified files need to carry a notice indicating
11// that they have been altered from the originals.
12
13//! Equivalence validation for knowledge-base rewrite rules.
14//!
15//! Rule definitions live in [`super::rule`].  This module validates a rule by
16//! comparing the symbolic unitary matrices of its left- and right-hand sides,
17//! with numerical sampling as a fallback for identities the symbolic
18//! simplifier cannot prove directly.
19
20use crate::circuit::error::{CircuitError, ParameterError};
21use crate::circuit::symbolic_matrix::matrix::simplify_matrix;
22use crate::circuit::symbolic_matrix::{
23    SymbolicMatrix, circuit_to_symbolic_matrix, evaluate_symbolic_matrix,
24    symbolic_matrices_equivalent,
25};
26use crate::circuit::{Circuit, Instruction, Parameter, ParameterValue, Qubit, StandardGate};
27use crate::compile::knowledge::rule::{Condition, Rule, RuleItem};
28use crate::compile::{NUMERIC_ZERO_TOLERANCE, UNIT_PHASE_NORM_TOLERANCE};
29use ndarray::parallel::prelude::*;
30use num_complex::Complex64;
31use rand::Rng;
32use smallvec::SmallVec;
33use std::collections::HashMap;
34
35const DETERMINISTIC_NUMERIC_TOLERANCE: f64 = 1e-12;
36const RULE_CONDITION_TOLERANCE: f64 = 1e-8;
37const DEFAULT_VERIFY_NUM_BINDINGS: usize = 16;
38const DEFAULT_VERIFY_SAMPLING_TOLERANCE: f64 = 1e-8;
39const ENV_VERIFY_BINDINGS: &str = "CQLIB_RULE_VERIFY_BINDINGS";
40const ENV_VERIFY_TOLERANCE: &str = "CQLIB_RULE_VERIFY_TOLERANCE";
41
42/// Number of parameter bindings for layered / sampling rule verification.
43///
44/// Override at runtime with `CQLIB_RULE_VERIFY_BINDINGS` (e.g. `128` for nightly deep checks).
45pub fn verify_sampling_bindings() -> usize {
46    std::env::var(ENV_VERIFY_BINDINGS)
47        .ok()
48        .and_then(|value| value.parse().ok())
49        .filter(|count| *count > 0)
50        .unwrap_or(DEFAULT_VERIFY_NUM_BINDINGS)
51}
52
53/// Numerical tolerance for sampling-based rule verification.
54///
55/// Override with `CQLIB_RULE_VERIFY_TOLERANCE`.
56pub fn verify_sampling_tolerance() -> f64 {
57    std::env::var(ENV_VERIFY_TOLERANCE)
58        .ok()
59        .and_then(|value| value.parse().ok())
60        .filter(|tolerance: &f64| *tolerance > 0.0 && tolerance.is_finite())
61        .unwrap_or(DEFAULT_VERIFY_SAMPLING_TOLERANCE)
62}
63
64/// Result of layered rule equivalence verification via [`Rule::verify`].
65///
66/// Taxonomy for rule-library health checks:
67///
68/// | Outcome | Meaning |
69/// |---------|---------|
70/// | [`VerifyResult::Equivalent`] | Symbolic or fixed-parameter numeric matrix comparison proved equivalence |
71/// | [`VerifyResult::SampledEqual`] | Symbolic verification was insufficient, but parameter sampling passed |
72/// | [`VerifyResult::NotEquivalent`] | Symbolic and sampling both failed — suspected rule bug |
73/// | [`VerifyResult::Inconclusive`] | Could not generate parameter bindings satisfying `require` conditions |
74///
75/// Bare symbolic comparison alone is not sufficient for parametric rules, rules with
76/// `require` / `mod` conditions, or many commutation rules. [`Rule::verify`] falls back
77/// to sampling automatically when those features are present.
78#[derive(Debug)]
79pub enum VerifyResult {
80    /// The symbolic verifier proved the rule equivalent up to global phase.
81    Equivalent,
82    /// Symbolic matrices differ structurally but are numerically equal at all
83    /// sampled parameter bindings.
84    SampledEqual { num_bindings: usize },
85    /// The verifier did not prove equivalence.
86    NotEquivalent,
87    /// Could not verify (e.g., cannot generate satisfying bindings).
88    Inconclusive { reason: String },
89}
90
91impl VerifyResult {
92    /// Returns whether equivalence verification succeeded.
93    pub fn is_verified(&self) -> bool {
94        matches!(self, Self::Equivalent | Self::SampledEqual { .. })
95    }
96}
97
98/// Errors during verification setup (not verification failure).
99#[derive(Debug, thiserror::Error)]
100pub enum VerifyError {
101    #[error("unsupported instruction pattern: {0}")]
102    UnsupportedPattern(String),
103    #[error("parameter error: {0}")]
104    Parameter(#[from] ParameterError),
105    #[error("circuit error: {0}")]
106    Circuit(#[from] CircuitError),
107}
108
109impl Rule {
110    /// Verify this rule with layered equivalence checking:
111    ///
112    /// 1. Symbolic matrix comparison (and numeric check for fixed-parameter rules)
113    /// 2. If that fails and the rule has free parameters or `require` conditions,
114    ///    fall back to [`Self::verify_by_sampling`] with default binding count and tolerance
115    pub fn verify(&self) -> Result<VerifyResult, VerifyError> {
116        let (lhs, rhs) = build_simplified_matrices(self)?;
117        layered_verify(
118            self,
119            &lhs,
120            &rhs,
121            verify_sampling_bindings(),
122            verify_sampling_tolerance(),
123        )
124    }
125
126    /// Verify this rule symbolically first, then fall back to numerical sampling.
127    pub fn verify_by_sampling(
128        &self,
129        num_bindings: usize,
130        tolerance: f64,
131    ) -> Result<VerifyResult, VerifyError> {
132        let (lhs, rhs) = build_simplified_matrices(self)?;
133        layered_verify(self, &lhs, &rhs, num_bindings, tolerance)
134    }
135
136    /// Whether layered verification should fall back to parameter sampling after
137    /// symbolic comparison fails.
138    pub fn needs_sampling_fallback(&self) -> bool {
139        !self.collect_free_symbols().is_empty() || self.conditions.is_some()
140    }
141}
142
143fn layered_verify(
144    rule: &Rule,
145    lhs: &SymbolicMatrix,
146    rhs: &SymbolicMatrix,
147    num_bindings: usize,
148    tolerance: f64,
149) -> Result<VerifyResult, VerifyError> {
150    if symbolic_matrices_equivalent(lhs, rhs)? {
151        return Ok(VerifyResult::Equivalent);
152    }
153
154    if !rule.needs_sampling_fallback() {
155        let (lhs_num, rhs_num) = rayon::join(
156            || evaluate_symbolic_matrix(lhs, &None),
157            || evaluate_symbolic_matrix(rhs, &None),
158        );
159        let lhs_num = lhs_num?;
160        let rhs_num = rhs_num?;
161        if max_diff_up_to_global_phase(&lhs_num, &rhs_num) < DETERMINISTIC_NUMERIC_TOLERANCE {
162            return Ok(VerifyResult::Equivalent);
163        }
164        return Ok(VerifyResult::NotEquivalent);
165    }
166
167    verify_by_sampling(rule, lhs, rhs, num_bindings, tolerance)
168}
169
170fn build_simplified_matrices(rule: &Rule) -> Result<(SymbolicMatrix, SymbolicMatrix), VerifyError> {
171    let num_qubits = rule.num_qubits();
172
173    let (lhs, rhs) = rayon::join(
174        || rule_items_to_circuit(&rule.operations, num_qubits),
175        || rule_items_to_circuit(&rule.target, num_qubits),
176    );
177    let lhs = lhs?;
178    let rhs = rhs?;
179
180    let (lhs, rhs) = rayon::join(
181        || circuit_to_symbolic_matrix(&lhs, None),
182        || circuit_to_symbolic_matrix(&rhs, None),
183    );
184    let lhs = lhs?;
185    let rhs = rhs?;
186
187    let (lhs, rhs) = rayon::join(|| simplify_matrix(&lhs), || simplify_matrix(&rhs));
188    let lhs = lhs?;
189    let rhs = rhs?;
190
191    Ok((lhs, rhs))
192}
193
194fn verify_by_sampling(
195    rule: &Rule,
196    lhs: &SymbolicMatrix,
197    rhs: &SymbolicMatrix,
198    num_bindings: usize,
199    tolerance: f64,
200) -> Result<VerifyResult, VerifyError> {
201    let free_symbols = rule.collect_free_symbols();
202    if free_symbols.is_empty() {
203        let (lhs_num, rhs_num) = rayon::join(
204            || evaluate_symbolic_matrix(lhs, &None),
205            || evaluate_symbolic_matrix(rhs, &None),
206        );
207        let lhs_num = lhs_num?;
208        let rhs_num = rhs_num?;
209        let diff = max_diff_up_to_global_phase(&lhs_num, &rhs_num);
210        if diff < tolerance {
211            return Ok(VerifyResult::SampledEqual { num_bindings: 1 });
212        }
213        return Ok(VerifyResult::NotEquivalent);
214    }
215
216    let symbol_names: Vec<&str> = free_symbols.iter().map(|s| s.as_str()).collect();
217    let bindings =
218        generate_satisfying_bindings(&symbol_names, rule.conditions.as_deref(), num_bindings);
219
220    if bindings.is_empty() {
221        return Ok(VerifyResult::Inconclusive {
222            reason: "could not generate parameter bindings satisfying conditions".to_string(),
223        });
224    }
225
226    for binding in &bindings {
227        let bindings_ref: Option<HashMap<&str, f64>> =
228            Some(binding.iter().map(|(k, &v)| (k.as_str(), v)).collect());
229        let (lhs_num, rhs_num) = rayon::join(
230            || evaluate_symbolic_matrix(lhs, &bindings_ref),
231            || evaluate_symbolic_matrix(rhs, &bindings_ref),
232        );
233        let lhs_num = lhs_num?;
234        let rhs_num = rhs_num?;
235        let diff = max_diff_up_to_global_phase(&lhs_num, &rhs_num);
236        if diff >= tolerance {
237            return Ok(VerifyResult::NotEquivalent);
238        }
239    }
240
241    Ok(VerifyResult::SampledEqual {
242        num_bindings: bindings.len(),
243    })
244}
245
246fn rule_items_to_circuit(ops: &[RuleItem], num_qubits: usize) -> Result<Circuit, VerifyError> {
247    let mut circuit = Circuit::new(num_qubits);
248
249    for op in ops {
250        let instruction = match &op.instruction {
251            Instruction::Standard(gate) => Instruction::Standard(*gate),
252            Instruction::McGate(gate) => Instruction::McGate(gate.clone()),
253            other => {
254                return Err(VerifyError::UnsupportedPattern(other.to_string()));
255            }
256        };
257
258        if matches!(&instruction, Instruction::Standard(StandardGate::GPhase)) {
259            let actual = op.params.as_ref().map_or(0, SmallVec::len);
260            if actual != 1 {
261                return Err(CircuitError::ParameterCountMismatch {
262                    expected: 1,
263                    actual,
264                }
265                .into());
266            }
267            let theta = op
268                .params
269                .as_ref()
270                .and_then(|params| params.first())
271                .expect("GPhase parameter count was checked");
272            let phase = match theta {
273                ParameterValue::Param(param) => param.clone(),
274                ParameterValue::Fixed(value) => Parameter::from(*value),
275            };
276            circuit.set_global_phase(circuit.global_phase() + phase);
277            continue;
278        }
279
280        let qubits: SmallVec<[Qubit; 3]> =
281            op.qubits.iter().map(|&qubit| Qubit::new(qubit)).collect();
282        let params = op.params.clone().unwrap_or_default();
283        circuit.append(instruction, qubits, params, None)?;
284    }
285
286    Ok(circuit)
287}
288
289/// Compute the maximum element-wise difference between two numerical matrices.
290fn max_diff_strict(lhs: &ndarray::Array2<Complex64>, rhs: &ndarray::Array2<Complex64>) -> f64 {
291    (0..lhs.len())
292        .into_par_iter()
293        .map(|i| {
294            let l = lhs.as_slice().unwrap()[i];
295            let r = rhs.as_slice().unwrap()[i];
296            (l - r).norm()
297        })
298        .fold(|| 0.0_f64, f64::max)
299        .reduce(|| 0.0_f64, f64::max)
300}
301
302/// Compute the maximum element-wise difference between two numerical matrices.
303fn max_diff_up_to_global_phase(
304    lhs: &ndarray::Array2<Complex64>,
305    rhs: &ndarray::Array2<Complex64>,
306) -> f64 {
307    let mut phase_ratio = None;
308
309    for (&l, &r) in lhs.iter().zip(rhs.iter()) {
310        let l_zero = l.norm() <= NUMERIC_ZERO_TOLERANCE;
311        let r_zero = r.norm() <= NUMERIC_ZERO_TOLERANCE;
312
313        if l_zero != r_zero {
314            return max_diff_strict(lhs, rhs);
315        }
316
317        if !l_zero {
318            let ratio = r / l;
319            let ratio_norm = ratio.norm();
320
321            if !ratio_norm.is_finite() || (ratio_norm - 1.0).abs() > UNIT_PHASE_NORM_TOLERANCE {
322                return max_diff_strict(lhs, rhs);
323            }
324
325            phase_ratio = Some(ratio);
326            break;
327        }
328    }
329
330    let Some(phase_ratio) = phase_ratio else {
331        return max_diff_strict(lhs, rhs);
332    };
333
334    let lhs_slice = lhs.as_slice().expect("lhs matrix must be contiguous");
335    let rhs_slice = rhs.as_slice().expect("rhs matrix must be contiguous");
336
337    (0..lhs_slice.len())
338        .into_par_iter()
339        .map(|i| {
340            let l = lhs_slice[i];
341            let r = rhs_slice[i];
342
343            // phase_ratio = rhs / lhs, so rhs / phase_ratio aligns with lhs.
344            let normalized_r = r / phase_ratio;
345            (l - normalized_r).norm()
346        })
347        .fold(|| 0.0_f64, f64::max)
348        .reduce(|| 0.0_f64, f64::max)
349}
350
351/// Generate parameter bindings that satisfy the rule's conditions.
352fn generate_satisfying_bindings(
353    symbols: &[&str],
354    conditions: Option<&[Condition]>,
355    num_bindings: usize,
356) -> Vec<HashMap<String, f64>> {
357    let mut result = Vec::new();
358    let mut rng = rand::rng();
359    let two_pi = 2.0 * std::f64::consts::PI;
360
361    for _ in 0..num_bindings {
362        let mut bindings: HashMap<String, f64> = HashMap::new();
363        for &sym in symbols {
364            let val = rng.random_range(-two_pi..two_pi);
365            bindings.insert(sym.to_string(), val);
366        }
367
368        if let Some(conds) = conditions {
369            for cond in conds {
370                adjust_binding_for_condition(&mut bindings, cond, symbols);
371            }
372        }
373
374        if let Some(conds) = conditions {
375            let bindings_ref: Option<HashMap<&str, f64>> =
376                Some(bindings.iter().map(|(k, &v)| (k.as_str(), v)).collect());
377            let all_satisfied = conds.iter().all(|c| match c {
378                Condition::Eq(a, b) => {
379                    if let (Ok(va), Ok(vb)) = (a.evaluate(&bindings_ref), b.evaluate(&bindings_ref))
380                    {
381                        (va - vb).abs() < RULE_CONDITION_TOLERANCE
382                    } else {
383                        false
384                    }
385                }
386                Condition::EqMod(a, b, m) => {
387                    if let (Ok(va), Ok(vb), Ok(vm)) = (
388                        a.evaluate(&bindings_ref),
389                        b.evaluate(&bindings_ref),
390                        m.evaluate(&bindings_ref),
391                    ) {
392                        if vm.abs() < NUMERIC_ZERO_TOLERANCE {
393                            false
394                        } else {
395                            let remainder = (va - vb) % vm;
396                            remainder.abs() < RULE_CONDITION_TOLERANCE
397                                || (vm - remainder).abs() < RULE_CONDITION_TOLERANCE
398                        }
399                    } else {
400                        false
401                    }
402                }
403            });
404            if !all_satisfied {
405                continue;
406            }
407        }
408
409        result.push(bindings);
410    }
411
412    result
413}
414
415/// Adjust one binding to satisfy a condition.
416fn adjust_binding_for_condition(
417    bindings: &mut HashMap<String, f64>,
418    condition: &Condition,
419    symbols: &[&str],
420) {
421    match condition {
422        Condition::Eq(lhs, rhs) => {
423            adjust_for_equality(bindings, lhs, rhs, None, symbols);
424        }
425        Condition::EqMod(lhs, rhs, modulus) => {
426            if let Ok(mod_val) = modulus.evaluate(&Some(
427                bindings.iter().map(|(k, &v)| (k.as_str(), v)).collect(),
428            )) && mod_val.abs() > NUMERIC_ZERO_TOLERANCE
429            {
430                adjust_for_equality(bindings, lhs, rhs, Some(mod_val), symbols);
431            }
432        }
433    }
434}
435
436/// Adjust one binding so that `lhs == rhs`, or `lhs == rhs mod modulus`.
437fn adjust_for_equality(
438    bindings: &mut HashMap<String, f64>,
439    lhs: &Parameter,
440    rhs: &Parameter,
441    modulus: Option<f64>,
442    symbols: &[&str],
443) {
444    if try_adjust_symbol(bindings, rhs, lhs, modulus, symbols) {
445        return;
446    }
447    try_adjust_symbol(bindings, lhs, rhs, modulus, symbols);
448}
449
450/// Try to adjust one symbol in `expr` so that `expr == target mod modulus`.
451fn try_adjust_symbol(
452    bindings: &mut HashMap<String, f64>,
453    expr: &Parameter,
454    target: &Parameter,
455    modulus: Option<f64>,
456    symbols: &[&str],
457) -> bool {
458    let expr_syms = expr.get_symbols();
459    let target_sym = symbols.iter().find(|&&s| expr_syms.contains(s));
460
461    let Some(&sym) = target_sym else {
462        return false;
463    };
464
465    let bindings_ref: Option<HashMap<&str, f64>> =
466        Some(bindings.iter().map(|(k, &v)| (k.as_str(), v)).collect());
467
468    let Ok(target_val) = target.evaluate(&bindings_ref) else {
469        return false;
470    };
471
472    let bindings_without: Option<HashMap<&str, f64>> = Some(
473        bindings
474            .iter()
475            .map(|(k, &v)| {
476                if k == sym {
477                    (k.as_str(), 0.0)
478                } else {
479                    (k.as_str(), v)
480                }
481            })
482            .collect(),
483    );
484    let bindings_one: Option<HashMap<&str, f64>> = Some(
485        bindings
486            .iter()
487            .map(|(k, &v)| {
488                if k == sym {
489                    (k.as_str(), 1.0)
490                } else {
491                    (k.as_str(), v)
492                }
493            })
494            .collect(),
495    );
496    let (Ok(expr_zero), Ok(expr_one)) = (
497        expr.evaluate(&bindings_without),
498        expr.evaluate(&bindings_one),
499    ) else {
500        return false;
501    };
502
503    let coeff = expr_one - expr_zero;
504    if coeff.abs() < NUMERIC_ZERO_TOLERANCE {
505        return false;
506    }
507
508    let current_sym_val = bindings.get(sym).copied().unwrap_or(0.0);
509
510    if let Some(mod_val) = modulus {
511        let sym_for_k0 = (target_val - expr_zero) / coeff;
512        let k = ((current_sym_val - sym_for_k0) / mod_val).round();
513        let adjusted = sym_for_k0 + k * mod_val;
514        bindings.insert(sym.to_string(), adjusted);
515    } else {
516        let new_val = (target_val - expr_zero) / coeff;
517        bindings.insert(sym.to_string(), new_val);
518    }
519
520    true
521}
522
523#[cfg(test)]
524#[path = "rule_equivalence_test.rs"]
525mod rule_equivalence_test;