diff --git a/azure-quantum/azure/quantum/cirq/targets/generic.py b/azure-quantum/azure/quantum/cirq/targets/generic.py index 49a5e6fd..9b76db6e 100644 --- a/azure-quantum/azure/quantum/cirq/targets/generic.py +++ b/azure-quantum/azure/quantum/cirq/targets/generic.py @@ -215,8 +215,24 @@ def _to_cirq_job(self, azure_job, program: "cirq.Circuit" = None): target=self, ) + # Some backends emit verbose strings for individual qubit outcomes instead of + # single-character values. Map the known ones explicitly so they don't corrupt + # the per-qubit register split. + _SHOT_STRING_MAP: Dict[str, str] = { + "Zero": "0", + "False": "0", + "One": "1", + "True": "1", + "Loss": "-", + } + @staticmethod def _qir_display_to_bitstring(obj: Any) -> str: + if isinstance(obj, str): + mapped = AzureGenericQirCirqTarget._SHOT_STRING_MAP.get(obj) + if mapped is not None: + return mapped + if isinstance(obj, str) and not re.match(r"[\d\s]+$", obj): try: obj = ast.literal_eval(obj) @@ -228,7 +244,9 @@ def _qir_display_to_bitstring(obj: Any) -> str: AzureGenericQirCirqTarget._qir_display_to_bitstring(t) for t in obj ) if isinstance(obj, list): - return "".join(str(bit) for bit in obj) + return "".join( + AzureGenericQirCirqTarget._qir_display_to_bitstring(bit) for bit in obj + ) return str(obj) @staticmethod diff --git a/azure-quantum/tests/test_cirq.py b/azure-quantum/tests/test_cirq.py index aa777691..773d5f48 100644 --- a/azure-quantum/tests/test_cirq.py +++ b/azure-quantum/tests/test_cirq.py @@ -233,6 +233,84 @@ def test_cirq_generic_to_cirq_result_drops_non_binary_shots_and_exposes_raw(): ) +def test_cirq_generic_to_cirq_result_non_binary_shots_filtered_and_raw_preserved(): + """Non-binary shots are excluded from measurements[] but kept in raw_shots. + + Known backend-specific outcome strings ("Loss", "True", "False", "One", "Zero") + are mapped to their single-character equivalents before register splitting. + Single-char non-binary markers like "." pass through unchanged. + """ + np = pytest.importorskip("numpy") + cirq = pytest.importorskip("cirq") + + from azure.quantum.cirq.targets.generic import AzureGenericQirCirqTarget + + measurement_dict = {"m": [0]} + # "Loss" -> mapped to "-" + # "." -> preserved as "." + # "1"/"0" -> binary, appear in measurements + shots = ["Loss", ".", "1", "0"] + + result = AzureGenericQirCirqTarget._to_cirq_result( + result=shots, + param_resolver=cirq.ParamResolver({}), + measurement_dict=measurement_dict, + ) + + # Only binary outcomes in measurements. + np.testing.assert_array_equal( + result.measurements["m"], + np.asarray([[1], [0]], dtype=np.int8), + ) + + # Original shot objects kept verbatim in raw_shots. + assert result.raw_shots == shots + + # raw_measurements: "Loss" -> "-", "." -> ".", binary values unchanged. + raw_meas = result.raw_measurements() + np.testing.assert_array_equal( + raw_meas["m"], + np.asarray([["-"], ["."], ["1"], ["0"]], dtype=" "1.1" + assert fn(".1.0.1") == ".1.0.1" # fails eval, returned as-is + assert fn("-10") == "-10" # evals to int -10, str() -> "-10" + assert fn("1-0-1") == "1-0-1" # fails eval, returned as-is + + # Known multi-character per-qubit outcome strings are mapped explicitly. + assert fn("Zero") == "0" + assert fn("False") == "0" + assert fn("One") == "1" + assert fn("True") == "1" + assert fn("Loss") == "-" + + # List elements are processed recursively, so special-case strings inside + # a list are also mapped correctly. + assert fn(["Zero", "Loss", "Loss"]) == "0--" + assert fn(["One", "Zero", "One"]) == "101" + + def test_cirq_service_targets_excludes_non_qir_target(): """Targets without a target_profile (e.g. Pasqal) must not be wrapped as AzureGenericQirCirqTarget — they use pulse-level input formats incompatible