Skip to main content

quantrs2_anneal/
chain_break.rs

1//! Chain break resolution algorithms for quantum annealing
2//!
3//! When logical variables are embedded onto physical qubits using chains,
4//! the physical qubits in a chain may disagree in the solution. This module
5//! provides algorithms to resolve these chain breaks.
6
7use crate::embedding::Embedding;
8use crate::ising::{IsingError, IsingResult};
9use scirs2_core::random::{thread_rng, ChaCha8Rng, Rng, RngExt, SeedableRng};
10use std::collections::{HashMap, HashSet};
11
12/// Represents a solution from quantum annealing hardware
13#[derive(Debug, Clone)]
14pub struct HardwareSolution {
15    /// Values of physical qubits (spin values: +1 or -1)
16    pub spins: Vec<i8>,
17    /// Energy of this solution
18    pub energy: f64,
19    /// Number of occurrences (for multiple reads)
20    pub occurrences: usize,
21}
22
23/// Resolved solution after chain break resolution
24#[derive(Debug, Clone)]
25pub struct ResolvedSolution {
26    /// Values of logical variables
27    pub logical_spins: Vec<i8>,
28    /// Number of broken chains
29    pub chain_breaks: usize,
30    /// Energy of the resolved *logical* configuration.
31    ///
32    /// When a [`LogicalProblem`] is supplied to the resolver this is the exact
33    /// energy of `logical_spins` under that problem. When no logical problem is
34    /// available the resolver cannot recompute the logical energy, so it reports
35    /// the measured hardware energy (a real quantity) as the best available
36    /// estimate.
37    pub energy: f64,
38    /// Original hardware solution
39    pub hardware_solution: HardwareSolution,
40}
41
42/// Chain break resolution method
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ResolutionMethod {
45    /// Take majority vote within each chain
46    MajorityVote,
47    /// Minimize energy of the logical problem
48    EnergyMinimization,
49    /// Use weighted majority based on coupling strengths
50    WeightedMajority,
51    /// Discard solutions with broken chains
52    Discard,
53}
54
55/// Chain break resolver
56pub struct ChainBreakResolver {
57    /// Resolution method to use
58    pub method: ResolutionMethod,
59    /// Tie-breaking strategy for majority vote
60    pub tie_break_random: bool,
61    /// Random seed for tie-breaking
62    pub seed: Option<u64>,
63}
64
65impl Default for ChainBreakResolver {
66    fn default() -> Self {
67        Self {
68            method: ResolutionMethod::MajorityVote,
69            tie_break_random: true,
70            seed: None,
71        }
72    }
73}
74
75impl ChainBreakResolver {
76    /// Resolve chain breaks in a single hardware solution
77    pub fn resolve_solution(
78        &self,
79        hardware_solution: &HardwareSolution,
80        embedding: &Embedding,
81        logical_problem: Option<&LogicalProblem>,
82    ) -> IsingResult<ResolvedSolution> {
83        match self.method {
84            ResolutionMethod::MajorityVote => {
85                self.resolve_majority_vote(hardware_solution, embedding, logical_problem)
86            }
87            ResolutionMethod::WeightedMajority => {
88                self.resolve_weighted_majority(hardware_solution, embedding, logical_problem)
89            }
90            ResolutionMethod::EnergyMinimization => {
91                let problem = logical_problem.ok_or_else(|| {
92                    IsingError::InvalidValue(
93                        "Energy minimization requires logical problem".to_string(),
94                    )
95                })?;
96                self.resolve_energy_minimization(hardware_solution, embedding, problem)
97            }
98            ResolutionMethod::Discard => self.resolve_discard(hardware_solution, embedding),
99        }
100    }
101
102    /// Resolve multiple hardware solutions
103    pub fn resolve_solutions(
104        &self,
105        hardware_solutions: &[HardwareSolution],
106        embedding: &Embedding,
107        logical_problem: Option<&LogicalProblem>,
108    ) -> IsingResult<Vec<ResolvedSolution>> {
109        let mut resolved = Vec::new();
110
111        for hw_solution in hardware_solutions {
112            match self.resolve_solution(hw_solution, embedding, logical_problem) {
113                Ok(solution) => resolved.push(solution),
114                Err(_) if self.method == ResolutionMethod::Discard => {
115                    // Skip broken solutions when using discard method
116                    continue;
117                }
118                Err(e) => return Err(e),
119            }
120        }
121
122        // Sort by energy
123        resolved.sort_by(|a, b| a.energy.total_cmp(&b.energy));
124
125        Ok(resolved)
126    }
127
128    /// Resolve using majority vote
129    fn resolve_majority_vote(
130        &self,
131        hardware_solution: &HardwareSolution,
132        embedding: &Embedding,
133        logical_problem: Option<&LogicalProblem>,
134    ) -> IsingResult<ResolvedSolution> {
135        let mut logical_spins = Vec::new();
136        let mut chain_breaks = 0;
137        let num_vars = embedding.chains.len();
138        let mut rng = self.tie_break_rng();
139
140        for var in 0..num_vars {
141            let chain = embedding
142                .chains
143                .get(&var)
144                .ok_or_else(|| IsingError::InvalidQubit(var))?;
145
146            // Count votes
147            let mut plus_votes = 0;
148            let mut minus_votes = 0;
149
150            for &qubit in chain {
151                if qubit >= hardware_solution.spins.len() {
152                    return Err(IsingError::InvalidQubit(qubit));
153                }
154
155                match hardware_solution.spins[qubit] {
156                    1 => plus_votes += 1,
157                    -1 => minus_votes += 1,
158                    _ => return Err(IsingError::InvalidValue("Invalid spin value".to_string())),
159                }
160            }
161
162            // Determine logical value
163            let logical_value = if plus_votes > minus_votes {
164                1
165            } else if minus_votes > plus_votes {
166                -1
167            } else {
168                // Genuine tie: break it with the (optionally seeded) RNG when
169                // random tie-breaking is enabled, otherwise default to +1.
170                self.break_tie(&mut rng)
171            };
172
173            // Check for chain breaks
174            let unanimous = plus_votes == 0 || minus_votes == 0;
175            if !unanimous {
176                chain_breaks += 1;
177            }
178
179            logical_spins.push(logical_value);
180        }
181
182        let energy = Self::resolved_energy(&logical_spins, hardware_solution, logical_problem);
183
184        Ok(ResolvedSolution {
185            logical_spins,
186            chain_breaks,
187            energy,
188            hardware_solution: hardware_solution.clone(),
189        })
190    }
191
192    /// Resolve using weighted majority based on coupling strengths
193    fn resolve_weighted_majority(
194        &self,
195        hardware_solution: &HardwareSolution,
196        embedding: &Embedding,
197        logical_problem: Option<&LogicalProblem>,
198    ) -> IsingResult<ResolvedSolution> {
199        // Weighted majority voting: weight each qubit's vote by the number of
200        // other qubits in the chain that agree with it. This gives more influence
201        // to qubits that are part of a larger consensus.
202
203        let num_vars = embedding.chains.len();
204        let mut logical_spins = vec![0i8; num_vars];
205        let mut chain_breaks = 0;
206        let mut rng = self.tie_break_rng();
207
208        for var in 0..num_vars {
209            if let Some(chain) = embedding.chains.get(&var) {
210                if chain.is_empty() {
211                    return Err(IsingError::InvalidValue(format!(
212                        "Empty chain for variable {var}"
213                    )));
214                }
215
216                if chain.len() == 1 {
217                    // Single qubit chain - no possibility of chain break
218                    logical_spins[var] = hardware_solution.spins[chain[0]];
219                    continue;
220                }
221
222                // Calculate weighted votes for +1 and -1
223                let mut weight_plus = 0.0;
224                let mut weight_minus = 0.0;
225                let mut has_disagreement = false;
226
227                for &qubit_i in chain {
228                    let spin_i = hardware_solution.spins[qubit_i];
229
230                    // Calculate weight: count how many qubits in the chain agree with this one
231                    let mut agreement_count = 0.0;
232                    for &qubit_j in chain {
233                        if qubit_i != qubit_j && hardware_solution.spins[qubit_j] == spin_i {
234                            agreement_count += 1.0;
235                        }
236                    }
237
238                    // Weight is: 1.0 (base) + agreement_count (bonus for consensus)
239                    let weight = 1.0 + agreement_count;
240
241                    if spin_i == 1 {
242                        weight_plus += weight;
243                    } else if spin_i == -1 {
244                        weight_minus += weight;
245                    }
246
247                    // Check for disagreement
248                    if hardware_solution.spins[chain[0]] != spin_i {
249                        has_disagreement = true;
250                    }
251                }
252
253                // Choose the spin value with higher weighted vote
254                if weight_plus > weight_minus {
255                    logical_spins[var] = 1;
256                } else if weight_minus > weight_plus {
257                    logical_spins[var] = -1;
258                } else {
259                    // Genuine tie: break it with the (optionally seeded) RNG
260                    // when random tie-breaking is enabled, otherwise fall back
261                    // to the first qubit's measured spin.
262                    logical_spins[var] = if self.tie_break_random {
263                        self.break_tie(&mut rng)
264                    } else {
265                        hardware_solution.spins[chain[0]]
266                    };
267                }
268
269                if has_disagreement {
270                    chain_breaks += 1;
271                }
272            }
273        }
274
275        let energy = Self::resolved_energy(&logical_spins, hardware_solution, logical_problem);
276
277        Ok(ResolvedSolution {
278            logical_spins,
279            chain_breaks,
280            energy,
281            hardware_solution: hardware_solution.clone(),
282        })
283    }
284
285    /// Resolve by minimizing energy of logical problem
286    fn resolve_energy_minimization(
287        &self,
288        hardware_solution: &HardwareSolution,
289        embedding: &Embedding,
290        logical_problem: &LogicalProblem,
291    ) -> IsingResult<ResolvedSolution> {
292        let mut resolved =
293            self.resolve_majority_vote(hardware_solution, embedding, Some(logical_problem))?;
294
295        // For each broken chain, try flipping the logical variable
296        for var in 0..resolved.logical_spins.len() {
297            if self.is_chain_broken(var, hardware_solution, embedding)? {
298                // Calculate energy with current value
299                let current_energy = logical_problem.calculate_energy(&resolved.logical_spins);
300
301                // Flip and calculate energy
302                resolved.logical_spins[var] *= -1;
303                let flipped_energy = logical_problem.calculate_energy(&resolved.logical_spins);
304
305                // Keep the flip if it lowers energy
306                if flipped_energy >= current_energy {
307                    resolved.logical_spins[var] *= -1; // Flip back
308                }
309            }
310        }
311
312        // Recalculate final energy
313        resolved.energy = logical_problem.calculate_energy(&resolved.logical_spins);
314
315        Ok(resolved)
316    }
317
318    /// Discard solutions with broken chains
319    fn resolve_discard(
320        &self,
321        hardware_solution: &HardwareSolution,
322        embedding: &Embedding,
323    ) -> IsingResult<ResolvedSolution> {
324        let resolved = self.resolve_majority_vote(hardware_solution, embedding, None)?;
325
326        if resolved.chain_breaks > 0 {
327            Err(IsingError::HardwareConstraint(format!(
328                "Solution has {} broken chains",
329                resolved.chain_breaks
330            )))
331        } else {
332            Ok(resolved)
333        }
334    }
335
336    /// Check if a chain is broken
337    fn is_chain_broken(
338        &self,
339        var: usize,
340        hardware_solution: &HardwareSolution,
341        embedding: &Embedding,
342    ) -> IsingResult<bool> {
343        let chain = embedding
344            .chains
345            .get(&var)
346            .ok_or_else(|| IsingError::InvalidQubit(var))?;
347
348        if chain.is_empty() {
349            return Ok(false);
350        }
351
352        let first_spin = hardware_solution.spins[chain[0]];
353
354        for &qubit in &chain[1..] {
355            if hardware_solution.spins[qubit] != first_spin {
356                return Ok(true);
357            }
358        }
359
360        Ok(false)
361    }
362
363    /// Construct the RNG used for tie-breaking.
364    ///
365    /// When a `seed` is configured the generator is deterministic (reproducible
366    /// runs); otherwise it is seeded from the thread RNG so ties are broken
367    /// genuinely at random.
368    fn tie_break_rng(&self) -> ChaCha8Rng {
369        match self.seed {
370            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
371            None => ChaCha8Rng::seed_from_u64(thread_rng().random()),
372        }
373    }
374
375    /// Break a vote tie. With `tie_break_random` enabled this draws a genuine
376    /// random spin from `rng`; otherwise it deterministically defaults to `+1`.
377    fn break_tie(&self, rng: &mut ChaCha8Rng) -> i8 {
378        if self.tie_break_random {
379            if rng.random_bool(0.5) {
380                1
381            } else {
382                -1
383            }
384        } else {
385            1
386        }
387    }
388
389    /// Energy of the resolved logical configuration.
390    ///
391    /// If a [`LogicalProblem`] is available the exact logical energy is
392    /// computed from the resolved spins. Otherwise the measured hardware energy
393    /// is returned, since the logical energy is not derivable without the
394    /// problem definition.
395    fn resolved_energy(
396        logical_spins: &[i8],
397        hardware_solution: &HardwareSolution,
398        logical_problem: Option<&LogicalProblem>,
399    ) -> f64 {
400        match logical_problem {
401            Some(problem) => problem.calculate_energy(logical_spins),
402            None => hardware_solution.energy,
403        }
404    }
405}
406
407/// Represents a logical problem (QUBO or Ising)
408#[derive(Debug, Clone)]
409pub struct LogicalProblem {
410    /// Linear coefficients (`h_i` in Ising, diagonal in QUBO)
411    pub linear: Vec<f64>,
412    /// Quadratic coefficients as adjacency list
413    pub quadratic: HashMap<(usize, usize), f64>,
414    /// Constant offset
415    pub offset: f64,
416}
417
418impl LogicalProblem {
419    /// Create a new logical problem
420    #[must_use]
421    pub fn new(num_vars: usize) -> Self {
422        Self {
423            linear: vec![0.0; num_vars],
424            quadratic: HashMap::new(),
425            offset: 0.0,
426        }
427    }
428
429    /// Calculate energy for a given spin configuration
430    #[must_use]
431    pub fn calculate_energy(&self, spins: &[i8]) -> f64 {
432        let mut energy = self.offset;
433
434        // Linear terms
435        for (i, &h) in self.linear.iter().enumerate() {
436            if i < spins.len() {
437                energy += h * f64::from(spins[i]);
438            }
439        }
440
441        // Quadratic terms
442        for (&(i, j), &J) in &self.quadratic {
443            if i < spins.len() && j < spins.len() {
444                energy += J * f64::from(spins[i]) * f64::from(spins[j]);
445            }
446        }
447
448        energy
449    }
450
451    /// Convert from QUBO to Ising representation
452    pub fn from_qubo(qubo_matrix: &[Vec<f64>], offset: f64) -> IsingResult<Self> {
453        let n = qubo_matrix.len();
454        let mut problem = Self::new(n);
455        problem.offset = offset;
456
457        // Convert QUBO Q_ij to Ising h_i and J_ij
458        // x_i = (s_i + 1) / 2
459        // Minimize x^T Q x becomes minimize sum_i h_i s_i + sum_{i<j} J_ij s_i s_j
460
461        for i in 0..n {
462            for j in i..n {
463                let q_ij = qubo_matrix[i][j];
464                if q_ij.abs() > 1e-10 {
465                    problem.offset += q_ij / 4.0;
466                    if i == j {
467                        // Diagonal term contributes to linear coefficient
468                        problem.linear[i] += q_ij / 2.0;
469                    } else {
470                        // Off-diagonal term
471                        problem.quadratic.insert((i, j), q_ij / 4.0);
472                        problem.linear[i] += q_ij / 4.0;
473                        problem.linear[j] += q_ij / 4.0;
474                    }
475                }
476            }
477        }
478
479        Ok(problem)
480    }
481}
482
483/// Chain strength optimizer
484pub struct ChainStrengthOptimizer {
485    /// Minimum chain strength
486    pub min_strength: f64,
487    /// Maximum chain strength
488    pub max_strength: f64,
489    /// Number of strength values to try
490    pub num_tries: usize,
491}
492
493impl Default for ChainStrengthOptimizer {
494    fn default() -> Self {
495        Self {
496            min_strength: 0.1,
497            max_strength: 10.0,
498            num_tries: 10,
499        }
500    }
501}
502
503impl ChainStrengthOptimizer {
504    /// Find optimal chain strength by analyzing the problem
505    #[must_use]
506    pub fn find_optimal_strength(&self, logical_problem: &LogicalProblem) -> f64 {
507        // Calculate statistics of the logical problem coefficients
508        let mut all_coeffs = Vec::new();
509
510        // Add linear coefficients
511        for &h in &logical_problem.linear {
512            if h.abs() > 1e-10 {
513                all_coeffs.push(h.abs());
514            }
515        }
516
517        // Add quadratic coefficients
518        for &J in logical_problem.quadratic.values() {
519            if J.abs() > 1e-10 {
520                all_coeffs.push(J.abs());
521            }
522        }
523
524        if all_coeffs.is_empty() {
525            return 1.0; // Default strength
526        }
527
528        // Sort coefficients
529        all_coeffs.sort_by(|a, b| a.total_cmp(b));
530
531        // Use median as base strength
532        let median = if all_coeffs.len() % 2 == 0 {
533            f64::midpoint(
534                all_coeffs[all_coeffs.len() / 2 - 1],
535                all_coeffs[all_coeffs.len() / 2],
536            )
537        } else {
538            all_coeffs[all_coeffs.len() / 2]
539        };
540
541        // Chain strength should be strong enough to keep chains together
542        // but not so strong as to dominate the problem
543        (median * 1.5).max(self.min_strength).min(self.max_strength)
544    }
545
546    /// Optimize chain strength through multiple runs
547    #[must_use]
548    pub fn optimize_strength(
549        &self,
550        logical_problem: &LogicalProblem,
551        test_solutions: &[Vec<i8>],
552    ) -> f64 {
553        let mut best_strength = self.find_optimal_strength(logical_problem);
554        let mut best_score = f64::INFINITY;
555
556        // Try different strengths
557        let step = (self.max_strength - self.min_strength) / (self.num_tries as f64);
558
559        for i in 0..self.num_tries {
560            let strength = (i as f64).mul_add(step, self.min_strength);
561
562            // Evaluate this strength
563            let score = self.evaluate_strength(strength, logical_problem, test_solutions);
564
565            if score < best_score {
566                best_score = score;
567                best_strength = strength;
568            }
569        }
570
571        best_strength
572    }
573
574    /// Evaluate a chain strength against a set of representative solutions.
575    ///
576    /// A chain holds together only if its inter-qubit coupling (the chain
577    /// `strength`) is large enough to resist the local field that tries to flip
578    /// part of the chain. For each test solution and each logical variable we
579    /// compute that local field magnitude
580    /// `|h_i + Σ_j J_ij·s_j|` (the energy gradient acting on variable `i`); the
581    /// strength must dominate the largest such field to keep every chain intact.
582    /// The returned score (lower is better, so it composes with
583    /// [`Self::optimize_strength`]'s minimization) penalizes:
584    ///
585    /// * **under-strength** — `strength` below the required field, scaled by a
586    ///   large factor because a broken chain corrupts the embedded solution; and
587    /// * **over-strength** — `strength` far above what is needed, which flattens
588    ///   the logical problem and degrades solution quality.
589    ///
590    /// When no test solutions are supplied the worst-case field is estimated
591    /// from the problem coefficients alone (every neighbor aligned adversarially).
592    fn evaluate_strength(
593        &self,
594        strength: f64,
595        logical_problem: &LogicalProblem,
596        test_solutions: &[Vec<i8>],
597    ) -> f64 {
598        let num_vars = logical_problem.linear.len();
599
600        // Largest local field observed across variables and test solutions.
601        let mut required_strength = 0.0_f64;
602
603        if test_solutions.is_empty() {
604            // Worst case: every coupling pulls in the breaking direction.
605            for i in 0..num_vars {
606                let mut field = logical_problem.linear[i].abs();
607                for (&(a, b), &j) in &logical_problem.quadratic {
608                    if a == i || b == i {
609                        field += j.abs();
610                    }
611                }
612                required_strength = required_strength.max(field);
613            }
614        } else {
615            for solution in test_solutions {
616                for i in 0..num_vars.min(solution.len()) {
617                    let mut field = logical_problem.linear[i];
618                    for (&(a, b), &j) in &logical_problem.quadratic {
619                        if a == i {
620                            if let Some(&s) = solution.get(b) {
621                                field += j * f64::from(s);
622                            }
623                        } else if b == i {
624                            if let Some(&s) = solution.get(a) {
625                                field += j * f64::from(s);
626                            }
627                        }
628                    }
629                    required_strength = required_strength.max(field.abs());
630                }
631            }
632        }
633
634        if required_strength <= 0.0 {
635            // No field to resist: any positive strength is fine; prefer the
636            // smallest to avoid distorting the (trivial) problem.
637            return strength;
638        }
639
640        // Penalty for being too weak (chains break) — heavily weighted.
641        const BREAK_PENALTY: f64 = 10.0;
642        let under = (required_strength - strength).max(0.0) / required_strength;
643
644        // Penalty for being unnecessarily strong (problem distortion).
645        let over = (strength - required_strength).max(0.0) / required_strength;
646
647        BREAK_PENALTY.mul_add(under, over)
648    }
649}
650
651/// Statistics about chain breaks
652#[derive(Debug, Clone, Default)]
653pub struct ChainBreakStats {
654    /// Total number of chains
655    pub total_chains: usize,
656    /// Number of broken chains per solution
657    pub broken_chains: Vec<usize>,
658    /// Chain break rate
659    pub break_rate: f64,
660    /// Most frequently broken variables
661    pub frequent_breaks: Vec<(usize, usize)>,
662}
663
664impl ChainBreakStats {
665    /// Analyze chain breaks across multiple solutions
666    pub fn analyze(
667        hardware_solutions: &[HardwareSolution],
668        embedding: &Embedding,
669    ) -> IsingResult<Self> {
670        let total_chains = embedding.chains.len();
671        let mut broken_chains = Vec::new();
672        let mut break_counts: HashMap<usize, usize> = HashMap::new();
673
674        for hw_solution in hardware_solutions {
675            let mut breaks_in_solution = 0;
676
677            for (&var, chain) in &embedding.chains {
678                if chain.len() > 1 {
679                    let first_spin = hw_solution.spins[chain[0]];
680                    let is_broken = chain[1..]
681                        .iter()
682                        .any(|&q| hw_solution.spins[q] != first_spin);
683
684                    if is_broken {
685                        breaks_in_solution += 1;
686                        *break_counts.entry(var).or_insert(0) += 1;
687                    }
688                }
689            }
690
691            broken_chains.push(breaks_in_solution);
692        }
693
694        // Calculate statistics
695        let total_breaks: usize = broken_chains.iter().sum();
696        let break_rate = if hardware_solutions.is_empty() || total_chains == 0 {
697            0.0
698        } else {
699            total_breaks as f64 / (hardware_solutions.len() * total_chains) as f64
700        };
701
702        // Find most frequently broken variables
703        let mut frequent_breaks: Vec<(usize, usize)> = break_counts.into_iter().collect();
704        frequent_breaks.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
705        frequent_breaks.truncate(10); // Keep top 10
706
707        Ok(Self {
708            total_chains,
709            broken_chains,
710            break_rate,
711            frequent_breaks,
712        })
713    }
714
715    /// Get recommendations based on statistics
716    #[must_use]
717    pub fn get_recommendations(&self) -> Vec<String> {
718        let mut recommendations = Vec::new();
719
720        if self.break_rate > 0.5 {
721            recommendations.push(
722                "High chain break rate detected. Consider increasing chain strength.".to_string(),
723            );
724        }
725
726        if self.break_rate > 0.2 {
727            recommendations.push(
728                "Moderate chain breaks. Try optimizing embedding or chain strength.".to_string(),
729            );
730        }
731
732        if !self.frequent_breaks.is_empty() {
733            let vars: Vec<String> = self
734                .frequent_breaks
735                .iter()
736                .take(3)
737                .map(|(var, _)| var.to_string())
738                .collect();
739            recommendations.push(format!(
740                "Variables {} frequently have broken chains. Check embedding quality.",
741                vars.join(", ")
742            ));
743        }
744
745        recommendations
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    #[test]
754    fn test_majority_vote_resolution() {
755        let mut embedding = Embedding::new();
756        embedding
757            .add_chain(0, vec![0, 1, 2])
758            .expect("failed to add chain in test");
759        embedding
760            .add_chain(1, vec![3, 4, 5])
761            .expect("failed to add chain in test");
762
763        let hw_solution = HardwareSolution {
764            spins: vec![1, 1, -1, -1, -1, -1], // First chain: 2 vs 1, second: unanimous
765            energy: -1.0,
766            occurrences: 1,
767        };
768
769        let resolver = ChainBreakResolver::default();
770        let resolved = resolver
771            .resolve_solution(&hw_solution, &embedding, None)
772            .expect("failed to resolve solution in test");
773
774        assert_eq!(resolved.logical_spins, vec![1, -1]);
775        assert_eq!(resolved.chain_breaks, 1); // First chain is broken
776    }
777
778    #[test]
779    fn test_chain_strength_optimizer() {
780        let mut problem = LogicalProblem::new(3);
781        problem.linear = vec![1.0, -0.5, 0.0];
782        problem.quadratic.insert((0, 1), -2.0);
783        problem.quadratic.insert((1, 2), 1.5);
784
785        let optimizer = ChainStrengthOptimizer::default();
786        let strength = optimizer.find_optimal_strength(&problem);
787
788        // Should be around the median of coefficients
789        assert!(strength > 0.5 && strength < 5.0);
790    }
791
792    #[test]
793    fn test_evaluate_strength_uses_required_field() {
794        // Single coupling J=2 between var 0 and 1; with the test solution both
795        // aligned (+1,+1) the local field on each variable is |J·s| = 2, so the
796        // required chain strength is 2.0.
797        let mut problem = LogicalProblem::new(2);
798        problem.linear = vec![0.0, 0.0];
799        problem.quadratic.insert((0, 1), 2.0);
800
801        let optimizer = ChainStrengthOptimizer::default();
802        let test_solutions = vec![vec![1_i8, 1]];
803
804        // A strength below the required field is penalized far more heavily than
805        // a strength exactly at the required field.
806        let score_weak = optimizer.evaluate_strength(0.5, &problem, &test_solutions);
807        let score_matched = optimizer.evaluate_strength(2.0, &problem, &test_solutions);
808        let score_strong = optimizer.evaluate_strength(6.0, &problem, &test_solutions);
809
810        // The matched strength is the best (lowest score).
811        assert!(score_matched < score_weak);
812        assert!(score_matched < score_strong);
813        // At the required strength both penalties vanish.
814        assert!(score_matched.abs() < 1e-12);
815        // Under-strength is penalized by the heavy BREAK_PENALTY factor relative
816        // to an equal over-strength deviation.
817        let score_under = optimizer.evaluate_strength(1.0, &problem, &test_solutions); // 1 below
818        let score_over = optimizer.evaluate_strength(3.0, &problem, &test_solutions); // 1 above
819        assert!(score_under > score_over);
820
821        // optimize_strength must pick a strength resisting the field (>= ~2).
822        let best = optimizer.optimize_strength(&problem, &test_solutions);
823        assert!(
824            best >= 1.5,
825            "expected strength resisting the field, got {best}"
826        );
827    }
828
829    #[test]
830    fn test_chain_break_stats() {
831        let mut embedding = Embedding::new();
832        embedding
833            .add_chain(0, vec![0, 1])
834            .expect("failed to add chain in test");
835        embedding
836            .add_chain(1, vec![2, 3])
837            .expect("failed to add chain in test");
838
839        let solutions = vec![
840            HardwareSolution {
841                spins: vec![1, 1, -1, -1], // No breaks
842                energy: -1.0,
843                occurrences: 1,
844            },
845            HardwareSolution {
846                spins: vec![1, -1, -1, -1], // First chain broken
847                energy: -0.5,
848                occurrences: 1,
849            },
850        ];
851
852        let stats = ChainBreakStats::analyze(&solutions, &embedding)
853            .expect("failed to analyze chain break stats in test");
854
855        assert_eq!(stats.total_chains, 2);
856        assert_eq!(stats.broken_chains, vec![0, 1]);
857        assert_eq!(stats.break_rate, 0.25); // 1 break out of 4 chain instances
858    }
859}