# Copyright 2025 qBraid
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Module for defining custom conversions
"""
from __future__ import annotations
import importlib.util
import inspect
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
import numpy as np
from qbraid._logging import logger
from qbraid.programs import QPROGRAM_REGISTRY, get_program_type_alias
if TYPE_CHECKING:
import qbraid.programs
# Edge cost of a weight-0 conversion. Large enough that one such edge outweighs any
# realistic finite path (a path would need ~1400 edges of weight 1e-300 to compare), yet
# finite so that cost addition stays translation-invariant -- with float("inf") here,
# Dijkstra and Yen's algorithm in ConversionGraph return provably wrong path orderings,
# since inf + x == inf collapses distinctions the tie-breakers rely on. The effect is
# that paths rank by number of weight-0 conversions first, then by the rest of the key.
_ZERO_WEIGHT_COST = 1e6
def _is_module_installed(module: str) -> bool:
"""
Determine whether a module is installed and importable.
``importlib.util.find_spec`` only returns None for the *leaf* of a dotted path; it
imports each ancestor along the way, and raises ModuleNotFoundError if one of them is
missing. A conversion requiring ``pytket.extensions.qiskit`` therefore raises, rather
than reporting unavailable, whenever pytket is installed without any of its extensions.
Args:
module (str): The (possibly dotted) name of the module to look for.
Returns:
bool: True if the module can be located, otherwise False.
"""
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError) as err:
# ImportError: an ancestor package is not installed.
# ValueError: the module is in sys.modules, but has no __spec__.
logger.debug("Module '%s' is not available: %s", module, err)
return False
[docs]
class Conversion:
"""
Class for defining and handling custom conversions between different quantum program packages.
"""
# pylint: disable-next=too-many-arguments
[docs]
def __init__(
self,
source: str,
target: str,
conversion_func: Callable,
weight: Optional[float] = None,
bias: Optional[float] = None,
):
"""
Initialize a Conversion instance with source and target packages and a conversion function.
Args:
source (str): The source package from which conversion starts.
target (str): The target package to which conversion is done.
conversion_func (Callable): The function that performs the actual conversion.
weight (Optional[float]): Optional weighting factor for the conversion, ranging [0,1].
If not specified, defaults to 1 or a custom value derived from the conversion_func.
bias (Optional[float]): Optional non-negative factor used to fine-tune the weight
calculation and modify the decision thresholds for pathfinding. Defaults to 0.
Higher values
prioritize shorter paths. For example, a bias of 0.25 slightly favors a single
conversion at weight 0.8 over two conversions at weight 1.0, whereas a bias of 0.1
requires a single conversion of weight > 0.9 to be preferred over two at weight 1.0.
"""
self._source = source
self._target = target
self._conversion_func = conversion_func
self._bias = bias if bias is not None else 0
self._weight = self._get_adjusted_weight(weight)
self._extras = getattr(conversion_func, "requires_extras", [])
self._native = self._is_module_native(conversion_func)
self._supported = self._is_conversion_supported()
@property
def source(self) -> str:
"""
The source package of the conversion.
Returns:
str: The source package name.
"""
return self._source
@property
def target(self) -> str:
"""
The target package of the conversion.
Returns:
str: The target package name.
"""
return self._target
@property
def native(self) -> bool:
"""
True if the conversion function is native to qbraid package, False otherwise.
Returns:
bool: Whether the conversion function is native to qbraid package.
"""
return self._native
@property
def supported(self) -> bool:
"""
True if all packages required to perform the conversion are installed. False otherwise.
Returns:
bool: Whether the conversion function supported in the current runtime environment.
"""
return self._supported
@property
def weight(self) -> int:
"""
The weight of the conversion function used to prioritize conversion paths.
Returns:
int: The weight of the conversion function.
"""
return self._weight
def _get_adjusted_weight(self, weight: Optional[float] = None) -> float:
"""
Calculates and returns the effective weight of the conversion, applying a bias to
prioritize shorter conversion paths when used with pathfinding algorithms like rustworkx.
Args:
weight (float, optional): The initial weight provided by the user. Defaults to
the weight attribute of conversion_func if not provided.
Returns:
float: The calculated weight adjusted for pathfinding optimization.
Raises:
ValueError: If the calculated or provided weight is not between 0 and 1
inclusive, or if the bias is negative.
"""
effective_weight = (
weight if weight is not None else getattr(self._conversion_func, "weight", 1)
)
if not 0 <= effective_weight <= 1:
raise ValueError("Weight must be a float between 0 and 1, inclusive.")
if self._bias < 0:
# A negative bias makes edge costs negative, which silently breaks the
# shortest-path search (Dijkstra requires non-negative costs).
raise ValueError("Bias must be non-negative.")
# Log transform for positive weight differentiation with rustworkx. -log(w), not
# log(1/w): the reciprocal overflows to inf for subnormal weights (w < ~1e-308),
# which would rank a tiny positive weight worse than weight 0 and reintroduce the
# inf costs _ZERO_WEIGHT_COST exists to avoid. -log caps out around 745.
rx_adjusted_weight = (
_ZERO_WEIGHT_COST if effective_weight == 0 else -np.log(effective_weight)
)
adjusted_weight = rx_adjusted_weight + self._bias
return adjusted_weight
def _is_module_native(self, func: Callable) -> bool:
"""
Determine if the function's module is 'qbraid' and requires no extras.
Args:
func (Callable): The function to check the module of.
Returns:
bool: True if the module is 'qbraid' and requires no extras, False otherwise.
"""
module = inspect.getmodule(func)
is_native = (
module is not None
and module.__name__.split(".")[0] == "qbraid"
and len(self._extras) == 0
and getattr(func, "weight", None) is not None
)
return is_native
def _is_conversion_supported(self) -> bool:
"""
Determine if the required packages for the conversion are installed.
Returns:
bool: True if supported, otherwise False.
"""
if self._native:
return True
return all(_is_module_installed(m) for m in self._extras)
def convert(self, program: qbraid.programs.QPROGRAM) -> Union[qbraid.programs.QPROGRAM, Any]:
"""
Convert a quantum program from the source package to the target package.
Args:
program (qbraid.programs.QPROGRAM): The quantum program to be converted.
Returns:
Union[qbraid.programs.QPROGRAM, Any]: The converted quantum program,
typically of a supported program type.
Raises:
ValueError: If the provided program's type does not match the source package type.
"""
package = get_program_type_alias(program)
if package != self._source:
raise ValueError(
f"Expected program of type {QPROGRAM_REGISTRY[self._source]}, "
f"but got program of type {QPROGRAM_REGISTRY[package]}."
)
return self._conversion_func(program)
def __repr__(self) -> str:
"""
Represent the Conversion instance as a string indicating
source and target packages.
Returns:
str: String representation of the Conversion instance.
"""
return f"('{self._source}', '{self._target}')"
def __eq__(self, other: Any) -> bool:
"""
Check if another instance is equal to this instance.
Args:
other (Any): Another instance to compare.
Returns:
bool: True if the instances are equal, False otherwise.
"""
if not isinstance(other, Conversion):
return False
return (
self._source == other._source
and self._target == other._target
and self._native == other._native
and self._supported == other._supported
and self._extras == other._extras
and self._weight == other._weight
)