Skip to main content

cqlib_core/compile/transform/layout/
greedy.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//! Greedy initial layout.
14//!
15//! The greedy algorithm is a deterministic construction heuristic. It walks the
16//! logical interaction graph from highest to lowest weight, places the most
17//! important two-qubit interactions first, and then fills any idle logical
18//! qubits. It does not insert SWAP operations; it only prepares an initial
19//! mapping for later routing.
20
21use super::analysis::GateInteraction;
22use super::scoring::score_adjacent_interaction;
23use super::{
24    CircuitLayoutAnalysis, Interaction, LayoutDiagnostics, LayoutObjective, LayoutResult,
25    PhysicalLayoutGraph, analyze_circuit_for_layout, is_perfect_layout,
26};
27use crate::circuit::Circuit;
28use crate::compile::CompilerError;
29use crate::device::{Device, Layout, PhysicalQubit};
30use std::cmp::Ordering;
31use std::collections::BTreeMap;
32
33/// Builds a deterministic greedy initial layout from weighted interactions.
34///
35/// The algorithm places higher-weight logical interactions first. For each
36/// interaction it chooses the closest currently feasible physical placement,
37/// using the shared [`LayoutObjective`] as a local tie-breaker for direction
38/// and calibration data. Routing remains a later pass: this function only
39/// selects an initial logical-to-physical mapping.
40///
41/// This is a good default candidate generator for larger devices where VF2 may
42/// be too expensive or where a perfect topology embedding does not exist.
43///
44/// # Errors
45///
46/// Returns [`CompilerError::InvalidInput`] if there are fewer usable physical
47/// qubits than logical qubits, no physical candidate can be selected for a
48/// required placement, or if final objective scoring rejects the layout.
49///
50/// # Examples
51///
52/// ```rust
53/// use cqlib_core::circuit::{Circuit, Qubit};
54/// use cqlib_core::compile::transform::{LayoutObjective, greedy_layout};
55/// use cqlib_core::device::{Device, LogicalQubit, PhysicalQubit};
56///
57/// let device = Device::line("line-3", 3).unwrap();
58/// let objective = LayoutObjective::topology_only();
59///
60/// let mut circuit = Circuit::new(3);
61/// circuit.cx(Qubit::new(0), Qubit::new(1)).unwrap();
62/// circuit.cx(Qubit::new(1), Qubit::new(2)).unwrap();
63///
64/// let result = greedy_layout(&circuit, &device, &objective).unwrap();
65/// assert_eq!(
66///     result.layout.get_physical(LogicalQubit::new(0)),
67///     Some(PhysicalQubit::new(0)),
68/// );
69/// assert!(result.diagnostics.is_perfect);
70/// ```
71pub fn greedy_layout(
72    circuit: &Circuit,
73    device: &Device,
74    objective: &LayoutObjective,
75) -> Result<LayoutResult, CompilerError> {
76    let analysis = analyze_circuit_for_layout(circuit)?;
77    let physical = PhysicalLayoutGraph::from_device(device)?;
78    greedy_layout_prepared(&analysis, &physical, objective)
79}
80
81/// Builds a deterministic greedy layout from an already-built physical graph.
82///
83/// This lower-level entry point is useful when a workflow has already prepared
84/// circuit analysis and physical graph data for one or more layout algorithms.
85pub fn greedy_layout_prepared(
86    analysis: &CircuitLayoutAnalysis,
87    physical: &PhysicalLayoutGraph,
88    objective: &LayoutObjective,
89) -> Result<LayoutResult, CompilerError> {
90    match greedy_layout_candidate_prepared(analysis, physical, objective)? {
91        GreedyCandidateOutcome::Found(result) => Ok(result),
92        GreedyCandidateOutcome::Disconnected { left, right } => Err(CompilerError::InvalidInput(
93            format!("physical qubits {left} and {right} are disconnected in the usable topology"),
94        )),
95    }
96}
97
98/// Structured result used when greedy layout is an optional SABRE prepass.
99pub(crate) enum GreedyCandidateOutcome {
100    Found(LayoutResult),
101    Disconnected {
102        left: PhysicalQubit,
103        right: PhysicalQubit,
104    },
105}
106
107pub(crate) fn greedy_layout_candidate_prepared(
108    analysis: &CircuitLayoutAnalysis,
109    physical: &PhysicalLayoutGraph,
110    objective: &LayoutObjective,
111) -> Result<GreedyCandidateOutcome, CompilerError> {
112    if analysis.logical_qubits.len() > physical.physical_qubits().len() {
113        return Err(CompilerError::InvalidInput(format!(
114            "greedy layout requires at least as many usable physical qubits as logical qubits; got {} logical qubits and {} usable physical qubits",
115            analysis.logical_qubits.len(),
116            physical.physical_qubits().len()
117        )));
118    }
119
120    // Dense indices let the placement loop update mapping and vacancy state
121    // without repeatedly searching the logical-qubit list.
122    let logical_index = analysis
123        .logical_qubits
124        .iter()
125        .copied()
126        .enumerate()
127        .map(|(index, logical)| (logical, index))
128        .collect::<BTreeMap<_, _>>();
129    let mut activity = vec![None; analysis.logical_qubits.len()];
130    for interaction in analysis.interactions.interactions() {
131        for logical in [interaction.left, interaction.right] {
132            let entry = &mut activity[logical_index[&logical]];
133            *entry = Some(entry.unwrap_or(0.0) + interaction.weight);
134        }
135    }
136    let pair_candidates = sorted_physical_pair_candidates(physical);
137    let mut mapping = vec![None; analysis.logical_qubits.len()];
138    let mut vacant = vec![true; physical.physical_qubits().len()];
139    let mut candidates_evaluated = 0usize;
140
141    // Highest-weight interactions are placed first. Stable tie-breakers keep
142    // the result reproducible when several edges have equal weight.
143    let mut interaction_indices = analysis
144        .interactions
145        .interactions()
146        .iter()
147        .enumerate()
148        .filter(|(_, interaction)| interaction.weight > 0.0)
149        .map(|(index, _)| index)
150        .collect::<Vec<_>>();
151    interaction_indices.sort_by(|a, b| {
152        let a = &analysis.interactions.interactions()[*a];
153        let b = &analysis.interactions.interactions()[*b];
154        b.weight
155            .total_cmp(&a.weight)
156            .then_with(|| a.first_seen_order.cmp(&b.first_seen_order))
157            .then_with(|| a.left.cmp(&b.left))
158            .then_with(|| a.right.cmp(&b.right))
159    });
160
161    for interaction_index in interaction_indices {
162        let interaction = &analysis.interactions.interactions()[interaction_index];
163        let scored_interaction = ScoredInteraction {
164            interaction,
165            gate_contributions: analysis.interactions.gate_contributions(interaction_index),
166        };
167        let left_logical = logical_index[&interaction.left];
168        let right_logical = logical_index[&interaction.right];
169        match (mapping[left_logical], mapping[right_logical]) {
170            (Some(_), Some(_)) => {}
171            // If one endpoint is already placed, expand from that anchor.
172            (Some(left_physical), None) => {
173                let (right_physical, evaluated) = choose_single_candidate(
174                    scored_interaction,
175                    right_logical,
176                    left_physical,
177                    true,
178                    &vacant,
179                    physical,
180                    objective,
181                    &activity,
182                )?;
183                candidates_evaluated += evaluated;
184                mapping[right_logical] = Some(right_physical);
185                vacant[right_physical] = false;
186            }
187            (None, Some(right_physical)) => {
188                let (left_physical, evaluated) = choose_single_candidate(
189                    scored_interaction,
190                    left_logical,
191                    right_physical,
192                    false,
193                    &vacant,
194                    physical,
195                    objective,
196                    &activity,
197                )?;
198                candidates_evaluated += evaluated;
199                mapping[left_logical] = Some(left_physical);
200                vacant[left_physical] = false;
201            }
202            // If neither endpoint is placed, choose the best ordered physical
203            // pair and reserve both qubits.
204            (None, None) => {
205                let (left_physical, right_physical, evaluated) = choose_pair_candidate(
206                    scored_interaction,
207                    left_logical,
208                    right_logical,
209                    &vacant,
210                    physical,
211                    objective,
212                    &activity,
213                    &pair_candidates,
214                )?;
215                candidates_evaluated += evaluated;
216                mapping[left_logical] = Some(left_physical);
217                mapping[right_logical] = Some(right_physical);
218                vacant[left_physical] = false;
219                vacant[right_physical] = false;
220            }
221        }
222    }
223
224    for logical_index in 0..analysis.logical_qubits.len() {
225        if mapping[logical_index].is_some() {
226            continue;
227        }
228        // Logical qubits with no positive two-qubit interactions still need a
229        // deterministic physical assignment for a complete Layout.
230        let (physical_qubit, evaluated) = choose_idle_candidate(
231            logical_index,
232            &mapping,
233            &vacant,
234            physical,
235            objective,
236            &activity,
237        )?;
238        candidates_evaluated += evaluated;
239        mapping[logical_index] = Some(physical_qubit);
240        vacant[physical_qubit] = false;
241    }
242
243    let mut layout_mapping = BTreeMap::new();
244    for (logical_index, physical_index) in mapping.iter().copied().enumerate() {
245        let physical_index = physical_index.ok_or_else(|| {
246            CompilerError::InvariantViolation(
247                "greedy layout left a logical qubit unmapped".to_string(),
248            )
249        })?;
250        let physical_qubit = physical.physical_at(physical_index).ok_or_else(|| {
251            CompilerError::InvariantViolation(format!(
252                "greedy layout selected physical index {physical_index} outside target topology"
253            ))
254        })?;
255        layout_mapping.insert(analysis.logical_qubits[logical_index], physical_qubit);
256    }
257    let layout = Layout::new(
258        analysis.logical_qubits.clone(),
259        physical.physical_qubits().to_vec(),
260        Some(layout_mapping),
261    )
262    .map_err(|error| {
263        CompilerError::InvariantViolation(format!(
264            "greedy layout failed to construct a valid layout: {error}"
265        ))
266    })?;
267
268    if let Some((left, right)) = analysis
269        .interactions
270        .interactions()
271        .iter()
272        .filter_map(|interaction| {
273            Some((
274                layout.get_physical(interaction.left)?,
275                layout.get_physical(interaction.right)?,
276            ))
277        })
278        .find(|(left, right)| physical.distance(*left, *right).is_none())
279    {
280        return Ok(GreedyCandidateOutcome::Disconnected { left, right });
281    }
282    let score = objective.score_layout(analysis, physical, &layout)?;
283    let diagnostics = LayoutDiagnostics {
284        is_perfect: is_perfect_layout(analysis, physical, &layout),
285        candidates_evaluated,
286        used_fidelity: score.used_fidelity,
287        notes: Vec::new(),
288    };
289
290    Ok(GreedyCandidateOutcome::Found(LayoutResult {
291        layout,
292        score: Some(score),
293        diagnostics,
294    }))
295}
296
297/// Chooses physical qubits for an interaction with both endpoints unmapped.
298///
299/// Connected physical pairs are considered first, ordered by distance and
300/// qubit IDs. If no connected vacant pair remains, all ordered vacant pairs are
301/// considered so the caller gets a deterministic failure or least-bad choice.
302#[allow(clippy::too_many_arguments)]
303fn choose_pair_candidate(
304    interaction: ScoredInteraction<'_>,
305    left_logical: usize,
306    right_logical: usize,
307    vacant: &[bool],
308    physical: &PhysicalLayoutGraph,
309    objective: &LayoutObjective,
310    activity: &[Option<f64>],
311    pair_candidates: &[(usize, usize)],
312) -> Result<(usize, usize, usize), CompilerError> {
313    let mut best: Option<(usize, usize, CandidateCost)> = None;
314    let mut evaluated = 0usize;
315
316    // Prefer connected physical pairs first. If the usable graph is fully
317    // disconnected in the remaining vacant region, the fallback below still
318    // returns a deterministic least-bad pair so final scoring can reject or
319    // route around it consistently.
320    for (left_physical, right_physical) in pair_candidates.iter().copied() {
321        if !vacant[left_physical] || !vacant[right_physical] {
322            continue;
323        }
324        evaluated += 1;
325        update_best_pair(
326            interaction,
327            left_logical,
328            right_logical,
329            left_physical,
330            right_physical,
331            physical,
332            objective,
333            activity,
334            &mut best,
335        );
336    }
337
338    if best.is_none() {
339        for left_physical in 0..vacant.len() {
340            if !vacant[left_physical] {
341                continue;
342            }
343            for (right_physical, item) in vacant.iter().enumerate() {
344                if left_physical == right_physical || !item {
345                    continue;
346                }
347                evaluated += 1;
348                update_best_pair(
349                    interaction,
350                    left_logical,
351                    right_logical,
352                    left_physical,
353                    right_physical,
354                    physical,
355                    objective,
356                    activity,
357                    &mut best,
358                );
359            }
360        }
361    }
362
363    best.map(|(left, right, _)| (left, right, evaluated))
364        .ok_or_else(|| {
365            CompilerError::InvalidInput(
366                "greedy layout could not find two vacant physical qubits for an interaction"
367                    .to_string(),
368            )
369        })
370}
371
372/// Chooses the missing physical endpoint for a partially mapped interaction.
373///
374/// `anchored_physical` is the already-placed endpoint. `anchor_is_left`
375/// indicates whether that endpoint corresponds to the left side of
376/// `interaction`, preserving direction-sensitive scoring.
377#[allow(clippy::too_many_arguments)]
378fn choose_single_candidate(
379    interaction: ScoredInteraction<'_>,
380    candidate_logical: usize,
381    anchored_physical: usize,
382    anchor_is_left: bool,
383    vacant: &[bool],
384    physical: &PhysicalLayoutGraph,
385    objective: &LayoutObjective,
386    activity: &[Option<f64>],
387) -> Result<(usize, usize), CompilerError> {
388    let mut best: Option<(usize, CandidateCost)> = None;
389    let mut evaluated = 0usize;
390
391    // The anchored endpoint fixes one side of the logical interaction; each
392    // vacant physical qubit is evaluated as the other endpoint.
393    for (candidate_physical, item) in vacant.iter().enumerate() {
394        if !item {
395            continue;
396        }
397        evaluated += 1;
398        let (left_physical, right_physical) = if anchor_is_left {
399            (anchored_physical, candidate_physical)
400        } else {
401            (candidate_physical, anchored_physical)
402        };
403        let (left_logical, right_logical) = if anchor_is_left {
404            (usize::MAX, candidate_logical)
405        } else {
406            (candidate_logical, usize::MAX)
407        };
408        let cost = CandidateCost::for_interaction(
409            interaction,
410            left_logical,
411            right_logical,
412            left_physical,
413            right_physical,
414            physical,
415            objective,
416            activity,
417        );
418        let candidate = (candidate_physical, cost);
419        if best.as_ref().is_none_or(|best| {
420            compare_cost(candidate.1, best.1)
421                .then_with(|| {
422                    physical.physical_qubits()[candidate.0].cmp(&physical.physical_qubits()[best.0])
423                })
424                .is_lt()
425        }) {
426            best = Some(candidate);
427        }
428    }
429
430    best.map(|(physical, _)| (physical, evaluated))
431        .ok_or_else(|| {
432            CompilerError::InvalidInput(
433                "greedy layout could not find a vacant physical qubit for an interaction"
434                    .to_string(),
435            )
436        })
437}
438
439/// Chooses a physical qubit for a logical qubit with no placed interaction.
440///
441/// Idle placement prefers candidates that stay near already-used physical
442/// qubits, then uses readout cost and physical ID as deterministic tie-breaks.
443fn choose_idle_candidate(
444    logical: usize,
445    mapping: &[Option<usize>],
446    vacant: &[bool],
447    physical: &PhysicalLayoutGraph,
448    objective: &LayoutObjective,
449    activity: &[Option<f64>],
450) -> Result<(usize, usize), CompilerError> {
451    let active_physical = mapping.iter().flatten().copied().collect::<Vec<_>>();
452    let mut best: Option<(usize, usize, u64, f64)> = None;
453    let mut evaluated = 0usize;
454
455    // Idle qubits are placed near the already-used region when possible. This
456    // keeps future routing options compact without pretending that idle qubits
457    // impose a hard interaction constraint.
458    for (candidate_physical, item) in vacant.iter().enumerate() {
459        if !item {
460            continue;
461        }
462        evaluated += 1;
463        let mut disconnected = 0usize;
464        let mut distance_sum = 0u64;
465        for mapped_physical in &active_physical {
466            match physical.distance_by_index(candidate_physical, *mapped_physical) {
467                Some(distance) => distance_sum += u64::from(distance),
468                None => disconnected += 1,
469            }
470        }
471
472        let readout = readout_cost(logical, candidate_physical, physical, objective, activity);
473        let candidate = (candidate_physical, disconnected, distance_sum, readout);
474        if best.as_ref().is_none_or(|best| {
475            candidate
476                .1
477                .cmp(&best.1)
478                .then_with(|| candidate.2.cmp(&best.2))
479                .then_with(|| candidate.3.total_cmp(&best.3))
480                .then_with(|| {
481                    physical.physical_qubits()[candidate.0].cmp(&physical.physical_qubits()[best.0])
482                })
483                .is_lt()
484        }) {
485            best = Some(candidate);
486        }
487    }
488
489    best.map(|(physical, _, _, _)| (physical, evaluated))
490        .ok_or_else(|| {
491            CompilerError::InvalidInput(
492                "greedy layout could not find a vacant physical qubit for an idle logical qubit"
493                    .to_string(),
494            )
495        })
496}
497
498#[derive(Debug, Clone, Copy)]
499struct ScoredInteraction<'a> {
500    interaction: &'a Interaction,
501    gate_contributions: &'a [GateInteraction],
502}
503
504#[derive(Debug, Clone, Copy)]
505struct CandidateCost {
506    /// Whether the two physical candidates are disconnected in the usable graph.
507    disconnected: bool,
508    /// Shortest-path distance between the physical candidates.
509    distance: u32,
510    /// Local objective score for this placement decision.
511    objective_total: f64,
512}
513
514impl CandidateCost {
515    /// Computes the local greedy cost for placing one interaction.
516    ///
517    /// The cost is local to the current placement decision; final layout
518    /// quality is still evaluated later by [`LayoutObjective::score_layout`].
519    #[allow(clippy::too_many_arguments)]
520    fn for_interaction(
521        interaction: ScoredInteraction<'_>,
522        left_logical: usize,
523        right_logical: usize,
524        left_physical: usize,
525        right_physical: usize,
526        physical: &PhysicalLayoutGraph,
527        objective: &LayoutObjective,
528        activity: &[Option<f64>],
529    ) -> Self {
530        let Some(distance) = physical.distance_by_index(left_physical, right_physical) else {
531            return Self {
532                disconnected: true,
533                distance: u32::MAX,
534                objective_total: f64::INFINITY,
535            };
536        };
537
538        let adjacent = if distance == 1 {
539            score_adjacent_interaction(
540                interaction.gate_contributions,
541                left_physical,
542                right_physical,
543                physical,
544            )
545        } else {
546            Default::default()
547        };
548        let two_qubit_error = if distance == 1 && objective.two_qubit_error_weight != 0.0 {
549            adjacent.effective_two_qubit_error
550        } else {
551            0.0
552        };
553        let readout = readout_cost(left_logical, left_physical, physical, objective, activity)
554            + readout_cost(right_logical, right_physical, physical, objective, activity);
555
556        Self {
557            disconnected: false,
558            distance,
559            objective_total: objective.distance_weight
560                * interaction.interaction.weight
561                * f64::from(distance)
562                + objective.direction_weight * adjacent.direction
563                + objective.two_qubit_error_weight * two_qubit_error
564                + objective.readout_error_weight * readout,
565        }
566    }
567}
568
569/// Returns the readout contribution for placing one logical qubit.
570///
571/// A logical index outside the activity slice is treated as activity `1.0`.
572/// This is used for anchored single-endpoint scoring where the anchored side
573/// should not affect the candidate endpoint's activity lookup.
574fn readout_cost(
575    logical: usize,
576    physical_qubit: usize,
577    physical: &PhysicalLayoutGraph,
578    objective: &LayoutObjective,
579    activity: &[Option<f64>],
580) -> f64 {
581    if objective.readout_error_weight == 0.0 {
582        return 0.0;
583    }
584
585    physical
586        .readout_error_by_index(physical_qubit)
587        .map(|error| activity.get(logical).copied().flatten().unwrap_or(1.0) * error)
588        .unwrap_or(0.0)
589}
590
591/// Orders two local greedy costs from better to worse.
592fn compare_cost(a: CandidateCost, b: CandidateCost) -> Ordering {
593    // Hard topology quality dominates local calibration tie-breaks: connected
594    // beats disconnected, then shorter path, then objective score.
595    a.disconnected
596        .cmp(&b.disconnected)
597        .then_with(|| a.distance.cmp(&b.distance))
598        .then_with(|| a.objective_total.total_cmp(&b.objective_total))
599}
600
601/// Updates the current best two-endpoint placement candidate.
602///
603/// Ties are resolved by physical qubit IDs so the greedy result is
604/// reproducible across runs.
605#[allow(clippy::too_many_arguments)]
606fn update_best_pair(
607    interaction: ScoredInteraction<'_>,
608    left_logical: usize,
609    right_logical: usize,
610    left_physical: usize,
611    right_physical: usize,
612    physical: &PhysicalLayoutGraph,
613    objective: &LayoutObjective,
614    activity: &[Option<f64>],
615    best: &mut Option<(usize, usize, CandidateCost)>,
616) {
617    let cost = CandidateCost::for_interaction(
618        interaction,
619        left_logical,
620        right_logical,
621        left_physical,
622        right_physical,
623        physical,
624        objective,
625        activity,
626    );
627    let candidate = (left_physical, right_physical, cost);
628    if best.as_ref().is_none_or(|best| {
629        compare_cost(candidate.2, best.2)
630            .then_with(|| {
631                physical.physical_qubits()[candidate.0].cmp(&physical.physical_qubits()[best.0])
632            })
633            .then_with(|| {
634                physical.physical_qubits()[candidate.1].cmp(&physical.physical_qubits()[best.1])
635            })
636            .is_lt()
637    }) {
638        *best = Some(candidate);
639    }
640}
641
642/// Returns connected ordered physical pairs in deterministic preference order.
643fn sorted_physical_pair_candidates(physical: &PhysicalLayoutGraph) -> Vec<(usize, usize)> {
644    // Pre-sorting connected ordered pairs avoids repeating the same
645    // distance/ID tie-break logic for every unmapped interaction.
646    let mut candidates = Vec::new();
647    for left in 0..physical.physical_qubits().len() {
648        for right in 0..physical.physical_qubits().len() {
649            if left != right && physical.distance_by_index(left, right).is_some() {
650                candidates.push((left, right));
651            }
652        }
653    }
654    candidates.sort_by(|a, b| {
655        physical
656            .distance_by_index(a.0, a.1)
657            .cmp(&physical.distance_by_index(b.0, b.1))
658            .then_with(|| physical.physical_qubits()[a.0].cmp(&physical.physical_qubits()[b.0]))
659            .then_with(|| physical.physical_qubits()[a.1].cmp(&physical.physical_qubits()[b.1]))
660    });
661    candidates
662}