Source code for divi.qprog.problems._base
# SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
#
# SPDX-License-Identifier: Apache-2.0
"""QAOAProblem protocol — base class for all QAOA-compatible problems."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable, Hashable
from typing import Any
from qiskit.quantum_info import SparsePauliOp
from divi.hamiltonians import x_mixer
from divi.hamiltonians._term_ops import _require_qiskit_num_qubits
from divi.qprog.algorithms import InitialState, SuperpositionState
[docs]
class QAOAProblem(ABC):
"""Base class for all QAOA-compatible problems.
Subclasses must implement the abstract properties that provide
the ingredients QAOA needs. Default implementations of
``mixer_hamiltonian``, ``recommended_initial_state``, ``is_feasible``,
``repair_infeasible_bitstring``, and ``compute_energy`` are provided and
can be overridden.
"""
@property
@abstractmethod
def cost_hamiltonian(self) -> SparsePauliOp:
"""The cost Hamiltonian encoding the optimization objective."""
@property
def mixer_hamiltonian(self) -> SparsePauliOp:
"""Mixer Hamiltonian for exploring the solution space.
Defaults to the standard X mixer over the cost Hamiltonian qubits,
suitable for unconstrained binary optimization. Override this for
constrained feasible subspaces or problem-specific mixers.
"""
return x_mixer(_require_qiskit_num_qubits(self.cost_hamiltonian.num_qubits))
@property
@abstractmethod
def loss_constant(self) -> float:
"""Constant offset added back to the expectation value."""
@property
@abstractmethod
def decode_fn(self) -> Callable[[str], Any]:
"""Map a measurement bitstring to a domain-level solution.
Bitstrings use **left-to-right** qubit ordering: the character at
index *i* corresponds to qubit *i* of the cost Hamiltonian.
"""
@property
def recommended_initial_state(self) -> InitialState:
"""Recommended initial quantum state for this problem.
Defaults to :class:`~divi.qprog.algorithms.SuperpositionState` (ground state of the
standard X mixer).
"""
return SuperpositionState()
@property
def wire_labels(self) -> tuple:
"""Qubit-position-ordered labels for the cost Hamiltonian.
Defaults to dense ``range(num_qubits)``. Override for problems whose
users expect domain-level identifiers (e.g. graph node names) at
``QAOA._circuit_wires`` and through ``decode_fn``.
"""
return tuple(
range(_require_qiskit_num_qubits(self.cost_hamiltonian.num_qubits))
)
[docs]
def is_feasible(self, bitstring: str) -> bool:
"""Check whether a bitstring represents a feasible solution.
Defaults to ``True`` (unconstrained).
"""
return True
[docs]
def repair_infeasible_bitstring(
self, bitstring: str
) -> tuple[str, Any, float | None]:
"""Repair an infeasible bitstring into a feasible one.
Returns:
A three-element tuple ``(repaired_bitstring, decoded, energy)``:
- **repaired_bitstring**: The feasible bitstring after repair.
- **decoded**: Domain-level solution (e.g. tour list, routes),
or ``None`` if unavailable.
- **energy**: Objective value of the repaired solution,
or ``None`` if unknown.
The default implementation returns the bitstring unchanged.
"""
return bitstring, None, None
[docs]
def compute_energy(self, bitstring: str) -> float | None:
"""Evaluate the objective energy for a bitstring.
Defaults to ``None`` (unknown).
"""
return None
# ------------------------------------------------------------------
# Decomposition hooks (override to enable partitioned workflows)
# ------------------------------------------------------------------
[docs]
def decompose(self) -> dict[Hashable, QAOAProblem]:
"""Decompose this problem into sub-problems for partitioned solving.
Returns a dict mapping program IDs to sub-problems. Program IDs must
be hashable and stable because later partitioning hooks receive the
same IDs when extending candidates into a global solution.
Raises:
NotImplementedError: If the subclass does not support decomposition.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support decomposition. "
"Override decompose() to enable partitioning workflows."
)
[docs]
def initial_solution_size(self) -> int:
"""Size of the global solution vector (e.g. number of graph nodes)."""
raise NotImplementedError(
f"{type(self).__name__} does not implement initial_solution_size()."
)
[docs]
def extend_solution(
self,
current_solution: list[int],
prog_id: Hashable,
candidate_decoded: list[int],
) -> list[int]:
"""Map a sub-solution's decoded bits into the global solution vector.
``prog_id`` is one of the keys returned by :meth:`decompose`.
Must return a **new** list — do not mutate *current_solution*.
"""
raise NotImplementedError(
f"{type(self).__name__} does not implement extend_solution()."
)
[docs]
def evaluate_global_solution(self, solution: list[int]) -> float:
"""Score a complete global solution for beam search.
Lower scores are better. Problems with maximization objectives should
usually return a negated score here, then expose the natural objective
value from :meth:`postprocess_candidates`.
"""
raise NotImplementedError(
f"{type(self).__name__} does not implement evaluate_global_solution()."
)
[docs]
def postprocess_candidates(
self, candidates: list[tuple[float, list[int]]], *, strict: bool = False
) -> list[tuple[Any, float]]:
"""Post-process raw partition-aggregation candidates.
Args:
candidates: Beam-search ``(score, solution)`` pairs, where
``score`` is the value returned by
:meth:`evaluate_global_solution` and lower is better.
strict: If supported by a constrained problem, reject invalid raw
solutions instead of repairing them. The default implementation
ignores this flag.
Returns:
Problem-specific ``(result, objective_value)`` tuples ready to
return from partitioned aggregation APIs. The objective value uses
the problem's public convention, which may differ from the
beam-search score. Default: ``(solution, score)``.
"""
return [(solution, score) for score, solution in candidates]