From 1a8183d6161a1262ae3c143f52d215dacd88ed14 Mon Sep 17 00:00:00 2001 From: Mayckon Giovani Date: Fri, 24 Jul 2026 14:18:05 -0400 Subject: [PATCH] feat: add local Ocean BQM parity report --- README.md | 2 +- docs/dwave_mapping.md | 6 +- examples/dwave_ocean_exchange.py | 39 ++------ python/noetheris/backends/__init__.py | 2 + python/noetheris/backends/dwave.py | 122 ++++++++++++++++++++------ tests/test_external_examples.py | 10 ++- tests/test_optional_integrations.py | 91 ++++++++++++++++++- 7 files changed, 204 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 5eefbb8..4384855 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ python3 -m pip install -e '.[qiskit]' python3 -m pip install -e '.[dwave]' ``` -The default path requires no paid APIs, no IBM Quantum credentials, and no D-Wave credentials. +The default path requires no paid APIs, no IBM Quantum credentials, and no D-Wave credentials. Installing `.[dwave]` enables local `dimod.BinaryQuadraticModel` construction and energy parity reports; it does not submit to D-Wave cloud services. ## Tests And Audit diff --git a/docs/dwave_mapping.md b/docs/dwave_mapping.md index 0c2b13d..d34b3af 100644 --- a/docs/dwave_mapping.md +++ b/docs/dwave_mapping.md @@ -44,7 +44,9 @@ The v0.2 parity harness checks: These values must agree for every assignment of each small parity fixture. Larger export-only models are validated and serialized without invoking the bounded exhaustive solver. -When Ocean is installed, Noetheris also constructs a `dimod.BinaryQuadraticModel` locally. This does not require D-Wave credentials. The BQM summary records variable count, interaction count, vartype, and `to_qubo` offset. +When Ocean is installed, Noetheris also constructs a `dimod.BinaryQuadraticModel` locally. This does not require D-Wave credentials. The BQM report records vartype, variable count, interaction count, offset, `to_qubo` offset, checked assignments, Noetheris energy, Ocean energy, and agreement status. + +If Ocean is absent, the report states that `dimod` is unavailable and leaves BQM data empty. This is a supported default path, not a degraded verification path: Noetheris replay and certificate checks remain local. The executable local example is: @@ -52,7 +54,7 @@ The executable local example is: python3 examples/dwave_ocean_exchange.py ``` -It emits the canonical exchange payload, optional local Ocean parity data, the exact local reference assignment, and a replay result that verifies hashes and energy. +It emits the canonical exchange payload, optional local Ocean BQM parity data, the exact local reference assignment, and a replay result that verifies hashes and energy. ## Solver Boundary diff --git a/examples/dwave_ocean_exchange.py b/examples/dwave_ocean_exchange.py index 74f8557..2132fd6 100644 --- a/examples/dwave_ocean_exchange.py +++ b/examples/dwave_ocean_exchange.py @@ -3,45 +3,15 @@ import json from pathlib import Path import sys -from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "python")) -from noetheris.backends import export_qubo_to_dwave +from noetheris.backends import export_qubo_to_dwave, ocean_bqm_parity_report from noetheris.ir import StructuralSystem from noetheris.qubo import compile_system, replay_external_solution, solve_exact -def _ocean_energy_check(exchange: dict[str, Any], assignment: dict[str, bool]) -> dict[str, Any]: - try: - import dimod # type: ignore - except Exception as exc: - return {"available": False, "reason": exc.__class__.__name__} - - bqm = dimod.BinaryQuadraticModel( - { - item["variable"]: float(item["coefficient"]) - for item in exchange["linear_terms"] - }, - { - (item["left"], item["right"]): float(item["coefficient"]) - for item in exchange["quadratic_terms"] - }, - float(exchange["offset"]), - dimod.BINARY, - ) - ocean_assignment = {variable: int(value) for variable, value in assignment.items()} - return { - "available": True, - "bqm_class": "dimod.BinaryQuadraticModel", - "vartype": str(bqm.vartype), - "num_variables": len(bqm.variables), - "num_interactions": len(bqm.quadratic), - "energy": float(bqm.energy(ocean_assignment)), - } - - def main() -> None: system = StructuralSystem.from_json_file( ROOT / "examples" / "structural_ir" / "consensus_safety_ir.json" @@ -65,7 +35,10 @@ def main() -> None: "embedding": None, }, ) - ocean_check = _ocean_energy_check(exported["exchange"], solution.assignment) + ocean_bqm_report = ocean_bqm_parity_report( + compiled.model, + assignments=(solution.assignment,), + ) print( json.dumps( { @@ -76,7 +49,7 @@ def main() -> None: "exchange": exported["exchange"], "dwave_status": exported["status"], "bqm_summary": exported["bqm_summary"], - "ocean_energy_check": ocean_check, + "ocean_bqm_report": ocean_bqm_report, "local_solution": { "assignment": solution.assignment, "energy": solution.energy, diff --git a/python/noetheris/backends/__init__.py b/python/noetheris/backends/__init__.py index b522b92..d553247 100644 --- a/python/noetheris/backends/__init__.py +++ b/python/noetheris/backends/__init__.py @@ -1,6 +1,7 @@ from noetheris.backends.dwave import ( dwave_status, export_qubo_to_dwave, + ocean_bqm_parity_report, qubo_exchange_payload, replay_external_sample, ) @@ -16,6 +17,7 @@ "export_bool_expr_to_qiskit", "export_oracle_to_qiskit", "export_qubo_to_dwave", + "ocean_bqm_parity_report", "qubo_exchange_payload", "qasm_like_export", "qiskit_status", diff --git a/python/noetheris/backends/dwave.py b/python/noetheris/backends/dwave.py index 96d51fa..fcfebc1 100644 --- a/python/noetheris/backends/dwave.py +++ b/python/noetheris/backends/dwave.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, Mapping from noetheris.certificates import stable_problem_hash from noetheris.qubo import QuboModel @@ -16,38 +16,14 @@ def dwave_status() -> dict[str, Any]: def export_qubo_to_dwave(model: QuboModel) -> dict[str, Any]: model.validate() - canonical = model.canonicalized() status = dwave_status() payload = qubo_exchange_payload(model) - bqm_summary: dict[str, Any] | None = None - if status["available"]: - try: - import dimod # type: ignore - - bqm = dimod.BinaryQuadraticModel( - dict(canonical.linear), - {(term.left, term.right): term.coefficient for term in canonical.quadratic}, - canonical.constant, - dimod.BINARY, - ) - qubo, offset = bqm.to_qubo() - bqm_summary = { - "class": "dimod.BinaryQuadraticModel", - "vartype": str(bqm.vartype), - "num_variables": len(bqm.variables), - "num_interactions": len(bqm.quadratic), - "to_qubo_terms": len(qubo), - "to_qubo_offset": float(offset), - } - except Exception as exc: - bqm_summary = { - "class": "dimod.BinaryQuadraticModel", - "export_error": exc.__class__.__name__, - } + bqm_report = ocean_bqm_parity_report(model, assignments=()) return { "status": status, "exchange": payload, - "bqm_summary": bqm_summary, + "bqm_summary": bqm_report["bqm_summary"], + "ocean_bqm_report": bqm_report, "credential_required": False, "external_solver_policy": "solver samples are untrusted until local energy replay succeeds", } @@ -81,6 +57,96 @@ def qubo_exchange_payload(model: QuboModel) -> dict[str, Any]: return {**payload, "model_hash": stable_problem_hash(payload)} +def ocean_bqm_parity_report( + model: QuboModel, + *, + assignments: tuple[Mapping[str, bool | int], ...], +) -> dict[str, Any]: + model.validate() + try: + import dimod # type: ignore + except Exception as exc: + return { + "available": False, + "reason": exc.__class__.__name__, + "credential_required": False, + "bqm_summary": None, + "assignment_reports": [], + "energy_agreement": None, + "policy": "Ocean is optional; missing dimod does not affect local Noetheris replay", + } + + canonical = model.canonicalized() + try: + linear_biases = { + variable: canonical.linear.get(variable, 0.0) + for variable in canonical.variables + } + bqm = dimod.BinaryQuadraticModel( + linear_biases, + {(term.left, term.right): term.coefficient for term in canonical.quadratic}, + canonical.constant, + dimod.BINARY, + ) + qubo, offset = bqm.to_qubo() + bqm_summary = { + "class": "dimod.BinaryQuadraticModel", + "vartype": str(bqm.vartype), + "num_variables": len(bqm.variables), + "num_interactions": len(bqm.quadratic), + "offset": float(getattr(bqm, "offset", canonical.constant)), + "to_qubo_terms": len(qubo), + "to_qubo_offset": float(offset), + } + assignment_reports = [ + _ocean_assignment_report(model, bqm, assignment) + for assignment in assignments + ] + except Exception as exc: + return { + "available": True, + "credential_required": False, + "bqm_summary": { + "class": "dimod.BinaryQuadraticModel", + "export_error": exc.__class__.__name__, + }, + "assignment_reports": [], + "energy_agreement": False, + "policy": "local BQM construction failed before any external solver boundary", + } + return { + "available": True, + "credential_required": False, + "bqm_summary": bqm_summary, + "assignment_reports": assignment_reports, + "energy_agreement": ( + all(item["agreement"] for item in assignment_reports) + if assignment_reports + else None + ), + "policy": "local dimod BQM construction only; no D-Wave credentials or sampler calls", + } + + +def _ocean_assignment_report( + model: QuboModel, bqm: Any, assignment: Mapping[str, bool | int] +) -> dict[str, Any]: + normalized = {variable: bool(value) for variable, value in assignment.items()} + noetheris_energy = model.evaluate(normalized) + ocean_assignment = { + variable: int(normalized[variable]) for variable in model.variables + } + ocean_energy = float(bqm.energy(ocean_assignment)) + difference = ocean_energy - noetheris_energy + return { + "assignment": {variable: normalized[variable] for variable in model.variables}, + "noetheris_energy": noetheris_energy, + "ocean_energy": ocean_energy, + "difference": difference, + "agreement": abs(difference) <= 1e-9, + } + + def replay_external_sample( model: QuboModel, sample: dict[str, bool | int], diff --git a/tests/test_external_examples.py b/tests/test_external_examples.py index 3fe36db..b4c60a1 100644 --- a/tests/test_external_examples.py +++ b/tests/test_external_examples.py @@ -32,9 +32,13 @@ def test_dwave_ocean_exchange_example_replays_locally() -> None: assert payload["replay"]["status"] == "verified" assert payload["replay"]["energy_recomputed"] is True assert payload["local_solution"]["energy"] == payload["replay"]["energy"] - ocean_check = payload["ocean_energy_check"] - if ocean_check["available"]: - assert ocean_check["energy"] == payload["local_solution"]["energy"] + ocean_report = payload["ocean_bqm_report"] + assert ocean_report["credential_required"] is False + if ocean_report["available"]: + assert ocean_report["energy_agreement"] is True + assert ocean_report["assignment_reports"][0]["ocean_energy"] == payload["local_solution"]["energy"] + else: + assert ocean_report["bqm_summary"] is None def test_qiskit_oracle_export_example_has_exact_local_semantics() -> None: diff --git a/tests/test_optional_integrations.py b/tests/test_optional_integrations.py index 37c294d..b10ba70 100644 --- a/tests/test_optional_integrations.py +++ b/tests/test_optional_integrations.py @@ -1,15 +1,18 @@ from __future__ import annotations +from types import SimpleNamespace + from noetheris.annealing import export_to_dimod_bqm from noetheris.backends import ( export_bool_expr_to_qiskit, export_oracle_to_qiskit, export_qubo_to_dwave, + ocean_bqm_parity_report, qubo_exchange_payload, replay_external_sample, ) from noetheris.circuits import AND, BooleanOracle, BoolExpr -from noetheris.qubo import QuboModel +from noetheris.qubo import QuadraticTerm, QuboModel def test_dwave_ocean_export_gracefully_reports_availability() -> None: @@ -33,6 +36,7 @@ def test_backend_wrappers_never_require_credentials() -> None: dwave_payload = export_qubo_to_dwave(model) assert dwave_payload["credential_required"] is False assert dwave_payload["exchange"]["vartype"] == "BINARY" + assert dwave_payload["ocean_bqm_report"]["credential_required"] is False assert dwave_payload["external_solver_policy"].startswith("solver samples are untrusted") oracle = BooleanOracle(("x",), lambda bits: bits[0], name="identity") qiskit_payload = export_oracle_to_qiskit(oracle) @@ -51,6 +55,91 @@ def test_dwave_exchange_payload_and_replay_are_local() -> None: assert replay["embedding_metadata"]["provided"] is False +def test_ocean_bqm_parity_report_handles_optional_dependency() -> None: + model = QuboModel(variables=["x"], linear={"x": -1.0}) + report = ocean_bqm_parity_report(model, assignments=({"x": True},)) + assert report["credential_required"] is False + if report["available"]: + assert report["bqm_summary"]["class"] == "dimod.BinaryQuadraticModel" + assert report["assignment_reports"][0]["agreement"] is True + else: + assert report["reason"] == "ModuleNotFoundError" + assert report["bqm_summary"] is None + assert report["energy_agreement"] is None + + +def test_ocean_bqm_parity_report_verifies_local_bqm_fields(monkeypatch) -> None: + class LocalBqm: + def __init__(self, linear, quadratic, offset, vartype): + self.linear = dict(linear) + self.quadratic = dict(quadratic) + self.offset = float(offset) + self.vartype = vartype + variables = set(self.linear) + for left, right in self.quadratic: + variables.add(left) + variables.add(right) + self.variables = tuple(sorted(variables)) + + def to_qubo(self): + terms = { + (variable, variable): coefficient + for variable, coefficient in self.linear.items() + if coefficient != 0.0 + } + terms.update(self.quadratic) + return terms, self.offset + + def energy(self, assignment): + value = self.offset + for variable, coefficient in self.linear.items(): + if assignment[variable]: + value += coefficient + for (left, right), coefficient in self.quadratic.items(): + if assignment[left] and assignment[right]: + value += coefficient + return value + + local_dimod = SimpleNamespace( + __version__="local-test", + BINARY="BINARY", + BinaryQuadraticModel=LocalBqm, + ) + monkeypatch.setitem(__import__("sys").modules, "dimod", local_dimod) + model = QuboModel( + variables=["a", "b", "c"], + linear={"a": -1.0}, + quadratic=[ + QuadraticTerm("b", "a", 2.0), + QuadraticTerm("a", "a", 3.0), + ], + constant=0.25, + ) + report = ocean_bqm_parity_report( + model, + assignments=( + {"a": True, "b": False, "c": False}, + {"a": True, "b": True, "c": True}, + ), + ) + assert report["available"] is True + assert report["credential_required"] is False + assert report["energy_agreement"] is True + assert report["bqm_summary"] == { + "class": "dimod.BinaryQuadraticModel", + "vartype": "BINARY", + "num_variables": 3, + "num_interactions": 1, + "offset": 0.25, + "to_qubo_terms": 2, + "to_qubo_offset": 0.25, + } + assert report["assignment_reports"][0]["noetheris_energy"] == 2.25 + assert report["assignment_reports"][0]["ocean_energy"] == 2.25 + assert report["assignment_reports"][1]["noetheris_energy"] == 4.25 + assert report["assignment_reports"][1]["ocean_energy"] == 4.25 + + def test_dwave_exchange_handles_large_and_comma_named_models() -> None: variables = [f"x,{idx}" for idx in range(25)] model = QuboModel(variables=variables, linear={variables[0]: -1.0})