Skip to content

Movement

PhysicalPlacementStrategy dataclass

PhysicalPlacementStrategy(
    arch_spec: ArchSpec = get_physical_arch_spec(),
    traversal: RustPlacementTraversal = (
        lambda: RustPlacementTraversal(strategy="entropy")
    )(),
    target_generator: (
        TargetGeneratorABC | TargetGeneratorCallable | None
    ) = None,
)

Bases: MoveToPlacementStrategyABC


              flowchart TD
              bloqade.lanes.heuristics.physical.movement.PhysicalPlacementStrategy[PhysicalPlacementStrategy]
              bloqade.lanes.analysis.placement.strategy.MoveToPlacementStrategyABC[MoveToPlacementStrategyABC]
              bloqade.lanes.analysis.placement.strategy.PlacementStrategyABC[PlacementStrategyABC]

                              bloqade.lanes.analysis.placement.strategy.MoveToPlacementStrategyABC --> bloqade.lanes.heuristics.physical.movement.PhysicalPlacementStrategy
                                bloqade.lanes.analysis.placement.strategy.PlacementStrategyABC --> bloqade.lanes.analysis.placement.strategy.MoveToPlacementStrategyABC
                



              click bloqade.lanes.heuristics.physical.movement.PhysicalPlacementStrategy href "" "bloqade.lanes.heuristics.physical.movement.PhysicalPlacementStrategy"
              click bloqade.lanes.analysis.placement.strategy.MoveToPlacementStrategyABC href "" "bloqade.lanes.analysis.placement.strategy.MoveToPlacementStrategyABC"
              click bloqade.lanes.analysis.placement.strategy.PlacementStrategyABC href "" "bloqade.lanes.analysis.placement.strategy.PlacementStrategyABC"
            

Physical placement strategy backed by the Rust TargetSolver.

rust_bound_stats_total property

rust_bound_stats_total: dict[str, float]

Branch-and-bound pruning statistics summed over every solve.

Counters and depth sums add; the per-instance root_lower_bound / incumbent_cost / gap do not aggregate meaningfully across solves, so the widest observed gap is kept as max_optimality_gap instead.

Empty — not zeroed — before the first solve, and after any number of solves in which no bound ran: an unbounded run measured nothing, and reporting zeros would be indistinguishable from a bounded run that pruned nothing. A bound runs when :pyattr:RustPlacementTraversal.completion_bound is set, or when a cascade strategy's refinement is gated (:pyattr:RustPlacementTraversal.cascade_bound), which needs no completion_bound. Key-check rather than expecting the keys to exist.

rust_entropy_fallback_count property

rust_entropy_fallback_count: int

Number of solved Rust entropy stages that used the budget-exhaustion fallback (Push and Rotate, with the greedy sequential router as the out-of-regime tertiary).

rust_nodes_expanded_total property

rust_nodes_expanded_total: int

Total Rust solver node expansions for this strategy instance.

rust_proven_total property

rust_proven_total: int

Solves whose plan the bound proved optimal.

Counts Proof.OPTIMAL only; a Proof.NO_PLAN is a different claim. Read against the solve count rather than alone: a solve without a proof is unproven, not suboptimal.

traced_blocked_locations property

traced_blocked_locations: tuple[LocationAddress, ...]

Spectator atom positions for the traced CZ layer (atoms not in the active placement).

traced_target property

traced_target: dict[int, LocationAddress]

First candidate target for the traced CZ layer (used by visualizers).

compute_moves

compute_moves(
    state_before: ConcreteState, state_after: ConcreteState
) -> tuple[tuple[LaneAddress, ...], ...]

Route atoms between two fixed layouts for user-directed MoveTo.

Delegates to the shared compute_move_layers primitive, passing this strategy's own traversal and cached :class:SearchEngine so MoveTo routing uses the same search configuration and per-arch lane index as cz_placements (the docstring on compute_move_layers notes the two callsites are meant to share a traversal).

The import is deferred to call time: heuristics.move_synthesis imports this module, so a top-level import would be a cycle.

Source code in .venv/lib/python3.14/site-packages/bloqade/lanes/heuristics/physical/movement.py
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
def compute_moves(
    self,
    state_before: ConcreteState,
    state_after: ConcreteState,
) -> tuple[tuple[LaneAddress, ...], ...]:
    """Route atoms between two fixed layouts for user-directed ``MoveTo``.

    Delegates to the shared ``compute_move_layers`` primitive, passing this
    strategy's own ``traversal`` and cached :class:`SearchEngine` so MoveTo
    routing uses the same search configuration and per-arch lane index as
    ``cz_placements`` (the docstring on ``compute_move_layers`` notes the two
    callsites are meant to share a traversal).

    The import is deferred to call time: ``heuristics.move_synthesis``
    imports this module, so a top-level import would be a cycle.
    """
    from bloqade.lanes.heuristics.move_synthesis import compute_move_layers

    return compute_move_layers(
        self.arch_spec,
        state_before,
        state_after,
        engine=self._get_engine(),
        traversal=self.traversal,
    )

RustPlacementTraversal dataclass

RustPlacementTraversal(
    strategy: SearchStrategyName = "entropy",
    max_movesets_per_group: int = 3,
    max_goal_candidates: int = 3,
    max_expansions: int | None = 300,
    restarts: int = 1,
    lookahead: bool = False,
    collect_entropy_trace: bool = False,
    seed: int = 0,
    completion_bound: str | None = None,
    block_spectators: bool = True,
    fallback_push_rotate: bool = False,
    cascade_bound: bool = True,
)

Config for the Rust search engine (TargetSolver).

restarts and lookahead are now exposed and threaded into SolveOptions; per-strategy entropy knobs (max_movesets_per_group, max_goal_candidates, collect_entropy_trace) feed EntropyOptions via :func:_move_search_from_traversal.

Not yet exposed (Rust defaults used): weight, deadlock_policy, w_t. These will be threaded through once validated via Rust-only benchmarking.

block_spectators class-attribute instance-attribute

block_spectators: bool = True

Scope each CZ solve to the participating qubits.

When True (default), qubits not in the CZ's controls/targets are treated as blocked obstacles for that solve rather than free atoms to route. This keeps the search effort independent of how many unrelated qubits share the same merged StaticPlacement block (under always_merge every CZ would otherwise search over all block qubits, inflating nodes_expanded).

Set False to let each CZ route over all block qubits. Note: blocking spectators removes routing freedom, so for some strategies it can change (sometimes increase) move counts (e.g. DFS may relocate a spectator to shorten a participant's path); the search-effort reduction is not always move-count-free.

cascade_bound class-attribute instance-attribute

cascade_bound: bool = True

Gate a cascade strategy's A* refinement with the completion bound.

On by default; False restores the ungated refinement. The refinement looks for a plan strictly cheaper than the inner strategy's; with the gate, a child whose g + h already reaches that cost is dropped before it takes a node, which saves memory without losing a cheaper plan (a tie may resolve to a different plan of the same cost). Only the cascade-* strategies read it.

completion_bound class-attribute instance-attribute

completion_bound: str | None = None

Admissible completion bound for branch-and-bound pruning.

None (default) prunes a branch only once its accumulated cost alone reaches the incumbent's. "weighted_distance" additionally prunes when g + h reaches it, where h is the max objective-weighted distance over unresolved atoms. Affects which subtrees are explored, never candidate generation, and never returns a worse plan.

Only consulted by the entropy strategy, and ignored for loose-goal (CZ pair) solves where the bound is not admissible.

fallback_push_rotate class-attribute instance-attribute

fallback_push_rotate: bool = False

Route with Push and Rotate when the search fails.

Off by default. When True, a solve the search does not finish is handed to Push and Rotate, a complete rule-based router: it either returns a schedule or proves that none exists. Only the failure path pays for it. Push and Rotate starts from the furthest configuration the search reached, so the schedule is the search's prefix plus its own layers; if that cannot finish, it reruns from the original placement. Push and Rotate does not honour an AOD capacity, so on an architecture that sets one, a schedule with a shot over it is discarded and the search's own result stands.

convert_move_layers

convert_move_layers(
    raw_layers,
) -> tuple[tuple[LaneAddress, ...], ...]

Wrap Rust solver move_layers into Python LaneAddress tuples.

Source code in .venv/lib/python3.14/site-packages/bloqade/lanes/heuristics/physical/movement.py
56
57
58
59
60
61
62
def convert_move_layers(
    raw_layers,
) -> tuple[tuple[LaneAddress, ...], ...]:
    """Wrap Rust solver move_layers into Python ``LaneAddress`` tuples."""
    return tuple(
        tuple(LaneAddress.from_inner(lane) for lane in step) for step in raw_layers
    )

make_physical_placement_strategy

make_physical_placement_strategy(
    *,
    move_solutions_per_layer: int = 3,
    search_budget: int | None = 300,
    strategy: SearchStrategyName = "entropy",
    arch_spec: ArchSpec | None = None,
    return_moves: bool = True,
    mover_selection: MoverSelection | None = None
) -> PlacementStrategyABC

Build a physical placement strategy from user-facing search knobs.

Uses :class:~bloqade.lanes.heuristics.physical.nohome.NoHomePlacementStrategy backed by the Rust NoHomeCzPlacement solver.

move_solutions_per_layer maps to k_candidates (candidate home sites per qubit in the Hungarian assignment). search_budget maps to max_expansions. mover_selection picks which qubit of each CZ pair moves (see :class:~bloqade.lanes.bytecode.MoverSelection); None keeps NoHome's default, RANKED. lambda_lookahead is fixed at 0 because palindrome return always moves atoms back to their original home position, so future-layer proximity penalties carry no signal.

return_moves also selects mirrored solving: it wraps the strategy in :class:~bloqade.lanes.analysis.placement.PalindromePlacementStrategy and turns on backwards_search, since palindrome is what makes mirroring sound (see below).

.. warning::

On the palindrome path (return_moves=True, the default) with a home starting layout — atoms on their even/home words — move_solutions_per_layer has no effect. It maps to k_candidates, which feeds the no-home return assignment, and that phase never runs under palindrome: solve_nohome skips it whenever every atom already sits on a home site. Confirmed empirically — sweeping move_solutions_per_layer over 1, 3, 8 and 20 produces byte-identical move layers at 4, 6 and 8 atoms. The knob still applies when return_moves=False, or when atoms start off their home words.

The wider consequence is that palindrome reduces :class:~bloqade.lanes.heuristics.physical.nohome.NoHomePlacementStrategy to its CZ phase, so gamma, k_candidates, top_bus_signatures and bus_reward_rho are all inert on that path. mover_selection is not: the CZ phase's choice of which qubit of each pair moves is exactly what it controls.

Source code in .venv/lib/python3.14/site-packages/bloqade/lanes/heuristics/physical/movement.py
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
def make_physical_placement_strategy(
    *,
    move_solutions_per_layer: int = 3,
    search_budget: int | None = 300,
    strategy: SearchStrategyName = "entropy",
    arch_spec: ArchSpec | None = None,
    return_moves: bool = True,
    mover_selection: MoverSelection | None = None,
) -> PlacementStrategyABC:
    """Build a physical placement strategy from user-facing search knobs.

    Uses :class:`~bloqade.lanes.heuristics.physical.nohome.NoHomePlacementStrategy`
    backed by the Rust ``NoHomeCzPlacement`` solver.

    ``move_solutions_per_layer`` maps to ``k_candidates`` (candidate home
    sites per qubit in the Hungarian assignment).  ``search_budget`` maps to
    ``max_expansions``.  ``mover_selection`` picks which qubit of each CZ pair
    moves (see :class:`~bloqade.lanes.bytecode.MoverSelection`); ``None`` keeps
    NoHome's default, ``RANKED``.  ``lambda_lookahead`` is fixed at ``0`` because
    palindrome return always moves atoms back to their original home position,
    so future-layer proximity penalties carry no signal.

    ``return_moves`` also selects mirrored solving: it wraps the strategy in
    :class:`~bloqade.lanes.analysis.placement.PalindromePlacementStrategy`
    *and* turns on ``backwards_search``, since palindrome is what makes
    mirroring sound (see below).

    .. warning::

       On the palindrome path (``return_moves=True``, the default) with a home
       starting layout — atoms on their even/home words —
       ``move_solutions_per_layer`` has no effect. It maps to
       ``k_candidates``, which feeds the no-home *return* assignment, and that
       phase never runs under palindrome: ``solve_nohome`` skips it whenever
       every atom already sits on a home site. Confirmed empirically —
       sweeping ``move_solutions_per_layer`` over 1, 3, 8 and 20 produces
       byte-identical move layers at 4, 6 and 8 atoms. The knob still applies
       when ``return_moves=False``, or when atoms start off their home words.

       The wider consequence is that palindrome reduces
       :class:`~bloqade.lanes.heuristics.physical.nohome.NoHomePlacementStrategy`
       to its CZ phase, so ``gamma``, ``k_candidates``,
       ``top_bus_signatures`` and ``bus_reward_rho`` are all inert on that
       path. ``mover_selection`` is not: the CZ phase's choice of which qubit
       of each pair moves is exactly what it controls.
    """
    from bloqade.lanes.heuristics.physical.nohome import NoHomePlacementStrategy

    if move_solutions_per_layer < 1:
        raise ValueError("move_solutions_per_layer must be >= 1")
    if search_budget is not None and search_budget < 1:
        raise ValueError("search_budget must be None or >= 1")

    inner = NoHomePlacementStrategy(
        arch_spec=get_physical_arch_spec() if arch_spec is None else arch_spec,
        strategy=_STRATEGY_MAP[strategy],
        max_expansions=search_budget,
        k_candidates=move_solutions_per_layer,
        lambda_lookahead=0.0,
        # Mirrored solving is only sound as an *optimisation* when the target
        # is reliably the more constrained endpoint, and palindrome is what
        # guarantees that: it restores the pre-CZ home layout before every
        # solve, so each solve runs home (unpaired) → CZ pairing. That also
        # makes it a single routing call — `solve_nohome` skips its return
        # phase when every atom already sits on a home site — so the one call
        # being mirrored is exactly the one with the gradient. Without
        # palindrome there is no such guarantee, hence the tie to
        # `return_moves`.
        backwards_search=return_moves,
        mover_selection=mover_selection,
    )

    return PalindromePlacementStrategy(inner=inner) if return_moves else inner