diff --git a/.gitignore b/.gitignore index 905bf08..c32de0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ *.DS_Store __pycache__ cache -assets/__generated_theme.css +tests/class_tests/blockchain_test/* +tests/class_tests/score_tree_test/* +venv/ +.venv/ +env/ +.env/ diff --git a/README.md b/README.md index 2f3dc57..0ed2ff9 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,21 @@ -### Interested in contributing a code example? +[![Open in GitHub Codespaces]( + https://img.shields.io/badge/Open%20in%20GitHub%20Codespaces-333?logo=github)]( + https://codespaces.new/dwave-examples/quantum-blockchain?quickstart=1) -Please take a look at our [contribution guidelines](CONTRIBUTING.md) before getting started. -Thank you! +# Proof of Quantum Work Blockchains -The Dash template is intended for demos that would benefit from a user interface. This user -interface could include settings to run and customize the problem, an interactive graphical element, -or tables/charts to compare different solutions. This template is also useful for demos that are -intended for a general audience, as it is more approachable for those without a technical background. +Ledgers are widespread record keeping structures. A proof of work blockchain is a ledger supported +by consensus mechanisms to ensure that no single authority is required to verify the ledger. +Cryptographically-linked hard problems are solved by miners to encode new transactions. +Transactions can be considered finalized in the ledger because the community is incentivized by consensus mechanisms to behave honestly, and any attacker must out-work this majority to manipulate the ledger. - - +The proof of quantum work blockchain demonstrated in this example, works similarly to Bitcoin [[1]](#arXiv_2503_14462), but replaces the hard problem of finding rare SHA256 hashes with the problem of finding quantum experiments consistent with rare statistics. The particular quantum experiments can be chosen to be beyond-classical, so that only quantum computers can participate [[2]](#10.1126/science.ado6285). -# Demo Name - -Describe your demo and specify what it is demonstrating. Consider the -following questions: - -* Is it a canonical problem or a real-world application? -* Does it belong to a particular domain such as material simulation or logistics? -* What level of Ocean proficiency does it target: beginner, advanced? - -A clear description allows us to properly categorize your demo. - -Please include a screenshot of your demo below. +In this example, a blockchain evolution is simulated by fixing the number of miners, depth of the chain, and computational context (the set of QPUs available to the miners). +Consensus on the state of the chain from the perspective of mining participants is presented. ![Demo Example](static/demo.png "Image of demo interface") - ## Installation You can run this example without installation in cloud-based IDEs that support the @@ -66,57 +55,136 @@ Configuration options can be found in the [demo_configs.py](demo_configs.py) fil with the `--debug` command-line argument for live reloads and easier debugging: `python app.py --debug` - +Tests can be run by running + +```bash +pytest -k "test" +``` +from the quantum-blockchain/tests directory. + + +# Problem Description + +This demo implements a simplified version of a blockchain with adherence of miners to the blockchain rules. Modeling of transactions and passive (non-mining) stakeholders is omitted - this does not impact the evolution of the blockchain. Networking delays are not modeled, and miners use identical (by default distributed) computing resources. + +Miners demonstrate completion of quantum work by performing experiments parameterized by the state of the blockchain. Experimental results (sample sets) are post-processed to pairwise correlation statistics. Correlations realized by a particular experiment define a point in a high dimensional space; a miner must find experimental parameters such that this point falls in a small subspace. Miners search by varying a nonce parameter, which controls the parameters of the unitary evolution and post-processing. Statistics can be digitalized to produce a hash. Any other miner can rerun the experiment to verify a claim of work, up to control and sampling errors. The process of generating a hash is demonstrated below. + +![Quantum Hash Generation](static/Quantum_hash_infographic.png "Image of quantum hash generation") + +Miners present work subject to statistical uncertainty, their work is not guaranteed to be accepted by the community. Consensus mechanisms ensure that such uncertainty is resolved subject to a delay, so that the state of the ledger can be confidently asserted. An example of how probabilistic verification impacts the state of the chain is shown below. +Consensus mechanisms guarantee that such disagreements are short lived, and there is only ever uncertainty on the status of very recently proposed blocks. + +![Blockchain State Uncertainty](static/Consensus_infographic.png "Image of blockchain state uncertainty") + +This demo implements a quantum blockchain wherein a set of miners perform quantum experiments on a set of QPUs (and embeddings), yet arrive at a consensus on experimental outcomes and the state of the ledger. +After setting parameters in the browser, the blockchain is initiated with a genesis block. +As blocks are mined and proposed they are assessed independently by miners conducting their own quantum experiments. + +This example implements methods found in the paper, “Blockchain with Proof of Quantum Work” [[1]](#arXiv_2503_14462), where D-Wave executes the first-ever demonstration of distributed quantum computing deployed blockchain across four cloud-based annealing quantum computers in Canada and the United States. The research highlights how D-Wave built and tested a “proof of quantum” algorithm that uses quantum computation to generate and validate blockchain hashes. The resulting techniques demonstrated that D-Wave’s quantum blockchain architecture could enhance security and significantly reduce electricity costs. + + +### Comparison with Amin et al. Blockchain with Proof of Quantum Work [[1]](#arXiv_2503_14462) -## Problem Description -Give an overview of the problem you are solving in this demo. +The simulations in this example execute unitary evolutions on cubic spin glasses matching the paper, subject to changes in the generally available solvers. +The hash length is fixed to 64 and `num_reads` to 600 in order to reduce the QPU access time and accelerate the blockchain evolution (as opposed to the standardized 1 second of QPU access time in the [[1]](#arXiv_2503_14462) experiments). -**Objectives**: define the goal this example attempts to accomplish by minimizing or maximizing -certain aspects of the problem. For example, a production-line optimization might attempt to -minimize the time to produce all of the products. +The blockchain demo uses confidence-based Chainwork. We choose the quantum hash length as 64, and Nmax (called ALLOWABLE_ERROR in demo) to be 1, which allows comparison to the paper statistics demonstrating high efficiency and small delay if we account for changes to the compute environment. Experiments determine the witness uncertain (due to sampling and control errors at 1 second of QPU access time) to be 0.16 with the current set of generally available QPUs (January 2026). The discrepancy between generally available QPUs was larger (0.18) for the generally available QPUs in the paper experiments. Furthermore number of reads is reduced in the demo (enhancing witness uncertainty). A new default for the dW parameter is set accordingly, matching the paper methods. -**Constraints**: aspects of the problem, with limited or no flexibility, that must be satisfied for -solutions to be considered feasible. For example, a production-line optimization might have a -limitation that Machine A can only bend 10 parts per hour. +Problem-Hamiltonian and/or annealing rescaling allows one processor to emulate another, accommodating differences in the annealing schedules (energy scales). In both the paper and demo the target unitary evolution is defined relative to the Advantage2_prototype2 solver schedule. Advantage systems modeled this schedule in the paper by lengthening their anneal times, to emulate the higher energy scales of Advantage2, which is also true in the demo. However, Advantage2 solvers (unavailable at the time of the original study) can have higher energy scales than the prototype system and emulation of the unitary dynamics must be achieved by scaling down of the problem Hamiltonian (since we cannot reduce annealing_time beyond the lower bound of [currently 5 nanoseconds]). +Visualization of the blockchain in the paper placed blocks sequential on a spiral, in the demo visualization the strongest chain follows the same parametric spiral, but other (non-strongest, or rejected) branches deviate inwards from that path. Whereas the paper included 4-color global views, the demo also allows 2-color presentations of the state from an individual miner perspective. -## Model Overview -The clearer your model is presented here, the more useful it will be to others. For a strong example -of this section, see [here](https://github.com/dwave-examples/3d-bin-packing#model-overview). +The delay and efficiency of quantum blockchains was evaluated in the paper, in part, by use of bootstrapping statistics. The same methods can be implemented in the context of this demo by enabling HIDE_SIMULATED_SOLVERS=False in the demo_configs.py file. + +## Model and Code Overview ### Parameters -List and define the parameters used in your model. +The demo defines the following parameters for the underlying proof-of-work protocol + +* Number of miners: The number of participating miners. +* The length of the chain: the number of blocks that will be mined before the simulation stops. +* The set of QPUs used: One can select a single generally available QPU or all available QPUs. + +The simulation can be run, paused, and reset at fixed parameters. + +### Initialization + +The user selects the number of participating miners, the number of mining events to simulate, +and a set of QPUs (single or multiple). If multiple QPUs are selected, each experiment selects the QPU uniformly from the available QPUs. Each QPU supports a large set of programmings (differing in control error), which are also sampled uniformly at random on every evaluation. Experimental outcomes are subject to control and sampling errors. + +### Mining and Validation + +For each round of mining, one miner is randomly selected to be the 'winner' of that round, +simulating a distributed community with competitive mining where each miner has an equal chance +of winning. The winning miner completes a quantum experiment of sufficient confidence, creates a hash, and publishes a block. +Each unsuccessful miner validates the block and adjusts their pattern of mining based on the validation. +As this process iterates a panel is updated to demonstrate verification patterns. +A central graphic showing the state of the chain is updated showing either a single miner view +or the global view. The genesis block is placed at the center, with blocks spiraling outward in the order of proposal. + +### Miner Blockchain View + +The outer blue path representing the strongest chain, with other proposals marked in orange. +A miner can trust that the initial part of their strongest chain is immutable with high probability [[1]](#arXiv_2503_14462). +Transactions in this portion of the chain can be trusted, with some lower confidence in finality for the final few blocks. -### Variables -List and define (including type: e.g., "binary" or "integer") the variables solved for in your model. +### Global Blockchain View -### Expressions -List and define any combinations of variables used for easier representations of the models. +The user can can select a global view that shows the consistency amongst the various miners. +Blocks that are finalized for all miners are marked blue. Blocks that are rejected by all users are marked orange. +Gray and black blocks have disputed status, with black blocks being actively mined by at least one miner (only black blocks have potential for further branching). +The efficiency is determined by the proportion of blue blocks, which should be large. +The delay is determined by the number of grey and black blocks, which indicate blocks whose validity is currently contested by miners. -### Objective -Mathematical formulation of the objective described in the previous section using the listed -parameters, variables, etc. -### Constraints -Mathematical formulation of the constraints described in the previous section using the listed -parameters, variables, etc. +### Quantum Unitary Evolution and the Quantum Hash -## Code Overview +The unitary evolution that defines "the quantum puzzle", is defined by a set of programmable couplers, J, cryptographically determined by the strongest block (the block defining maximum chain work). +Each coupler is sampled uniformly at random +/- J for each edge matched to a 4x4x4 dimerized cubic lattice, with the desired evolution defined by a 5ns quench on the Advantage2_prototype2 system. +For other annealing QPUs to emulate the Advantage2_prototype2 schedule it is necessary to perform time energy rescaling (i.e. the rescaling of the problem Hamiltonian and annealing time is device specific with values precalculated). +A dimerized cubic lattice is a simple cubic lattice in which each node is replaced by a pair of nodes. +Miners access QPUs uniformly at random from the specified QPUs. +The QPU sampling makes use of the Ocean™ SDK composites framework with parallel embedding, +automorphism, and spin-reversal transform (SRT) averaging. The use of automorphism and SRT averaging, +enhances (relative) control errors, simulating variability that may exist across a more diverse ecosystem of QPUs. -A general overview of how the code works. +After the sampleset is collected, it is post-processed to nearest neighbor correlations. These correlations are then randomly projected by normally distributed random vectors, to give witnesses. The sign on the witnesses specify the bits of the quantum hash. -Include any notable parts of the code implementation: -* Talk about unusual or potentially difficult parts of the code -* Explain a code decision -* Explain how parameters were tuned +### Per-QPU One-Time Calibration: + +The unitary evolution is adjusted by selection of a QPU-specific energy-time +rescaling and embedding, precalculated for a restricted set of available solvers. + +A new online solver specified by name solver_name, or a change of a processor graph, typically dictates the creation of new embeddings as one time work. This can be done by running +```bash +python get_qpu_embeddings.py -Q solver_name +``` +from the calibration/ directory. +Embeddings are automatically saved to a location suitable for use by the demo. + +Different solvers are characterized by different energy scales. In order for a solver to emulate a reference unitary dynamics it is possible to either rescale upwards the time (if the energy scale is too low), or scale down the problem Hamiltonian (if the energy scale is too high). Estimates are determined by using +```bash +python get_qpu_energy_time_rescaling.py -Q solver_name +``` +again from the calibration/ directory. +Function customizations can be listed using the --help flag. + +In order to access a new solver in the demo: +1. The solver_name should also be enumerated as a SOLVER in src/protocols/hash_calculator.py +2. The solver_name and energy-time rescaling tuple should be added as a key-value pair to DEFAULT_ENERGY_TIME_RESCALING dictionary in src/values.py . -Note: there is no need to repeat everything that is already well-documented in -the code. ## References -A. Person (YEAR), "An Article Title that Helped Formulate the Problem". -[Link Title](https://example.com/) + +Blockchain with proof of quantum work +Mohammad H. Amin el al., arXiv:2503.14462 (2025) +https://arxiv.org/abs/2503.14462 + + +Beyond-classical computation in quantum simulation +Andrew D. King et al., Science (2025) +https://doi.org/10.1126/science.ado6285 ## License diff --git a/app.py b/app.py index 9eb3fb1..7658a5b 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave +# Copyright 2026 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,15 +17,8 @@ import argparse import dash -import diskcache -from dash import DiskcacheManager - -from demo_configs import APP_TITLE -from demo_interface import create_interface import dash_mantine_components as dmc - -# Essential for initializing callbacks. Do not remove. -import demo_callbacks +import diskcache # Fix Dash long callbacks crashing on macOS 10.13+ (also potentially not working # on other POSIX systems), caused by https://bugs.python.org/issue33725 @@ -35,6 +28,12 @@ # the `multiprocessing` library, but its fork, `multiprocess` still hasn't caught up. # (see docs: https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods) import multiprocess +from dash import DiskcacheManager + +# Essential for initializing callbacks. Do not remove. +import demo_callbacks +from demo_configs import APP_TITLE +from demo_interface import create_interface if multiprocess.get_start_method(allow_none=True) is None: multiprocess.set_start_method("spawn") diff --git a/assets/__demo_variables.css b/assets/__demo_variables.css index 49258ce..ff04904 100644 --- a/assets/__demo_variables.css +++ b/assets/__demo_variables.css @@ -38,7 +38,7 @@ Dash reads all css files contained in `/assets/` so no imports are necessary. --box-shadow: 0 0 1rem rgba(66, 82, 121, 0.2); --font: "proxima-nova", "Helvetica Neue", sans-serif; --banner-height: 3.5rem; - --left-col-width: 26.25rem; + --left-col-width: 25rem; --problem-details-height: 11.25rem; /*** Add new variables here ***/ } diff --git a/assets/demo.css b/assets/demo.css index 28313dc..27dc1d5 100644 --- a/assets/demo.css +++ b/assets/demo.css @@ -20,25 +20,178 @@ The following style rules can be altered or removed and any additional custom CSS can be added below. *******************************************************************/ -.tab-content-wrapper { - padding: 2rem 2rem 0; +#button-group { + margin-top: 2.5rem; +} + +#pause-button { + width: 100%; +} + +#reset-button, +#pause-button { + background-color: var(--red-light); +} + +#reset-button:hover, +#pause-button:hover { + background-color: var(--red-dark); +} + +#reset-resume-buttons { + display: flex; +} + +#reset-resume-buttons button { + width: 100%; +} + +#reset-resume-buttons button:first-child { + margin-right: 0.25rem; +} + +#reset-resume-buttons button:last-child { + margin-left: 0.25rem; +} + +#miner-table-title { + text-align: center; + font-weight: bold; + font-size: large; + width: 100% +} + +#miner-status-table { + margin: 0 auto 2rem; + width: 100%; + background-color: white; + display: inline-block; + overflow-y: auto; + max-height: calc(50vw - var(--left-col-width)/2); +} + +#miner-status-table th, +#miner-status-table td { + padding: 0.15rem 0.5rem; + border: 1px solid var(--grey-medium); + white-space: nowrap; +} + +#miner-status-table td:first-child { + font-weight: bold; +} + +#miner-status-table td.mined-cell { + background-color: black; + color: white; +} + +#miner-status-table td.validated-cell { + background-color: hsl(213, 75%, 80%); +} + +#miner-status-table td.rejected-cell { + background-color: hsl(25, 89%, 73%); +} + +#miner-status-table thead { + background-color: var(--blue-dark); + position: sticky; + top: 0; + z-index: 10; +} + +#miner-status-table th { + font-weight: 600; + color: white; + font-size: 1.125rem; + text-align: left; + border: 1px solid var(--blue-dark); +} + +#intro-text, +#prelim-text, +#loading-text { + text-align: center; display: flex; flex-direction: column; - justify-content: space-between; + height: 100%; + justify-content: center; +} + +.right-column { + padding: 2rem; +} + +.graph-loading { + height: 100%; +} + +.graph-loading .graph-loading > div:not(.display-none) { height: 100%; } -.input, .results { +.graph-table-wrapper { display: flex; - justify-content: center; - align-items: center; + height: 100%; + margin-top: 1rem; +} + +.graph-table-wrapper > div:first-child { + height: calc(50vw - var(--left-col-width)/2); + width: calc(50vw - var(--left-col-width)/2); + margin-right: 2rem; + position: relative; +} + +.graph-legend { + position: absolute; + bottom: 2px; + left: 0.75rem; +} + +.graph-legend p { + margin: 0.125rem 0; + font-size: 0.875rem; + font-weight: bold; +} + +.graph-legend span { + border-radius: 50%; + height: 1rem; + width: 1rem; + margin-right: 0.5rem; + display: inline-block; + vertical-align: middle; +} + +.graph-legend p:last-child span { + border: 4px solid; +} + +.graph-wrapper { + width: 100%; height: 100%; } -.problem-details-table { - width: 15.5rem; +#miner-graph-and-table { + height: 100%; + display: flex; + flex-direction: column; +} + +#miner-graph-and-table .dropdown-wrapper { + display: inline-block; + width: 12rem; + margin-right: 1rem; +} + +#miner-graph-and-table > div:first-child { + display: flex; + align-items: center; } - /****************************/ - /*** CUSTOM CSS GOES HERE ***/ -/****************************/ +#block-status { + font-style: italic; + margin-bottom: 0; +} diff --git a/demo_callbacks.py b/demo_callbacks.py index d1d3068..50b9fc1 100644 --- a/demo_callbacks.py +++ b/demo_callbacks.py @@ -1,147 +1,541 @@ -# Copyright 2024 D-Wave +# Copyright 2026 D-Wave # -# 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 +# 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. +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ from __future__ import annotations -from typing import Union +import copy +import random +import time +from typing import NamedTuple import dash -from dash import MATCH +import plotly.graph_objects as go +from dash import ALL, MATCH, ctx, set_props from dash.dependencies import Input, Output, State +from dash.exceptions import PreventUpdate -from demo_interface import generate_table -from src.demo_enums import SolverType +from demo_configs import ( + ALLOWABLE_ERR, + MIN_SIMULATION_LOOP_TIME, + MINER_NAMES, + N_ZEROES, + QUANTUM_HASH_LENGTH, +) +from demo_interface import GRAPH_VIEW_LABELS +from src.agents.trial_manager import TrialManager +from src.demo_enums import SolverMode +from src.structures.block import Block +from src.utilities.display_update import render_miner_status +from src.utilities.get_solvers import get_solver_lists +from src.utilities.spiral_plotter import SpiralPlotter + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ===================================================================================================== +# SECTION: Mining Round Steps | +# ===================================================================================================== +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @dash.callback( - Output({"type": "to-collapse-class", "index": MATCH}, "className"), - Output({"type": "collapse-trigger", "index": MATCH}, "aria-expanded"), + Output("reset-button", "className", allow_duplicate=True), + Output("pause-button", "className", allow_duplicate=True), inputs=[ - Input({"type": "collapse-trigger", "index": MATCH}, "n_clicks"), - State({"type": "to-collapse-class", "index": MATCH}, "className"), + Input("start-simulation", "data"), + State("miner-slider", "value"), + State("blocks-input", "value"), + State("blockchain-structure-data", "data"), + State("qpu-solver-select", "value"), + State("simulated-solver-select", "value"), + State("solver-mode-select", "value"), + ], + progress=[ + Output("current-block-data", "data"), + ], + running=[ + (Output("is-active-simulation", "data"), True, False), ], + cancel=[Input("pause-button", "n_clicks")], prevent_initial_call=True, + background=True, ) -def toggle_left_column(collapse_trigger: int, to_collapse_class: str) -> tuple[str, str]: - """Toggles a 'collapsed' class that hides and shows some aspect of the UI. +def simulation( + set_progress_miner_table, # set_progress function for 'progress' argument + start_simulation: bool, + num_miners: int, + num_blocks: int, + blockchain_structure: list, + qpu_solver_select_val: str, + simulated_solver_select_val: str, + solver_mode: str, +): + """Manages a single run of the blockchain simulation. + + This callback is triggered (indirectly) by the "run" and "resume" buttons. When triggered + by the "run" button, it will complete a full run of the blockchain simulation with the solver + scheme and the numbers of blocks and miners each defined by their respective input fields. As + it runs, it will call the 'update_current_block_data' function to provide progress updates, + which will then trigger other callbacks to update the display. Args: - collapse_trigger (int): The (total) number of times a collapse button has been clicked. - to_collapse_class (str): Current class name of the thing to collapse, 'collapsed' if not - visible, empty string if visible. + update_current_block_data: the progress function for providing updates while the callback is running + start_simulation (bool): flag to signal that the 'run' button has been clicked. Passing it this way + (instead of the 'run' button itself being used as an input), allows certain UI updates (such as + disabling/hiding components) to be processed immediately on clicking 'run', before the simulation starts + num_miners (int): the value of the the miner slider: determines how many miners the trial has. + num_blocks (int): the value of the blocks input. Determines how many blocks the simulation will run for. + blockchain_structure (list): The data structure storing the current blockchain data. If starting a trial, + this will simply be a list with 'None' in every field. But if resuming after a pause, this will hold + the data needed to reconstruct the blockchain + solver_mode (str): Value of the solver selector. Outputs as a string-typed integer value (e.g. + "1", "2"), which can just be immediately typed back to int and put into the AVAILABLE_SOLVERS + constant to get the solver + Returns: - str: The new class name of the thing to collapse. - str: The aria-expanded value. + Most of the output of this function is passed by 'update_current_block_data', so the only + actual return values simply toggle button visibility + + reset-button: makes the 'reset' button visible + pause-button: hides the 'pause' button """ - classes = to_collapse_class.split(" ") if to_collapse_class else [] - if "collapsed" in classes: - classes.remove("collapsed") - return " ".join(classes), "true" - return to_collapse_class + " collapsed" if to_collapse_class else "collapsed", "false" + if ctx.triggered_id != "start-simulation": + raise PreventUpdate + + solver_mode = SolverMode(solver_mode) + print(f"Starting TrialManager with {num_blocks} blocks and {num_miners} miners") + + available_qpu_solvers, available_simulated_solvers = get_solver_lists() + + mode_config = { + SolverMode.QPU: (int(qpu_solver_select_val), available_qpu_solvers), + SolverMode.SIMULATED: (int(simulated_solver_select_val), available_simulated_solvers), + } + dropdown_idx, solvers = mode_config[solver_mode] + + if dropdown_idx > 0: + solvers = [solvers[dropdown_idx - 1]] + + manager = TrialManager( + num_blocks, MINER_NAMES[:num_miners], solvers, QUANTUM_HASH_LENGTH, N_ZEROES, ALLOWABLE_ERR + ) + + block_dict_template = { + "block_json": "", + "block_number": 0, + "scores": {}, + "solvers": {}, + "miner_id": "", + } + current_block_dict = copy.deepcopy(block_dict_template) + current_blockchain = [] + + if len(blockchain_structure) > 0: + current_idx = len(blockchain_structure) + print(f"Restarting trial at block {current_idx}") + short_blockchain = blockchain_structure[:-1] + last_block = blockchain_structure[-1] + finished_miners = [miner_id for miner_id in last_block["scores"].keys()] + unfinished_miners = [] + + for miner_id, miner in manager.miners.items(): + if miner_id in last_block["scores"]: + miner.re_initialize_blockchain(blockchain_structure) + else: + miner.re_initialize_blockchain(short_blockchain) + unfinished_miners.append(miner_id) + + manager.round_progress = len(finished_miners) + manager.block_broadcast = last_block["block_json"] + random.shuffle(unfinished_miners) + manager.round_order = finished_miners + unfinished_miners + current_block_dict = last_block + manager.blocks_mined = current_block_dict["block_number"] + set_progress_miner_table(current_block_dict) + current_block = Block.from_json(current_block_dict["block_json"]) + for miner_id, miner in manager.miners.items(): + assert ( + current_block.previous_hash in miner.blockchain.hash_to_branch_lookup + ), f"{miner_id} failed to have latest block {current_block.hash} with tree structure {miner.blockchain}" + current_blockchain = blockchain_structure + + print("Finished restarting simulation.") + + # Miner graph views available in the form GRAPH_ID: INDEX + view_miners = {MINER_NAMES[(i - 1) % num_miners]: i for i in range(len(GRAPH_VIEW_LABELS))} + + while manager.blocks_mined <= num_blocks: + iter_start_time = time.time() + if manager.round_progress == 0 and manager.blocks_mined == num_blocks: + break + + mined, miner_id, block_score, solver = manager.single_step() + if mined: + current_block_dict = copy.deepcopy(block_dict_template) + current_block_dict["block_number"] = manager.blocks_mined + current_block_dict["block_json"] = manager.block_broadcast + current_block_dict["miner_id"] = miner_id + + print(f"TrialManager starting new round with {manager.blocks_mined} blocks mined.") + + current_block_dict["scores"][miner_id] = block_score + current_block_dict["solvers"][miner_id] = solver + current_blockchain.append(current_block_dict) + + else: + current_block_dict["scores"][miner_id] = block_score + current_block_dict["solvers"][miner_id] = solver + current_blockchain[-1] = current_block_dict + + set_props("blockchain-structure-data", {"data": current_blockchain}) + time.sleep(0.2) + + set_progress_miner_table(current_block_dict) + + time.sleep(0.2) + + if miner_id in view_miners: + active_hashes = manager.get_active_block_hashes() + most_recent_block = manager.miners[miner_id].blockchain.most_recent_block + plotter = SpiralPlotter() + if view_miners[miner_id] == 0: # Global view + last_shared_block = manager.get_last_common_trunk_block() + miner_fig = plotter.create_plot_from_tree( + manager.miners[miner_id].blockchain, + active_blocks=active_hashes, + active_block_cutoff=last_shared_block, + mining_block=most_recent_block, + ) + else: + miner_fig = plotter.create_plot_from_tree( + manager.miners[miner_id].blockchain, + active_blocks=active_hashes, + mining_block=most_recent_block, + ) + + miner_fig.update_layout( + autosize=False, + showlegend=False, + xaxis=dict(showticklabels=False), + yaxis=dict(showticklabels=False), + margin=dict(l=0, r=0, b=0, t=0, pad=4), + paper_bgcolor="white", + plot_bgcolor="white", + ) + set_props( + {"type": "view-graph", "index": view_miners[miner_id]}, # Graph ID + {"figure": miner_fig}, + ) + + iter_end_time = time.time() + iter_total_time = iter_end_time - iter_start_time + if iter_total_time < MIN_SIMULATION_LOOP_TIME: + time.sleep(MIN_SIMULATION_LOOP_TIME - iter_total_time) + + return "", "display-none" + + +# ====================================================================================================== @dash.callback( - Output("input", "children"), + Output("miner-graph-and-table", "className", allow_duplicate=True), + Output("prelim-text", "className"), + Output("block-status", "children", allow_duplicate=True), + Output("miner-status-table", "children", allow_duplicate=True), + Output("graph-loading", "display", allow_duplicate=True), inputs=[ - Input("slider", "value"), + Input("current-block-data", "data"), + State("miner-slider", "value"), + State("solver-mode-select", "value"), + State("qpu-solver-select", "value"), + State("simulated-solver-select", "value"), + State("blocks-input", "value"), ], + prevent_initial_call=True, ) -def render_initial_state(slider_value: int) -> str: - """Runs on load and any time the value of the slider is updated. - Add `prevent_initial_call=True` to skip on load runs. +def update_miner_display( + current_block_data: dict, + num_miners: int, + solver_mode: str, + qpu_select: str, + simulated_select: str, + num_blocks: int, +): + """Processes blockchain structure data and uses it to update the miner status + table and the graph display.""" - Args: - slider_value: The value of the slider. + solver_mode = SolverMode(solver_mode) + show_solvers = (solver_mode is SolverMode.QPU and int(qpu_select) == 0) or ( + solver_mode is SolverMode.SIMULATED and int(simulated_select) == 0 + ) - Returns: - str: The content of the input tab. - """ - return f"Put demo input here. The current slider value is {slider_value}." + block_number = current_block_data["block_number"] + + block_status_text = ( + f"Currently mining block number {block_number}" + if block_number <= num_blocks + else "Finished Simulation" + ) + + miner_table_body = render_miner_status(current_block_data, num_miners, show_solvers) + + graph_loading = "auto" if block_number > 1 else dash.no_update + + return "", "display-none", block_status_text, miner_table_body, graph_loading + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ===================================================================================================== +# SECTION: User Controls | +# ===================================================================================================== +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +class RunSimulationReturn(NamedTuple): + """Return type for the ``run_simulation`` callback function.""" + + run_button_classname: str = "display-none" + reset_button_classname: str = "display-none" + pause_button_classname: str = "" + start_simulation: bool = True + miner_slider_disabled: bool = True + blocks_input_disabled: bool = True + qpu_solver_select_disabled: bool = True + simulated_solver_select_disabled: bool = True + simulation_is_active: bool = True + graph_loading_display: str = "show" @dash.callback( - # The Outputs below must align with the return values of the function. - Output("results", "children"), - Output("problem-details", "children"), - background=True, + Output("run-button", "className", allow_duplicate=True), + Output("reset-button", "className", allow_duplicate=True), + Output("pause-button", "className", allow_duplicate=True), + Output("start-simulation", "data", allow_duplicate=True), + Output("miner-slider", "disabled", allow_duplicate=True), + Output("blocks-input", "disabled", allow_duplicate=True), + Output("qpu-solver-select", "disabled", allow_duplicate=True), + Output("simulated-solver-select", "disabled", allow_duplicate=True), + Output("is-active-simulation", "data", allow_duplicate=True), + Output("graph-loading", "display", allow_duplicate=True), inputs=[ - # The first string in the Input/State elements below must match an id in demo_interface.py - # Remove or alter the following id's to match any changes made to demo_interface.py Input("run-button", "n_clicks"), - State("solver-type-select", "value"), - State("solver-time-limit", "value"), - State("slider", "value"), - State("dropdown", "value"), - State("checklist", "value"), - State("radio", "value"), + State("is-active-simulation", "data"), ], - running=[ - (Output("cancel-button", "className"), "", "display-none"), # Show/hide cancel button. - (Output("run-button", "className"), "display-none", ""), # Hides run button while running. - (Output("results-tab", "disabled"), True, False), # Disables results tab while running. - (Output("results-tab", "label"), "Loading...", "Results"), - (Output("tabs", "value"), "input-tab", "input-tab"), # Switch to input tab while running. - (Output("run-in-progress", "data"), True, False), # Can block certain callbacks. + prevent_initial_call=True, +) +def run_simulation(run_click: int, simulation_is_active: bool) -> RunSimulationReturn: + """Runs a simulation with the selected number of miners and blocks. + + Args: + run_click (int): unused + simulation_is_active (bool): Returns 'True.' Flag to signal that one instance of + 'simulation' callback is already running, so another should not be started""" + if simulation_is_active: + raise PreventUpdate + + return RunSimulationReturn() + + +# ======================================================================================== + + +@dash.callback( + Output("reset-button", "className", allow_duplicate=True), + Output("resume-button", "className", allow_duplicate=True), + Output("pause-button", "className", allow_duplicate=True), + Output("is-active-simulation", "data", allow_duplicate=True), + inputs=[ + Input("pause-button", "n_clicks"), ], - cancel=[Input("cancel-button", "n_clicks")], prevent_initial_call=True, ) -def run_optimization( - # The parameters below must match the `Input` and `State` variables found - # in the `inputs` list above. - run_click: int, - solver_type: str, - time_limit: float, - slider_value: int, - dropdown_value: int, - checklist_value: list, - radio_value: int, -) -> tuple[str, list]: - """Runs the optimization and updates UI accordingly. - - This is the main function which is called when the ``Run Optimization`` button is clicked. - This function takes in all form values and runs the optimization, updates the run/cancel - buttons, deactivates (and reactivates) the results tab, and updates all relevant HTML - components. +def pause_simulation(pause_click: int): + """This callback will pause the current, in-progress simulation. In reality, the + 'simulation' callback is cancelled, but the data defining its current state is + still stored in the blockchain_structure_data dcc.Store object, so the simulation + can be restarted by reconstructing the state. Args: - run_click: The (total) number of times the run button has been clicked. - solver_type: The solver to use for the optimization run defined by SolverType in demo_enums.py. - time_limit: The solver time limit. - slider_value: The value of the slider. - dropdown_value: The value of the dropdown. - checklist_value: A list of the values of the checklist. - radio_value: The value of the radio. + pause_click (int): Unused. The pause button just needs to trigger the callback, + its value is irrelevant. Returns: - results: The results to display in the results tab. - problem-details: List of the table rows for the problem details table. + reset-button (str): makes visible + resume-button (str): makes visible + pause-button (str): hides""" + + return "", "", "display-none", False + + +# ======================================================================================== + + +@dash.callback( + Output("reset-button", "className", allow_duplicate=True), + Output("resume-button", "className", allow_duplicate=True), + Output("pause-button", "className", allow_duplicate=True), + Output("start-simulation", "data", allow_duplicate=True), + Output("is-active-simulation", "data", allow_duplicate=True), + inputs=[ + Input("resume-button", "n_clicks"), + State("is-active-simulation", "data"), + ], + prevent_initial_call=True, +) +def resume_simulation(pause_click: int, simulation_is_active: bool): + """Resumes a paused simulation. In practice, this means starting a new instance of the + 'simulation' callback, but without resetting the blockchain data. The simulation + will then reconstruct its previous state and pick up where it left off. + + Args: + pause_click (int): Unused. + simulation_is_active (bool): Returns 'True.' Flag to signal that one instance of + 'simulation' callback is already running, so another should not be started + + Returns: + reset-button (str): hides + resume-button (str): hides + pause-button (str): makes visible + start-simulation (bool): Altering this Store (even from True to True) triggers the + 'simulation' callback, in this case resuming an in-progress simulation.""" + + if simulation_is_active: + raise PreventUpdate + + return "display-none", "display-none", "", True, True + + +# ======================================================================================== +class ResetSimulationReturn(NamedTuple): + """Return type for the ``reset_simulation`` callback function.""" + + intro_text_classname: str = "" + loading_text_classname: str = "display-none" + miner_graph_and_table_classname: str = "display-none" + run_button_classname: str = "" + reset_button_classname: str = "display-none" + resume_button_classname: str = "display-none" + prelim_text_classname: str = "" + miner_slider_disabled: bool = False + blocks_input_disabled: bool = False + qpu_solver_select_disabled: bool = False + simulated_solver_select_disabled: bool = False + blockchain_data: list = [] + graphs: list[go.Figure] = dash.no_update + + +@dash.callback( + Output("intro-text", "className", allow_duplicate=True), + Output("loading-text", "className", allow_duplicate=True), + Output("miner-graph-and-table", "className", allow_duplicate=True), + Output("run-button", "className", allow_duplicate=True), + Output("reset-button", "className", allow_duplicate=True), + Output("resume-button", "className", allow_duplicate=True), + Output("prelim-text", "className", allow_duplicate=True), + Output("miner-slider", "disabled", allow_duplicate=True), + Output("blocks-input", "disabled", allow_duplicate=True), + Output("qpu-solver-select", "disabled", allow_duplicate=True), + Output("simulated-solver-select", "disabled", allow_duplicate=True), + Output("blockchain-structure-data", "data", allow_duplicate=True), + Output({"type": "view-graph", "index": ALL}, "figure"), + inputs=[ + Input("reset-button", "n_clicks"), + State({"type": "view-graph", "index": ALL}, "figure"), + ], + prevent_initial_call=True, +) +def reset_simulation(reset_click: int, graphs: list) -> ResetSimulationReturn: + """Resets the simulation, allowing a new simulation to be started.""" + + return ResetSimulationReturn(graphs=[go.Figure()] * len(graphs)) + + +# ======================================================================================= + + +@dash.callback( + Output({"type": "view-wrapper", "index": ALL}, "className"), + inputs=[ + Input("view-select", "value"), + State({"type": "view-wrapper", "index": ALL}, "className"), + ], + prevent_initial_call=True, +) +def toggle_graph_display(selected_view: str, graphs: list[str]): + """Toggles the visibility of the four different graph displays. Will default to showing the Global + View graph. When triggered, will hide three of the graph displays and make the selected one visible. """ - solver_type = SolverType(int(solver_type)) + return_tuple = ["display-none"] * len(graphs) + return_tuple[int(selected_view)] = "" + return return_tuple - ########################### - ### YOUR CODE GOES HERE ### - ########################### +# ========================================================================================= - # Generates the problem details table on the results page. - problem_details_table = generate_table( - {"Solver": [solver_type.label], "Time Limit": [time_limit]} - ) - return "Put demo results here.", problem_details_table +@dash.callback( + Output("qpu-dropdown", "className"), + Output("simulated-dropdown", "className"), + inputs=[ + Input("solver-mode-select", "value"), + ], +) +def toggle_solver_mode(solver_mode): + """Toggles between QPU Solver mode and Simulated Solver Mode""" + solver_mode = SolverMode(solver_mode) + + if solver_mode is SolverMode.QPU: + return "", "display-none" + elif solver_mode is SolverMode.SIMULATED: + return "display-none", "" + + raise Exception("Invalid solver select option") + + +@dash.callback( + Output({"type": "to-collapse-class", "index": MATCH}, "className"), + Output({"type": "collapse-trigger", "index": MATCH}, "aria-expanded"), + inputs=[ + Input({"type": "collapse-trigger", "index": MATCH}, "n_clicks"), + State({"type": "to-collapse-class", "index": MATCH}, "className"), + ], + prevent_initial_call=True, +) +def toggle_left_column(collapse_trigger: int, to_collapse_class: str) -> tuple[str, str]: + """Toggles a 'collapsed' class that hides and shows some aspect of the UI. + + Args: + collapse_trigger (int): The (total) number of times a collapse button has been clicked. + to_collapse_class (str): Current class name of the thing to collapse, 'collapsed' if not + visible, empty string if visible. + + Returns: + str: The new class name of the thing to collapse. + str: The aria-expanded value. + """ + + classes = to_collapse_class.split(" ") if to_collapse_class else [] + if "collapsed" in classes: + classes.remove("collapsed") + return " ".join(classes), "true" + return to_collapse_class + " collapsed" if to_collapse_class else "collapsed", "false" diff --git a/demo_configs.py b/demo_configs.py index d62be88..d5e350c 100644 --- a/demo_configs.py +++ b/demo_configs.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave +# Copyright 2026 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,41 +12,89 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""This file stores input parameters for the app.""" +"""This file stores input parameters for the app and various configuration values that +will change the appearance (but generally not the functionality) of the demo as it runs.""" THUMBNAIL = "static/dwave_logo.svg" -APP_TITLE = "Demo Name" -MAIN_HEADER = "Demo Name" +APP_TITLE = "Quantum Blockchain" +MAIN_HEADER = "Quantum Blockchain" DESCRIPTION = """\ -This is a Dash template for new examples. It includes some basic settings, tabs, and styling. +A Proof of Quantum Work blockchain run on D-Wave Quantum Processing Units """ +HIDE_SIMULATED_SOLVERS = True + +INTRO_TEXT = 'Select a number of miners and blocks and press "Begin Simulation".' +INTRO_SUBTEXT = "Results will display here while simulation is running." +LOADING_TEXT = "Generating graphs..." + +# Minimum time for a single loop in "simulation" callback. +# If loops complete too quickly, display components won't update correctly. +MIN_SIMULATION_LOOP_TIME = 1.1 + +####################################### +# Graph Visual Elements # +####################################### +TRUNK_POINT_COLOR = "#1F74DA" # Blocks that are considered valid parts of the main chain +TRUNK_EDGE_COLOR = "#7AAEEC" # Spiral segments connecting the valid blocks +ABANDONED_BRANCH_POINT_COLOR = "#D9610C" # Blocks that are not considered valid +ABANDONED_BRANCH_EDGE_COLOR = "#F5924B" # Spiral segments connecting invalid blocks +ACTIVE_BRANCH_POINT_COLOR = "#929292" # Differentiates 'disputed' blocks from 'consensus' blocks +ACTIVE_BRANCH_EDGE_COLOR = "#C5C5C5" +MINING_BLOCK_BORDER_COLOR = "#17BEBB" # Block that is currently (or most recently) being mined on +TRUNK_TIP_COLOR = "black" # Block at the end of the trunk and other 'active' blocks in global view +GRAPH_RADIAL_LINE_COLOR = "#E6E6E6" # Radial lines that help with graph readability +GRAPH_RADIAL_LINE_WIDTH = 0.7 # Width of the radial lines + +GRAPH_POINT_MIN_SIZE = 5 # Points drawn nearer the center of the graph will be closer to this size +# Points drawn closer to the edge of the graph will be closer to this size +GRAPH_POINT_MAX_SIZE = 15 + +# Determines how large the branch points are relative to the trunk points. +GRAPH_BRANCH_POINT_SCALING = 0.65 +GRAPH_MAX_POINTS_PER_REVOLUTION = 36 # Set to a multiple of 4 to keep dynamic adjustments nice +GRAPH_MIN_POINTS_PER_REVOLUTION = 8 # How many points are drawn in one full 'turn' of the spiral + +# Controls how many straight segments are used to connect each graph point. More segments make a +# smoother curve. Dynamically adjusted to the size of the graph. +GRAPH_SEGMENTS_PER_REVOLUTION = GRAPH_MAX_POINTS_PER_REVOLUTION * 2 + +# Value to multiply each successive loop (in from the outermost one) of the spiral graph. +GRAPH_LOOP_SCALING = 2 / 3 + +# Furthest out on the chart area that points will be drawn. Value of 1 is the very edge. +GRAPH_MAX_RADIUS = 0.999 + +# How far in towards the next trunk section a branch can extend. +# Value of 1 is all the way to the trunk. +GRAPH_MAX_BRANCH_DISTANCE = 0.78 + + ####################################### # Sliders, buttons and option entries # ####################################### -# an example slider -SLIDER = { - "min": 1, - "max": 10, - "step": 1, - "value": 5, -} - -# an example dropdown -DROPDOWN = ["Option 1", "Option 2"] - -# an example checklist -CHECKLIST = ["Option 1", "Option 2"] - -# an example radio list -RADIO = ["Option 1", "Option 2"] - -# solver time limits in seconds (value means default) -SOLVER_TIME = { - "min": 10, - "max": 300, - "step": 5, - "value": 10, -} +MINER_SLIDER = {"min": 4, "max": 28, "step": 1, "value": 7} + +NUM_BLOCKS = {"min": 3, "max": 600, "step": 1, "value": 20} + +NUM_MINER_VIEWS = 3 + +MINER_NAMES = [f"Miner_{i}" for i in range(1, MINER_SLIDER["max"] + 1)] + +####################################### +# Mining Difficulty Parameters # +####################################### + +# Length of the quantum hash. Determines how difficult it is to mine and validate a block. +QUANTUM_HASH_LENGTH = 64 + +# The number of single-bit errors validators will allow. Increasing this will cause validators to +# reject blocks more often, resulting in the chain branching more. +ALLOWABLE_ERR = 1 + +# Hardness block mining. At hardness 0, mining succeeds on every attempt. At hardness n, +# it takes (on average) 2^n attempts to mine a block. Increasing this will slow down the +# mining rate and make the simulation take longer. +N_ZEROES = 0 diff --git a/demo_interface.py b/demo_interface.py index e503f11..b7fd422 100644 --- a/demo_interface.py +++ b/demo_interface.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave +# Copyright 2026 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,25 +13,43 @@ # limitations under the License. """This file stores the Dash HTML layout for the app.""" + from __future__ import annotations -from dash import dcc, html +from collections import namedtuple + import dash_mantine_components as dmc +from dash import dcc, html from demo_configs import ( - CHECKLIST, + ABANDONED_BRANCH_POINT_COLOR, + ACTIVE_BRANCH_POINT_COLOR, DESCRIPTION, - DROPDOWN, + HIDE_SIMULATED_SOLVERS, + INTRO_SUBTEXT, + INTRO_TEXT, + LOADING_TEXT, MAIN_HEADER, - RADIO, - SLIDER, - SOLVER_TIME, + MINER_NAMES, + MINER_SLIDER, + MINING_BLOCK_BORDER_COLOR, + NUM_BLOCKS, + NUM_MINER_VIEWS, THUMBNAIL, + TRUNK_POINT_COLOR, + TRUNK_TIP_COLOR, ) -from src.demo_enums import SolverType +from src.demo_enums import SolverMode +from src.utilities.get_solvers import get_solver_lists THEME_COLOR = "#2d4376" +ViewOption = namedtuple("ViewOption", ["menu_select", "graph_name", "wrapper_name", "miner_number"]) + +GRAPH_VIEW_LABELS = ["Global View"] + [ + f'{" ".join(MINER_NAMES[i].split("_"))} View' for i in range(NUM_MINER_VIEWS) +] + def slider(label: str, id: str, config: dict) -> html.Div: """Slider element for value selection. @@ -39,7 +57,7 @@ def slider(label: str, id: str, config: dict) -> html.Div: Args: label: The title that goes above the slider. id: A unique selector for this element. - config: A dictionary of slider configerations, see dcc.Slider Dash docs. + config: A dictionary of slider configurations, see dcc.Slider Dash docs. """ return html.Div( className="slider-wrapper", @@ -50,8 +68,8 @@ def slider(label: str, id: str, config: dict) -> html.Div: className="slider", **config, marks=[ - {"value": config["min"], "label": f"{config["min"]}"}, - {"value": config["max"], "label": f"{config["max"]}"}, + {"value": config["min"], "label": f"{config['min']}"}, + {"value": config["max"], "label": f"{config['max']}"}, ], labelAlwaysOn=True, thumbLabel=f"{label} slider", @@ -69,10 +87,11 @@ def dropdown(label: str, id: str, options: list) -> html.Div: id: A unique selector for this element. options: A list of dictionaries of labels and values. """ + return html.Div( className="dropdown-wrapper", children=[ - html.Label(label, htmlFor=id), + html.Label(label, htmlFor=id) if label else (), dmc.Select( id=id, data=options, @@ -83,51 +102,41 @@ def dropdown(label: str, id: str, options: list) -> html.Div: ) -def checklist(label: str, id: str, options: list, values: list, inline: bool = True) -> html.Div: - """Checklist element for option selection. +def radio( + label: str, id: str, options: list, value: int, inline: bool = True, class_name="" +) -> html.Div: + """Radio element for option selection. Args: - label: The title that goes above the checklist. + label: The title that goes above the radio. id: A unique selector for this element. options: A list of dictionaries of labels and values. - values: A list of values that should be preselected in the checklist. - inline: Whether the options of the checklist are displayed beside or below each other. + value: The value of the radio that should be preselected. + inline: Whether the options are displayed beside or below each other. """ return html.Div( - className="checklist-wrapper", + className=class_name, children=[ html.Label(label, htmlFor=id), - dcc.Checklist( + dcc.RadioItems( id=id, - className=f"checklist{' checklist--inline' if inline else ''}", + className=f"radio{' radio--inline' if inline else ''}", inline=inline, options=options, - value=values, + value=value, ), ], ) -def radio(label: str, id: str, options: list, value: int, inline: bool = True) -> html.Div: - """Radio element for option selection. - - Args: - label: The title that goes above the radio. - id: A unique selector for this element. - options: A list of dictionaries of labels and values. - value: The value of the radio that should be preselected. - inline: Whether the options are displayed beside or below each other. - """ +def input_number(label: str, id: str, config: dict) -> html.Div: return html.Div( - className="radio-wrapper", + className="blocks-input-wrapper", children=[ html.Label(label, htmlFor=id), - dcc.RadioItems( + dmc.NumberInput( id=id, - className=f"radio{' radio--inline' if inline else ''}", - inline=inline, - options=options, - value=value, + **config, ), ], ) @@ -138,134 +147,114 @@ def generate_options(options_list: list) -> list[dict]: return [{"label": label, "value": i} for i, label in enumerate(options_list)] +def generate_options_dropdown(options_list: list) -> list[dict]: + """Generates options for dropdowns, checklists, radios, etc.""" + return [{"label": label, "value": f"{i}"} for i, label in enumerate(options_list)] + + def generate_settings_form() -> html.Div: """This function generates settings for selecting the scenario, model, and solver. Returns: html.Div: A Div containing the settings for selecting the scenario, model, and solver. """ - dropdown_options = [{"label": label, "value": f"{i}"} for i, label in enumerate(DROPDOWN)] - checklist_options = generate_options(CHECKLIST) - radio_options = generate_options(RADIO) + available_qpu_solvers, available_simulated_solvers = get_solver_lists() + + qpu_solver_opts = [f"Random {SolverMode.QPU.label}"] + qpu_solver_opts += [solver.solver_name for solver in available_qpu_solvers] + + simulated_solver_opts = [f"Random {SolverMode.SIMULATED.label}"] + simulated_solver_opts += [solver.solver_name for solver in available_simulated_solvers] - solver_options = [ - {"label": solver_type.label, "value": f"{solver_type.value}"} for solver_type in SolverType + solver_mode_options = [ + {"label": solver_mode.label, "value": solver_mode.value} for solver_mode in SolverMode ] - return html.Div( - className="settings", - children=[ - slider( - "Example Slider", - "slider", - SLIDER, - ), - dropdown( - "Example Dropdown", - "dropdown", - sorted(dropdown_options, key=lambda op: op["value"]), - ), - checklist( - "Example Checklist", - "checklist", - sorted(checklist_options, key=lambda op: op["value"]), - [0], - ), - radio( - "Example Radio", - "radio", - sorted(radio_options, key=lambda op: op["value"]), - 0, + solver_settings = ( + radio( + "Solver Mode", + "solver-mode-select", + solver_mode_options, + solver_mode_options[0]["value"], + class_name="display-none" if HIDE_SIMULATED_SOLVERS else "", + ), + html.Div( + id="qpu-dropdown", + children=dropdown( + "Solver", "qpu-solver-select", generate_options_dropdown(qpu_solver_opts) ), - dropdown( + ), + html.Div( + id="simulated-dropdown", + className="display-none", + children=dropdown( "Solver", - "solver-type-select", - sorted(solver_options, key=lambda op: op["value"]), - ), - html.Label("Solver Time Limit (seconds)", htmlFor="solver-time-limit"), - dmc.NumberInput( - id="solver-time-limit", - type="number", - **SOLVER_TIME, + "simulated-solver-select", + generate_options_dropdown(simulated_solver_opts), ), + ), + ) + + return html.Div( + className="settings", + children=[ + slider("Number of Miners", "miner-slider", MINER_SLIDER), + input_number("Number of Blocks", "blocks-input", NUM_BLOCKS), + *solver_settings, ], ) def generate_run_buttons() -> html.Div: - """Run and cancel buttons to run the optimization.""" + """Run, Pause, Reset and Resume buttons for the simulation""" return html.Div( id="button-group", children=[ - html.Button(id="run-button", children="Run Optimization", n_clicks=0, disabled=False), + html.Button(id="run-button", children="Start Simulation", n_clicks=0, disabled=False), html.Button( - id="cancel-button", - children="Cancel Optimization", + id="pause-button", + children="Pause", n_clicks=0, className="display-none", ), - ], - ) - - -def generate_table(table_data: dict[str, list]) -> html.Table: - """Generates a table containing table_data. - - Args: - table_data: A dictionary of table header keys and table column values. - - Returns: - html.Table: An HTML table containing table_data. - """ - table_columns = table_data.values() - num_rows = len(next(iter(table_columns))) - - return html.Table( - className="problem-details-table", - children=[ - html.Thead(html.Tr([html.Th(table_header) for table_header in table_data.keys()])), - html.Tbody( - [ - html.Tr( - [ - html.Td(column[i]) for column in table_columns - ] - ) for i in range(num_rows) - ] + html.Div( + id="reset-resume-buttons", + className="", + children=[ + html.Button( + id="reset-button", + children="Reset", + n_clicks=0, + className="display-none", + ), + html.Button( + id="resume-button", + children="Resume", + n_clicks=0, + className="display-none", + ), + ], ), ], ) -def problem_details(index: int) -> html.Div: - """Generate the problem details section. - - Args: - index: Unique element id to differentiate matching elements. - Must be different from left column collapse button. +def graph_legend() -> html.Div: + """Generate graph legend""" - Returns: - html.Div: Div containing a collapsable table. - """ + legend_items = ( + ("background", TRUNK_POINT_COLOR, "Consensus"), + ("background", ABANDONED_BRANCH_POINT_COLOR, "Abandoned"), + ("background", ACTIVE_BRANCH_POINT_COLOR, "Undecided"), + ("background", TRUNK_TIP_COLOR, "Available to Mine"), + ("border-color", MINING_BLOCK_BORDER_COLOR, "Currently Mining"), + ) return html.Div( - id={"type": "to-collapse-class", "index": index}, - className="details-collapse-wrapper collapsed", - children=[ - # Problem details collapsible button and header - html.Button( - id={"type": "collapse-trigger", "index": index}, - className="details-collapse", - children=[ - html.H5("Problem Details"), - html.Div(className="collapse-arrow"), - ], - **{"aria-expanded": "true"}, - ), - html.Div( - className="details-to-collapse", - id="problem-details", - ), + [ + html.P([html.Span(style={style_rule: color}), label]) + for style_rule, color, label in legend_items ], + className="graph-legend", ) @@ -280,8 +269,12 @@ def create_interface(): id="skip-to-main", className="skip-link", ), - # Below are any temporary storage items, e.g., for sharing data between callbacks. - dcc.Store(id="run-in-progress", data=False), # Indicates whether run is in progress + # The data in this first store is irrelevant: it acts as a pass-through to trigger the + # simulation callback when targeted by other callbacks. + dcc.Store(id="start-simulation", data=False), + dcc.Store(id="is-active-simulation", data=False), + dcc.Store(id="current-block-data", data=""), + dcc.Store(id="blockchain-structure-data", data=[]), # Header brand banner html.Header(className="banner", children=[html.Img(src=THUMBNAIL, alt="D-Wave logo")]), # Settings and results columns @@ -324,55 +317,78 @@ def create_interface(): html.Div( className="right-column", children=[ - dcc.Tabs( - id="tabs", - value="input-tab", - mobile_breakpoint=0, + html.Div( + id="prelim-text", + className="", children=[ - dcc.Tab( - label="Input", - id="input-tab", - value="input-tab", # for switching tabs programatically - className="tab", - children=[ - html.Div( - className="tab-content-wrapper", - children=[ - dcc.Loading( - parent_className="input", - type="circle", - color=THEME_COLOR, - # A Dash callback (in app.py) will generate content in the Div below - children=html.Div(id="input"), - ), - ] - ) + html.Div( + children=[html.H3(INTRO_TEXT), html.P(INTRO_SUBTEXT)], + id="intro-text", + ), + html.H3( + LOADING_TEXT, + id="loading-text", + className="display-none", + ), + ], + ), + html.Div( + className="display-none", + id="miner-graph-and-table", + children=[ + html.Div( + [ + dropdown( + "", + "view-select", + generate_options_dropdown( + [label for label in GRAPH_VIEW_LABELS] + ), + ), + html.H4(id="block-status"), ], ), - dcc.Tab( - label="Results", - id="results-tab", - className="tab", - disabled=True, + html.Div( + className="graph-table-wrapper", children=[ html.Div( - className="tab-content-wrapper", - children=[ + [ dcc.Loading( - parent_className="results", + parent_className="graph-loading", + id="graph-loading", type="circle", color=THEME_COLOR, - # A Dash callback (in app.py) will generate content in the Div below - children=html.Div(id="results"), + children=[ + html.Div( + id={ + "type": "view-wrapper", + "index": i, + }, + className=f"graph-wrapper {'display-none' if i > 0 else ''}", + children=[ + dcc.Graph( + id={ + "type": "view-graph", + "index": i, + }, + responsive=True, + config={ + "displayModeBar": False + }, + ), + ], + ) + for i in range(len(GRAPH_VIEW_LABELS)) + ], ), - # Problem details dropdown - html.Div([html.Hr(), problem_details(1)]), - ], - ) + graph_legend(), + ] + ), + html.Div(html.Table(id="miner-status-table")), ], ), ], - ) + ), ], ), ], diff --git a/get_qpu_embeddings.py b/get_qpu_embeddings.py new file mode 100644 index 0000000..29ed3f4 --- /dev/null +++ b/get_qpu_embeddings.py @@ -0,0 +1,219 @@ +# Copyright 2026 D-Wave +# +# 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. +import argparse +import os + +from dwave.system import DWaveSampler + +from src.utilities.quantum_cubic_utils import ( + create_lattice, + get_embeddings, + get_embeddings_filename, + source_dimer_orientation, + target_dimer_orientation, +) +from src.values import EMBEDDINGS_PATH + + +def main( + qpu: DWaveSampler, + *, + subgraph_embedding_timeout: float | int = 60, + parallel_embedding_timeout: float | int = 600, + seed: int = 1, + embedding_directory: str = EMBEDDINGS_PATH, + verbose: bool = True, + overwrite: bool = False, +) -> list[dict]: + """Finds embeddings for a specific qpu/sampler combination. + + For embeddings to be used as part of the demo they should be saved to + the EMBEDDINGS_PATH + + find_subgraph is a complete method, guaranteed to return a 1:1 embedding + (also called a subgraph isomorphism), should it exist (given sufficient time). + This routine is called iteratively + to determine disjoint embeddings with the find_multiple_embeddings routine. + The result is returned either when no further subgraphs can be found, + timeouts are reached in either routine, or the routine is interrupted. + Different seeds may impact the sequence of embeddings + found and the maximum number of embeddings. + Since unitary dynamics are parallelized across embeddings with results aggregated + more embeddings results typically results in better sampling error and higher + cross-validation. + + Note that, for typical applications, initial subgraph searches are fast + and only the final (unsuccessful) search is slow. Default settings + reflect limited testing and allow room for improvement. :code:`find_subgraph` + allows a number of parameters that can potentially accelerate search. + Divide and conquer strategies (e.g. :code:`find_sublattice_embeddings`), + or post-selection strategies on sets of overlapping embeddings, + can increase the number of parallel_embeddings yielded beyond those + achieved by this heuristic method. + Nevertheless, defaults should be sufficient for the demo use case. + + Args: + qpu: DWaveSampler, the edgelist and topology information is used. + subgraph_embedding_timeout: time per embedding search. + parallel_embedding_timeout: time limit for iterative search process, + note that this is tested after the subgraph search, the maximum + time is the sum of subgraph and parallel embedding timeouts. + seed: Used for reproducible randomization in the search. + verbose: Print a method summary and information on search completion. + overwrite: If False and an embedding exists in the cache, load it rather than + overwriting. + embedding_directory: path for loading and writing embeddings. + Returns: + A list of embeddings + """ + + if verbose: + print(f"Solving for chip_id {qpu.properties['chip_id']}\n") + + node_list_source, edge_list_source = create_lattice() + node_labels = ( + source_dimer_orientation(node_list_source), + target_dimer_orientation(qpu), + ) + find_subgraph_kwargs = { + "timeout": subgraph_embedding_timeout, + "node_labels": node_labels, + "seed": seed, + } + embedding_filename = get_embeddings_filename( + edge_list_source=edge_list_source, + edge_list_target=qpu.edgelist, + embedding_directory=embedding_directory, + ) + if os.path.isfile(embedding_filename) and not overwrite: + print( + f"A suitable file {embedding_filename} exists on the " + "specified path and will not be overwritten. " + "Set the overwrite flag to replace the " + "file, or modify the directory_path to " + "write to a new location." + ) + embeddings = None + else: + if verbose: + print( + f"A set of disjoint 1:1 embeddings (subgraph isomorphisms) is sought by recursive " + "use of the complete find_subgraph method. " + f"The parallel embedding timeout is set to {parallel_embedding_timeout} seconds, " + f"and the find_subgraph_timeout is set to {subgraph_embedding_timeout} seconds, " + f"which guarantees completion within {(parallel_embedding_timeout + subgraph_embedding_timeout)/60.0} minutes. " + "Typically less time is required." + ) + embeddings = get_embeddings( + edge_list_source=edge_list_source, + edge_list_target=qpu.edgelist, + embedding_directory=embedding_directory, + embedding_timeout=parallel_embedding_timeout, + max_num_emb=None, + load_from_cache=not overwrite, + save_to_cache=True, + verify_embeddings=True, + find_subgraph_kwargs=find_subgraph_kwargs, + ) + if verbose: + if len(embeddings) > 0: + + print( + f"A list of disjoint embeddings of length {len(embeddings)} has been created " + f"and saved to {embedding_filename}." + ) + else: + print( + "No embeddings were found, the solver graph cannot be supported. " + "Larger timeouts may resolve this problem." + ) + + return embeddings + + +if __name__ == "__main__": + description = ( + "Create per-QPU (or per QPU graph change) embeddings for cubic lattices " + "to allow hash generation in the context of the blockchain demo. " + "Typically calibration/get_energy_time_rescaling.py should also be run " + "per QPU, to approximate necessary blockchain parameters." + ) + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + "-Q", + "--solver_name", + type=str, + help="Option to specify QPU solver, by default an experimental system supporting fast reverse anneal", + default=None, + ) + parser.add_argument( + "-P", + "--profile", + type=str, + help="Profile used for the client connection", + default=None, + ) + parser.add_argument( + "-TS", + "--subgraph_embedding_timeout", + type=int, + help="Time allowed per subgraph search in seconds", + default=60, + ) + parser.add_argument( + "-TP", + "--parallel_embedding_timeout", + type=int, + help="Time allowed for all (iterated) subgraph searches in seconds", + default=600, + ) + parser.add_argument( + "-S", + "--seed", + type=int, + help="Seed used by find_subgraph to pseudo-randomize the search", + default=1, + ) + parser.add_argument( + "-D", + "--embedding_directory", + type=str, + help="directory in which to seek and write embeddings", + default=EMBEDDINGS_PATH, + ) + parser.add_argument( + "--verbose_off", + action="store_true", + help="Use this flag to switch off majority of print() statements.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Use this flag to ignore existing solutions, overwriting them with a new solution.", + ) + + args = parser.parse_args() + verbose = not args.verbose_off + if verbose: + print(description) + qpu = DWaveSampler(solver=args.solver_name, profile=args.profile) + main( + qpu=qpu, + subgraph_embedding_timeout=args.subgraph_embedding_timeout, + parallel_embedding_timeout=args.parallel_embedding_timeout, + seed=args.seed, + embedding_directory=args.embedding_directory, + verbose=verbose, + overwrite=args.overwrite, + ) diff --git a/get_qpu_energy_time_rescaling.py b/get_qpu_energy_time_rescaling.py new file mode 100644 index 0000000..65d81d1 --- /dev/null +++ b/get_qpu_energy_time_rescaling.py @@ -0,0 +1,314 @@ +# Copyright 2025 D-Wave +# +# 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. + +import argparse +from typing import Literal + +import dimod +import numpy as np +from dwave.system import DWaveSampler + +from src.utilities.quantum_cubic_utils import create_lattice, create_model, generate_default_sampler + +ADV2_PROTOTYPE2_FIT = { + "kappa_t": 0.93, + "kappa_R": 0.71, + "res_en": 18.82, + "dres_en": 0.25, + "annealing_time": 0.005, +} + + +def get_energy( + qpu: DWaveSampler, + *, + energy_rescaling: float = 1.0, + annealing_time: float = 0.005, + fast_anneal: bool = True, + num_reads: int = 1000, + seed_bqm: int | None = None, + statistic_type: Literal["mean-energy", "min-energy"] = "mean-energy", +) -> float: + """Return the expected energy by embedding + + Args: + qpu: the D-Wave sampler + energy_rescaling: scaling down of the problem Hamiltonian. + annealing_time: time in microseconds. + fast_anneal: QPU parameter + num_reads: number of reads per model + seed_bqm: integer seed for J distribution + statistic_type: statistic to extract per embedding + Returns: + An energy statistic + """ + _, edge_list = create_lattice() + solver = generate_default_sampler( + edge_list, + qpu=qpu, + ) + solver_kwargs = { + "auto_scale": False, + "annealing_time": annealing_time, + "fast_anneal": fast_anneal, + "num_reads": num_reads, + } + + bqm = ( + dimod.BinaryQuadraticModel("SPIN").from_ising(*create_model(seed=seed_bqm)) + / energy_rescaling + ) + + result = solver.sample(bqm, **solver_kwargs) + if statistic_type == "mean-energy": + return ( + energy_rescaling + * np.sum(result.record.energy * result.record.num_occurrences) + / np.sum(result.record.num_occurrences) + ) + elif statistic_type == "min-energy": + return energy_rescaling * result.first.energy + + raise ValueError("Unknown statistic") + + +def fit_rescaling_to_kibble_zurek_form( + qpu: DWaveSampler, + *, + num_bqms: int = 25, + kappa_t: float | None = None, + kappa_R: float | None = None, + res_en_target: float | None = None, + annealing_time_model: float | None = None, + energy_time_rescaling: tuple[float, float] | None = None, # Initial guess + ground_state_estimates: np.ndarray | None = None, +) -> tuple[tuple[float, float], tuple[float, float], np.ndarray]: + r"""Determine a time (or as necessary energy) rescaling to match a reference Kibble-Zurek curve. + + + The residual energy is well-enough described by a Kibble-Zurek scaling form + :math:`<-min_x H(x) >_{model} = E (t_a/t_0)^{-\kappa_t} (J/R_0)^{-\kappa_R}` (1) + where J is the problem Hamiltonian scale (relative to max value 1), + and t_a is the programmed annealing time. + R_0 and t_0 are QPU specific rescalings of problem-Hamiltonian and annealing time. + kappa_* are ensemble-specific scaling parameters, approximately constant across QPUs. + + If residual energy is well matched between devices, correlation error is + approximately minimized, and cross-validation rate is maximized. + Owing primarily to small system size, decoherence and control error, the + detailed form is expected to differ from a pure power law. + + + The Advantage2_prototype2 energy versus annealing time curve is taken as a + reference profile, per the methodology of arXiv:2503.14462. The model is + fitted to data averaged on on 25 seeds with problem Hamiltonian rescalings + in [0.5,1], and annealing times 5-10ns. + Note kappa is approximately constant (per model ensemble). We fix E=-153.2 +/- 0.4, + which is the average energy achieved at t_a = 0.005us, and t_0=R=J=1. + + To calculate R and/or t_0 for another QPU we can guess parameter settings + to match Advantage2_prototype2, by default (R_0, t_0) = 1., 1. for Advantage2 + and 1., 0.5 for Advantage. We can then collect data with R = R_0 and t=0.005/t_0 + to obtain an estimate , we can then solve for (1) for either t_0 or R_0. + If t_0 is viable (does not result in an annealing_time out-of-programmable bounds) + a time rescaling is sufficient. Otherwise R_0 > 1 (energy rescaling) is sufficient. + + This function applies a 1-step approach. + This process can be applied iteratively as part of a stoquastic gradient + descent process. A collapse method similar to arXiv:2503.14462 Figure 6 can + be used as an alternative. + + Args: + qpu: The DWaveSampler for which energy-time rescaling is required. + num_bqms: The number of models to test. + kappa_t: Scaling of time as a function of annealing time. + kappa_R: Scaling of energy as function of problem Hamiltonian rescaling. + res_en_target: residual energy for target model + annealing_time_model: annealing_time for which model energy is defined. + energy_time_rescaling: An initial estimate for the rescaling. + ground_state_estimates: An array of ground state energies. + + Returns: + energy_rescaling_proposal (float, float): estimate of the problem Hamiltonian + rescaling required to emulate the reference residual energy curve + time_rescaling_proposal (float, float): estimate of the annealing time rescaling + required to emulate the reference residual energy curve + residual_energies (np.ndarray): mean energy of each problem, minus estimated + ground-state energy + """ + if energy_time_rescaling is None: + energy_rescaling = 1.0 + if qpu.properties["chip_id"].startswith("Advantage_"): + # Suitable guess for Advantage: + time_rescaling = 0.5 + else: + # Suitable guess for Advantage2: + time_rescaling = 1.0 + else: + energy_rescaling, time_rescaling = energy_time_rescaling + + if annealing_time_model is None: + annealing_time_model = ADV2_PROTOTYPE2_FIT["annealing_time"] + if kappa_t is None: + kappa_t = ADV2_PROTOTYPE2_FIT["kappa_t"] + if kappa_R is None: + kappa_R = ADV2_PROTOTYPE2_FIT["kappa_R"] + if ground_state_estimates is None: + # A single QPU programming with 100 microsecond annealing and 1000 samples + # (other values left at default) solves these problems to optimality with + # high (sufficient) probability: + ground_state_estimates = np.array( + [ + get_energy( + qpu=qpu, + annealing_time=100, + fast_anneal=False, + num_reads=1000, + seed_bqm=seed_bqm, + statistic_type="min-energy", + ) + for seed_bqm in range(num_bqms) + ] + ) + + if res_en_target is None: + res_en_target = ADV2_PROTOTYPE2_FIT["res_en"] + + residual_energies = [ + get_energy( + qpu=qpu, + energy_rescaling=energy_rescaling, + annealing_time=annealing_time_model / time_rescaling, + seed_bqm=seed_bqm, + ) + - ground_state_estimates[seed_bqm] + for seed_bqm in range(num_bqms) + ] + mean_residual_energy = np.mean(residual_energies) + if mean_residual_energy <= 0: + raise ValueError( + "Expected residual energies should be positive, but the " + f"expected value is {mean_residual_energy}." + "Kibble-Zurek scaling fits are ill-defined as mean energies " + "approach the ground state and/or if ground state energies " + "are overestimated." + ) + + lin_target = np.log(mean_residual_energy / res_en_target) + proposed_t = time_rescaling * np.exp(-lin_target / kappa_t) + proposed_R = energy_rescaling * np.exp(-lin_target / kappa_R) + + if proposed_R < 1: + print( + f"Inviable problem-energy rescaling {proposed_R}, out of standard range 1/|J| in [1,infty)" + ) + + annealing_time = annealing_time_model / proposed_t + if ( + annealing_time < qpu.properties["fast_anneal_time_range"][0] + or annealing_time > qpu.properties["fast_anneal_time_range"][1] + ): + print( + f"Inviable annealing time rescaling {annealing_time}, out of programmable annealing_time range" + ) + + candidate_energy_rescaling = (float(proposed_R), time_rescaling) + candidate_time_rescaling = (energy_rescaling, float(proposed_t)) + + return candidate_energy_rescaling, candidate_time_rescaling, residual_energies + + +def main( + qpu: DWaveSampler, + verbose: bool = True, +) -> list[dict]: + """Present a rescaling option + + See :code:`fit_rescaling_to_kibble_zurek_form`. + + This function performs a basic fit, more careful parameterization may allow + higher cross-validation rates. + + Args: + qpu: DWaveSampler, the edgelist and topology information is used. + verbose: Print a method summary and information on search completion. + Returns: + A list of embeddings + """ + + if verbose: + print(f"Solving for chip_id {qpu.properties['chip_id']}\n") + print( + "A Kibble-Zurek model model provides a good description of the ensemble-average " + "expected energy for all Advantage and Advantage2 processors given a suitable " + "selection of the device and ensemble specific parameters. " + " = E_0 + E_1 (t_a/t_0)^{-kappa_t} (J/R_0)^{-kappa_R}. " + "A fit to the Advantage2_prototype2.6 system yields all but the time (t_0) " + "and energy (R_0) rescaling factors. These are determined by fitting the R_0 or t_0 " + "to the experimental average energy from 25 QPU programmings. A short delay applies " + "during data collection." + ) + + energy_option, time_option, _ = fit_rescaling_to_kibble_zurek_form(qpu) + print() # NEW LINE + + if time_option[1] < 1: + candidate = time_option + print(f"The following time-rescaling option is viable: {time_option}") + else: + candidate = energy_option + print(f"The following energy-rescaling option is viable: {energy_option}") + + if verbose: + print("Follow README instructions to make this value available to the demo.") + + return candidate + + +if __name__ == "__main__": + description = ( + "Create per-QPU energy-time rescalings for cubic lattices to " + "allow cross-validation between QPUs in the context of the blockchain demo. " + "Typically one should first run calibration/get_embeddings.py to generate embeddings." + ) + + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + "-Q", + "--solver_name", + type=str, + help="Option to specify QPU solver, by default an experimental system supporting fast reverse anneal", + default=None, + ) + parser.add_argument( + "-P", + "--profile", + type=str, + help="Profile used for the client connection", + default=None, + ) + parser.add_argument( + "--verbose_off", + action="store_true", + help="Use this flag to switch off majority of print() statements.", + ) + + args = parser.parse_args() + verbose = not args.verbose_off + if verbose: + print(description) + + qpu = DWaveSampler(solver=args.solver_name, profile=args.profile) + + main(qpu=qpu, verbose=verbose) diff --git a/requirements.txt b/requirements.txt index 36ca3ca..d85daec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,16 @@ +dash[diskcache,async]~=3.2 +dash-mantine-components~=2.3 +dwave-ocean-sdk~=9.0 +flask~=3.1 +requests~=2.32 +pandas~=2.3 +numpy<2.4 # Pending bugfix for dwave-samplers +pytest~=9.0 +matplotlib~=3.10 +dwave-experimental==2026.2.13 # Update after merging of https://github.com/dwavesystems/dwave-experimental/pull/38 dash[diskcache]~=3.2 dash-mantine-components~=2.3 dwave-ocean-sdk~=9.0 +pycryptodome~=3.23 +numpy<2.4 # Pending bugfix for dwave-samplers +pytest~=9.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/miner.py b/src/agents/miner.py new file mode 100644 index 0000000..41026bd --- /dev/null +++ b/src/agents/miner.py @@ -0,0 +1,204 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ + + +import numpy as np + +from src.protocols.proof_of_work_protocol import ProofOfWorkProtocol +from src.structures.block import Block +from src.structures.block_score_tree import BlockScoreTree + + +class Miner: + """This class is intended to encapsulate all necessary functions for running a miner + on the blockchain network.""" + + def __init__(self, miner_id: str, pow_protocol: ProofOfWorkProtocol, genesis_block: Block): + """Instantiates a new miner at the given hostname. The subdir is the + directory to store the mempool, known nodes, and blockchain. + + Args: + subdir (str): name of subdirectory with initialization files and where + file outputs will be written. + """ + + self.id = miner_id + self.blockchain = BlockScoreTree() + self.add_block_to_chain(genesis_block, 1.0) + self.pow = pow_protocol + + # Holds block that is currently being mined but not yet finalized or broadcast. + self.mining_block = None + self.mined_block = None + self.mined_block_score = None + + def re_initialize_blockchain(self, node_list: list[dict]): + """Recreates the miner's blockchain from a dictionary of miner blockchain data. This is used + when restarting the demo when it has been paused. Persistent blockchain data will be saved + in a list of dicts, where each dict contains a JSON-formatted block, plus several fields + of metadata about that block, including the scores assigned to each miner. When + a miner calls this function, it will look for a score keyed to its miner_id in the + score list of each block in order (starting from the first block mined) and add that + block to its chain with that score. This should reproduce an identical blockchain + to the one the miner had before the simulation was paused. + + Args: + node_list (list of dicts): A list of dicts containing JSON blocks and metadata. + should be passed to each miner by simulation callback during restart. + + Modifies: + self.blockchain: Adds nodes to the miner's blockchain to update it.""" + + for block_entry in node_list: + scores = block_entry["scores"] + score = scores[self.id] + block = Block.from_json(block_entry["block_json"]) + self.add_block_to_chain(block=block, block_score=score) + + def add_block_to_chain(self, block: Block, block_score: float = 0.0): + """Adds a block to the block_score_tree object stored in self.blockchain. Updates + blockchain beliefs based on the logic of the update_blockchain_beliefs function. + + Args: + block (Block): a block + block_score (int or float): score assigned to the block + + Modifies: + self.blockchain: the miner's blockchain + """ + + self.blockchain.add_block(block.hash, block.previous_hash, block_score) + + # Only need to update on blocks that are good and not already in trunk + if self.blockchain.score_predicate(block_score): + self.update_blockchain_beliefs() + + def update_blockchain_beliefs(self): + """Updates the blockchain tree so that the branch containing the highest scoring block is now the trunk. + + + Modifies: + self.blockchain.tree: the representation of the miner's chain structure""" + + if self.blockchain.trunk.tip.hash != self.blockchain.strongest_block_hash: + best_branch = self.blockchain.hash_to_branch_lookup[ + self.blockchain.strongest_block_hash + ] + self.blockchain.promote_to_trunk(best_branch) + + def assemble_new_block(self, previous_block_hash: str | None = None) -> Block: + """Assembles a new block + + Returns: + new_block (Block): a new block that is assembled with a random nonce, but has not yet had its quantum hash + or block hash set.""" + + if previous_block_hash is None: + previous_block_hash = self.blockchain.tip_hash + + nonce = np.random.randint(0, 2**15) + new_block = Block(miner_id=self.id, previous_block_hash=previous_block_hash, nonce=nonce) + return new_block + + def attempt_mine(self, mining_block: Block | None = None) -> tuple[Block, float, str]: + """Attempts to mine a new block, choosing the nonce at random, calculating the quantum hash + and the block hash and validating against the PoW requirement. + + Returns: + succeeded (bool): whether the mining succeeded or failed + sample_time (float): the time in seconds spent performing the quantum experiment.""" + + if mining_block is None: + if self.mining_block is None: + mining_block = self.assemble_new_block() + else: + mining_block = self.mining_block + mining_block.nonce += 1 + + new_block, block_score, solver = self.pow.mine_block(mining_block) + new_block.lock() + self.mined_block = new_block + self.mined_block_score = block_score + self.mining_block = None + return new_block, block_score, solver + + def receive_block(self, new_block_str) -> tuple[float, str]: + """Processes a new block that has been received as a JSON-formatted string, validates it + and adds it to the miner's blockchain. + + Args: + new_block_str (str): A new block, serialized into a JSON-formatted string. + + Returns: + score: the score assigned to the block.""" + + new_block = Block.from_json(new_block_str) + score, solver = self.validate_block(new_block) + self.add_block_to_chain(new_block, score) + return score, solver + + def validate_block(self, block: Block) -> tuple[float, str]: + """Validates the Block's compliance with the Proof of Work protocol. The Miner's + ProofOfWork Object calls its own validate_block function to check the main block hash, + the N_zeroes requirement, the Merkle root and the quantum hash, with the later + assigning a float-values score rather than a strict pass or fail boolean flag. This + final score is the only thing returned by this method (any other validation issue will + raise an Exception). + + Args: + block (Block): the Block object to be validated. + + returns: + score (float): the Block's score, as determined from evaluating its quantum hash against the miner's + scoring function. The current convention across all scoring functions is that positive score blocks + are initially presumed valid (and added to the Miner's trunk if applicable) while zero or negative + scores are presumed invalid and will create a secondary branch if their predecessor is in the trunk + (or be added to an existing branch otherwise).""" + + valid, score, solver = self.pow.validate_block(block) + + if not valid: + raise Exception( + f"Block {block.hash} failed required protocol validation checks for miner {self.id}" + ) + + return score, solver + + def broadcast_mined_block(self) -> str: + """Stores a copy of the Miner's most recently mined block in the Miner's own blockchain before serializing + a mined block into a JSON-formatted string, which is returned. + + Returns: + block_data (str): the mined block serialized as a JSON-formatted string""" + + if self.mined_block is None: + raise Exception(f"Miner {self.id} attempted to broadcast with no block ready") + + if self.mined_block_score is None: + raise Exception(f"Attempted to broadcast mined block with hash \ + {self.mined_block.hash}, but it had not been scored.") + else: + self.add_block_to_chain(self.mined_block, self.mined_block_score) + + block_data = self.mined_block.to_json + self.mined_block = None + self.mined_block_score = None + + return block_data diff --git a/src/agents/trial_manager.py b/src/agents/trial_manager.py new file mode 100644 index 0000000..f3c51a3 --- /dev/null +++ b/src/agents/trial_manager.py @@ -0,0 +1,271 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ + + +import random +import time + +from src.agents.miner import Miner +from src.protocols.hash_calculator import HashSolver +from src.protocols.proof_of_work_protocol import ProofOfWorkProtocol +from src.structures.block import Block +from src.values import ( + GENESIS_BLOCK_PREV_HASH, + GENESIS_BLOCK_TIMESTAMP, + GENESIS_MINER_ID, + MAX_MINING_ATTEMPTS, +) + + +def initialize_genesis_block( + miner_id: str = GENESIS_MINER_ID, + previous_block_hash: str = GENESIS_BLOCK_PREV_HASH, + timestamp: float = GENESIS_BLOCK_TIMESTAMP, +): + """Initializes genesis block for the blockchain. In ordinary mining, these steps will + need to be performed by the miners and interleaved with other operations. For the + genesis block, we can just do them all at once based on constant values. + + Args: + all args are dummy values that just need to be set to some constant. See documentation + in src/structure/block.py for descriptions of Block class constructor args + + Returns: + genesis_block (Block): If called with the default args, this Block will always have + the same hash on every invocation, allowing for a consistent seed and starting + point for different blockchain trials.""" + + genesis_block = Block(miner_id, previous_block_hash, timestamp) + genesis_block.set_quantum_hash() + genesis_block.set_hash() + genesis_block.lock() + return genesis_block + + +class TrialManager: + """This class manages a trial of blockchain mining. The purpose of this + class is to be able to iterate through a series of blocks and maintain + the state of the trial as it progresses. + """ + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Initialization | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def __init__( + self, + num_blocks: int, + miner_names: list[str], + solvers: list[HashSolver], + quantum_hash_length: int, + n_zeroes: int, + allowable_err: int, + ): + """Initializes a new TrialManager object. + + Args: + num_blocks (int): the number of blocks the trial will run before it concludes + miner_names (list[str]): a list of unique strings to use as names of miners in the + trial. TrialManager will use all such names passed, thus the length of the list + will determine the number of miners in the trial. + solvers (list[HashSolver]): a list of HashSolver objects. One will be selected each + time a miner attempts to mine or validate. To run a trial with a single solver, + pass a list containing only that solver. + quantum_hash_length (int): length in bits of the quantum hash. This partly determines + the cross-validation difficulty for the trial. + n_zeroes (int): the number of leading zeros a block hash must have to pass the PoW + requirement. This determines how difficult mining is: each extra 0 will (on + average) double the number of attempts required to mine successfully. + allowable_err (int): how much error is allowed in cross validation. Increasing + this will make cross-validation easier: see the 'scoring' function + in the ProofOfWorkProtocol class for a full mathematical description. + + Instantiates: + TrialMiners object: object which declares and initializes miners for the trial.""" + + self.max_blocks = num_blocks + self.solvers = solvers + self.pow = ProofOfWorkProtocol(solvers, quantum_hash_length, n_zeroes, allowable_err) + self.trial_init_time = time.time() + self.genesis_block = initialize_genesis_block() + self.initialize_miners(miner_names) + self.max_mining_attempts = MAX_MINING_ATTEMPTS + self.mining_miner = None + self.block_broadcast = None + self.round_order = [] + self.round_progress = 0 + self.blocks_mined = 0 + + @property + def num_miners(self): + return len(self.miners) + + def initialize_miners(self, miner_names: list[str]): + """Creates all the Miner objects necessary to run the trial, passing each one an id, a + ProofOrWorkProtocol object (which should contain initialized solvers) and the + genesis block to form the basis for the new blockchain. + + Args: + num_owners: the number of miners to initialize""" + + self.miners = {} + + for name in miner_names: + next_miner = Miner(name, self.pow, self.genesis_block) + self.miners.update({next_miner.id: next_miner}) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Mining Round Primary Steps | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def reset_round(self): + """Runs setup for a single round of mining, currently limited to randomly setting the round order + for mining and validation. + + Modifies: + self.round_order: sets the round order at random.""" + + self.block_broadcast = None + self.round_progress = 0 + miner_order = [miner_id for miner_id in self.miners.keys()] + random.shuffle(miner_order) + self.round_order = miner_order + + def mining_step(self) -> tuple[str, float, str]: + """Executes the mining step for the single round of the trial. Miner mines a single block (or times + out after exceeding the maximum number of attempts) and stores its serialized form in self.block_broadcast. + + Modifies: + self.block_broadcast: stores the newly-mined block here, serialized in JSON format.""" + + self.mining_miner_id = self.round_order[0] + self.mining_miner = self.miners[self.mining_miner_id] + + for _ in range(self.max_mining_attempts): + _, block_score, solver = self.mining_miner.attempt_mine() + if block_score > 0: # Deviates from paper methodology (for confidence-based scoring) + self.block_broadcast = self.mining_miner.broadcast_mined_block() + self.round_progress += 1 + self.blocks_mined += 1 + return self.mining_miner_id, block_score, solver + + raise Exception( + f"TrialManager exceeded max mining attempts of {self.max_mining_attempts} without {self.mining_miner_id} finding mining a valid block." + ) + + def validation_step(self) -> tuple[str, float, str]: + """Chooses the next miner in validation order to perform validation for the mined block. + + Returns: + validator_id (string): ID of the validating miner + block_score (float): score that the validator assigned to the block + solver (string): name of the solver used for validation""" + + validator_id = self.round_order[self.round_progress] + validator = self.miners[validator_id] + block_score, solver = validator.receive_block(self.block_broadcast) + self.round_progress += 1 + + return validator_id, block_score, solver + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Other Round Tasks | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def single_step(self) -> tuple[bool, str, float, str]: + """Executes a single, atomic step of the simulation algorithm, deciding based on the internal state + of the TrialManager object which action needs to happen next. + + Returns: + mined (bool): Indicates whether this step was a mining step + miner_id (string): ID of the miner that mined or validated this round + block_score (float): score of the block that was just mined or validated + solver (string): name of the solver that was used for mining or validation this round""" + + if self.round_progress == 0 or self.round_progress >= self.num_miners: + mined = True + self.reset_round() + miner_id, block_score, solver = self.mining_step() + else: + mined = False + miner_id, block_score, solver = self.validation_step() + return mined, miner_id, block_score, solver + + def run_trial(self, num_blocks: int = None): + """Runs the trial through some number of complete block mining and validation events. By + default it will run until the trial finishes, but this can be overridden by passing a + smaller number as an argument. + + Args: + num_blocks (int): Defaults to None, which will simply cause the trial to run to completion. If an integer + less than or equal to the number of blocks remaining in the trial is passed, TrialManager will run for + only that number of blocks, allowing the trial to be broken up into stages if desired. Passing more than + the remaining number will raise an Exception.""" + + if num_blocks is None: + stopping_block = self.max_blocks + elif num_blocks > self.max_blocks - self.blocks_mined: + raise Exception( + f"Attempted to run trial for {num_blocks} rounds, with only {self.max_blocks - self.blocks_mined} blocks remaining." + ) + else: + stopping_block = self.blocks_mined + num_blocks + + while self.blocks_mined < stopping_block: + self.single_step() + + def get_active_block_hashes(self) -> list[str]: + """Queries miners to get a list of the hashes of all blocks that are currently candidates for mining. + Each miner should have one block that they consider the strongest (may be the same for different + miners), which they will mine on top of if they are selected for the next mining round. This + function collects a list of those block hashes and returns it (with duplicates removed). + + Returns: + active_hash_list (list[str]). List of hashes of blocks that are candidates for mining. + """ + + active_hashes = [miner.blockchain.strongest_block_hash for miner in self.miners.values()] + return list(set(active_hashes)) + + def get_last_common_trunk_block(self) -> int: + """Finds the block number of the last block that all miners have in their trunks: that is, the last + block that all miners consider to be a canonical part of the main chain. This is important in + assessing the state of the blockchain, as once all miners agree on a block, it is effectively + immutable, as every new block mined will include it as a predecessor. + + Returns: + largest_common_block_num (int): the block number of the last block that all miners have + their trunk.""" + trunk_sets = [ + set([blk.block_number for blk in miner.blockchain.trunk]) + for miner in self.miners.values() + ] + common_block_nums = set.intersection(*trunk_sets) + if len(common_block_nums) == 0: + return 0 + + largest_common_block_num = max(common_block_nums) + return largest_common_block_num diff --git a/src/demo_enums.py b/src/demo_enums.py index 132aa06..393db09 100644 --- a/src/demo_enums.py +++ b/src/demo_enums.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave +# Copyright 2026 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,21 +15,13 @@ from enum import Enum -class SolverType(Enum): - """Add a list of solver options here. If this demo only requires 1 solver, - this functionality can be removed. - """ - - SOLVER_1 = 0 - SOLVER_2 = 1 +class SolverMode(Enum): + QPU = 0 + SIMULATED = 1 @property def label(self): return { - SolverType.SOLVER_1: "Solver 1", - SolverType.SOLVER_2: "Solver 2", + SolverMode.QPU: "QPU Solver", + SolverMode.SIMULATED: "Simulated Solver", }[self] - - -### If any settings or variables are being used repeatedly, thoughout the code, create a new -### Enum for the setting here to avoid string comparisons or other fragile code practices. diff --git a/src/protocols/hash_calculator.py b/src/protocols/hash_calculator.py new file mode 100644 index 0000000..6609eb1 --- /dev/null +++ b/src/protocols/hash_calculator.py @@ -0,0 +1,300 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ + + +import binascii +import os +from abc import ABC, abstractmethod +from collections import namedtuple +from enum import Enum + +import dimod +import numpy as np +from dwave.system import DWaveSampler + +from src.utilities import quantum_cubic_utils +from src.utilities.random_projection import RandomProjectionHasher +from src.values import ( + DEFAULT_ANNEALING_TIME, + DEFAULT_ENERGY_TIME_RESCALING, + DEFAULT_NUM_READS, + EMBEDDINGS_PATH, + SIMULATED_DATA_NUM_READS, + SIMULATED_PATH, +) + + +class SolverName(Enum): + """Named solvers available for hash generation + + + QPU solvers should be matched to those generally available through Leap. + This list can be updated to reflect availability. + Additions to the solver list should be paired with additions to the + ENERGY_TIME_RESCALING dictionary in src/values.py and embeddings + should also be created (see README Per-QPU calibration). + + Simulated solvers use independently sampled witnesses, with + the distributions parameterized by saved (offline) QPU experimental data. + Distributions are provided only for the set of generally-available QPUs used + in the study https://arxiv.org/pdf/2503.14462 + """ + + SOLVER1 = "Advantage_system4.1" + SOLVER2 = "Advantage_system6.4" + SOLVER3 = "Advantage2_system1.11" + + SIMULATED1 = "simulated_Advantage2_prototype2.6" # No longer generally available + SIMULATED2 = "simulated_Advantage_system4.1" + SIMULATED3 = "simulated_Advantage_system6.4" + SIMULATED4 = "simulated_Advantage_system7.1" # Offline + + +SolverParams = namedtuple( + "SolverParams", + ["solver_name", "profile"], + defaults=(None, None), +) + + +class HashSolver(ABC): + @abstractmethod + def calculate_quantum_hash(self, hash_length: int, rng_seed: int) -> tuple[str, np.ndarray]: + """Template for the method to calculate quantum hash values. Implementation will vary by + solver type, but input and return values should stay consistent. + + Args: + hash_length (int): length (in bits) of the hash to be calculated + rng_seed (int): For the case of sampling from QPUs the random seed + sets the unitary evolution parameters + (for quantum experiments) and random projections defining witnesses. + For the case of simulated sampling, it initiates the pseudorandom + sampling of offline data models. + + Returns: + hash_bits: a np vector whose values should be exclusively 0s and 1s, defining the + quantum hash. Note that this will be processed into a hex string and stored as + such by the Block class. + dot_vector: a np vector encoding the hyperplane distance for each bit (that is, the + dot product of the hash vector and the hyperplane's normal vector)""" + + @property + def solver_name(self) -> str: + return self._solver_name + + +def initialize_solver(solver_name: str) -> HashSolver: + """Function to allow a HashSolver of either type to be initiated from a SolverParams tuple, + without having to check which type of solver it is and invoke either subclass directly. + + Args: + solver_name: A SolverName compatible value used to initialize a QPU or simulated solver. + Returns: + A HashSolver""" + + if solver_name not in [n.value for n in SolverName]: + raise Exception(f"Unrecognized solver name {solver_name} passed. \ + Allowed names are {[name.value for name in SolverName]}") + elif "simulated" in solver_name: + return SimulatedHashSolver(solver_name) + + return QuantumHashSolver(solver_name) + + +class SimulatedHashSolver(HashSolver): + def __init__( + self, + solver_name: str | None = None, + *, + simulated_path: str = SIMULATED_PATH, + mean_witnesses: np.ndarray | None = None, + var_witnesses: np.ndarray | None = None, + var_rescaling_factor: float | None = None, + ): + """Initializes a simulated solver from a source file or by provision of numpy arrays. + + Args: + solver_name (str): Name of the solver being simulated. Each name serves to specify a + lookup file for loading witness statistics. + simulated_path (str): Path to the directory containing the witness data. + mean_witnesses (np.ndarray): A numpy array of expected witness values. + var_witnesses (np.ndarray): A numpy array of expected witness variances. + var_rescaling (float): Variance rescaling allows emulation of variable sampling error + (or high frequency control error). Resampled witnesses are distributed as + ~ N(mean, variance*variance_rescaling)). If fewer/more reads are to be simulated, + relative to the value used in data correction we can scale accordingly.""" + + if solver_name is None and mean_witnesses is None: + raise Exception( + "Witness must be provided or a solver associated to a source file specified" + ) + if mean_witnesses is None: + self._solver_name = solver_name + mean_filepath = os.path.join(simulated_path, self.solver_name + "_mean.npy") + var_filepath = os.path.join(simulated_path, self.solver_name + "_var.npy") + self.mean_witnesses = np.load(mean_filepath) + var_witnesses = np.load(var_filepath) + else: + self._solver_name = None + self.mean_witnesses = mean_witnesses + if var_witnesses is None: + var_witnesses = np.zeros(shape=mean_witnesses.shape) + if var_rescaling_factor is None: + var_rescaling_factor = float(SIMULATED_DATA_NUM_READS) / float(DEFAULT_NUM_READS) + self.var_witnesses = var_rescaling_factor * var_witnesses + self.num_witnesses = self.mean_witnesses.size + + def calculate_quantum_hash(self, hash_length: int, rng_seed: int) -> tuple[str, np.ndarray]: + """Implementation of quantum hash calculation for simulated solvers. Requires + Simulated files for the current solver to be in place in order to function. + + Args: + hash_length (int): length of the quantum hash to use, in bits + rng_seed (int): a seed to use to determine which witnesses to draw from + + Returns: + quantum_hash (str): the quantum hash formatted as a hexidecimal string. Note that + this means that the length will be 1/4 (rounded up) of the passed hash length + since a hex digit can store 4 binary digits. + dot_vector (np.ndarray): vector of hyperplane distances. Used in calculating confidence + scores.""" + + prng_header = np.random.default_rng(rng_seed) + prng_sampling = np.random.default_rng() + indices = prng_header.integers(self.num_witnesses, size=hash_length) + mu = self.mean_witnesses.ravel()[indices] + var = self.var_witnesses.ravel()[indices] + + dot_vector = mu + np.sqrt(var) * prng_sampling.normal(size=hash_length) + bool_vector = dot_vector > 0 + hash_bits = bool_vector.astype(int) + + quantum_hash = binascii.hexlify(np.packbits(hash_bits)).decode(encoding="utf-8") + + return quantum_hash, dot_vector + + +class QuantumHashSolver(HashSolver): + """Implementation of quantum hash calculation with D-Wave Solver. Requires an active solver + connection to run.""" + + @property + def solver_parameters(self) -> SolverParams: + return SolverParams( + solver_name=self.solver_name, + profile=self.profile, + ) + + def __init__( + self, + solver_name: str | None = None, + *, + profile: str = None, + num_reads: int = DEFAULT_NUM_READS, + reference_annealing_time: float = DEFAULT_ANNEALING_TIME, + energy_time_rescaling: tuple[float, float] | None = None, + embedding_directory: str = EMBEDDINGS_PATH, + sampler_kwargs: dict | None = None, + sampler: dimod.Sampler | None = None, + ): + """Initializes the QuantumHashSolver object, which will create and maintain a connection + to the indicated D-Wave Solver as long as this object in instantiated. + + Args: + solver_name (str): The name of the QPU solver + profile (str): client profile + num_reads (int): number of QPU reads per hash calculation + reference_annealing_time (float): targeted evolution time with respect to + Advantage2_prototype2 schedule. + energy_time_rescaling (tuple[float, float]): problem Hamiltonian and time rescaling + factors required to emulate Advantage2_prototype2 dynamics with the given solver. + embedding_directory (str): Location of embeddings + sampler_kwargs (dict): Arguments for the dimod sampler, defaulted to QPU fast annealing + arguments when not specified. Non defaulted arguments are used for testing. + sampler (`dimod.Sampler`): when not specified the solver name and profile is used to + select a QPU with the Leap client, and a suitable embedding is loaded. Non-QPU + samplers are used for testing.""" + + if energy_time_rescaling is None: + if solver_name not in DEFAULT_ENERGY_TIME_RESCALING: + raise ValueError( + "Unsupported {solver_name}: See the `calibration` directory for generation of energy-time rescaling values and embeddings" + ) + problem_hamiltonian_rescaling, time_rescaling = DEFAULT_ENERGY_TIME_RESCALING[ + solver_name + ] + else: + problem_hamiltonian_rescaling, time_rescaling = energy_time_rescaling + + self._solver_name = solver_name + if sampler_kwargs is None: + self.sampler_kwargs = dict( + fast_anneal=True, + annealing_time=reference_annealing_time / time_rescaling, + auto_scale=False, + num_reads=num_reads, + label=f"Examples - Quantum Blockchain", + ) + else: + self.sampler_kwargs = sampler_kwargs + self.problem_energy_scale = problem_hamiltonian_rescaling + self.profile = profile + + if sampler is None: + qpu = DWaveSampler(solver=self.solver_name, profile=self.profile) + _, source_edge_list = quantum_cubic_utils.create_lattice() + + self.sampler = quantum_cubic_utils.generate_default_sampler( + source_edge_list, + qpu=qpu, + embedding_directory=embedding_directory, + ) + else: + self.sampler = sampler + + def calculate_quantum_hash(self, hash_length: int, rng_seed: int) -> tuple[str, np.ndarray]: + """Implementation of quantum hash calculation for QPU samplers + + Args: + hash_length (int): length of the quantum hash to use, in bits + rng_seed (int): a seed to use to determine parameters of the quantum experiment + + Returns: + quantum_hash (str): the quantum hash formatted as a hexidecimal string. Note that + this means that the length will be 1/4 (rounded up) of the passed hash length + since a hex digit can store 4 binary digits. + dot_vector (np.ndarray): vector of hyperplane distances. Used in calculating confidence + sample_time (float): time spent sampling the D-Wave solver""" + + h, J = quantum_cubic_utils.create_model( + seed=rng_seed, problem_energy_scale=self.problem_energy_scale + ) + sampler_output = self.sampler.sample_ising(h, J, **self.sampler_kwargs) + + stats = quantum_cubic_utils.build_stats(sampler_output, J.keys()) + + hv = RandomProjectionHasher( + random_seed=rng_seed + 1, input_dimension=stats.size, num_bits_out=hash_length + ) + + hash_bits, dot_vector = hv.hash_vector(stats.reshape(-1)) + quantum_hash = binascii.hexlify(np.packbits(hash_bits)).decode(encoding="utf-8") + + return quantum_hash, dot_vector diff --git a/src/protocols/proof_of_work_protocol.py b/src/protocols/proof_of_work_protocol.py new file mode 100644 index 0000000..525fe86 --- /dev/null +++ b/src/protocols/proof_of_work_protocol.py @@ -0,0 +1,271 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ + + +import random +from logging import warning + +import numpy as np +from scipy.special import erf + +from src.protocols.hash_calculator import HashSolver +from src.structures.block import Block +from src.utilities.crypto_utils import compare_hashes, validate_zeroes +from src.values import DELTA_W_0_ALPHA, MIN_SCORE, W_0_ALPHA + + +class ProofOfWorkProtocol: + """This class implements the Proof of Work Protocol for a node on the blockchain. In practice, + that means the class manages mining, scoring and those aspects of Block assembly that + require resources outside the scope of the Block class, such as anything needing QPU access. + """ + + def __init__( + self, + hash_solvers: list[HashSolver], + quantum_hash_length: int, + n_zeroes: int, + allowable_err: int, + ): + """Initializes a ProofOfWorkProtocol object. In the current implementation, TrialManager + initializes a single ProofOfWorkProtocol, which is shared by all miners in a trial. + + Args: + hash_solvers (list[HashSolver]): list of HashSolver objects that this object can use to + call the .calculate_quantum_hash() method. If there is more than one solver on the + list, each mining or validation attempt will choose a solver to use at random. + quantum_hash_length (int): length of the quantum hash. In general, the longer this is + the greater the chance that a mined block will fail validation due to measurement + error. But see 'allowable_err' below. + n_zeroes (int): the number of leading zeroes a block hash must have to be considered + valid. Increasing this makes mining more difficult: miners must try more nonces + (and thus make more QPU calls) to find a valid block. + allowable_err (int): the error tolerance of validation. Increasing this means hashes + with more and larger error will still pass validation (but hashes with fewer + errors will still score higher).""" + + self.quantum_hash_length = quantum_hash_length + self.n_zeroes = n_zeroes + self.allowable_err = allowable_err + self.solver_list = hash_solvers + self.set_random_solver() + + def validate_block(self, block: Block) -> tuple[bool, float, str]: + """Validates a block according to the stored protocol parameters and scoring function. + + If self.quantum_hash_length > 0 this should involve either making a call to a D-Wave + QPU (using the stored solver info and connection) or whatever equivalent is specified + by the protocol. Convention is that fully valid blocks have positive scores and fully + invalid blocks have negative scores, with some scoring functions allowing for variation + in both how positive and how negative they might be. + + To save QPU cycles, the classical checks are done before any QPU call is made, with + each done in increasing order of computational cost. First the block is checked for + passing the easily verified n_zeroes requirement (which allows efficient filtering of + no-work blocks). After that, the classical hash of the block is checked to make sure + it is consistent with the block header data. If both of these checks pass, then the + QPU is called to validate the quantum hash and check it against the scoring + requirement. This allows corrupted or improperly-mined blocks to be quickly and + cheaply discarded without having to spend any QPU time on them. + + Blocks which fail a classical check return default values indicating an invalid block + with a very low score. + + Args: + block (Block): The Block object to be validated + + Returns: + valid: a bool indicating whether the block passed all classical validation checks and + achieved a positive score. + block_score: the score assigned to the block by the stored scoring function. + validation_bits: the bitwise report of whether each bit in the quantum hash passed or + failed debugging.""" + + valid = False + block_score = MIN_SCORE + + # If any other validation fails, no reason to waste a QPU call + if not validate_zeroes(block.hash): + warning( + f"N_zeroes validation failed, expected {'0'*self.n_zeroes} got {int(block.hash[self.n_zeroes//2], 16)}" + ) + + elif not block.validate_hash(): + warning(f"Failed hash check for block with hash {block.hash}") + + else: + valid = True + block_score = self.score_block(block) + + return valid, block_score, self.current_solver.solver_name + + def mine_block(self, block: Block) -> tuple[Block, float, str]: + """Makes a single attempt to mine a block based on the stored Proof Of Work requirements. + Returns the block itself (with a current quantum and classical hash, and possibly a + digital signature) as well as a summary of whether the block passes the stored + requirements and the sample time. + + Args: + block (Block): A block that is finalized except for the quantum hash, quantum signature + (if applicable) and the classical hash. This method will not alter the nonce value: + miners should do that on their own. + + Returns: + MinedBlock: the block with quantum hash, quantum signature (if applicable) and + classical hash added + score: the score assessed for the block. This will be 0 if the block fails the N_zeroes + check, otherwise the miner will evaluate their own confidence in the block based + on their measurement data. + solver_name (str): the name of the solver used for this mining attempt""" + + new_quantum_hash, dot_vector = self.calculate_quantum_hash(block) + block.set_quantum_hash(new_quantum_hash) + validation_bits = [1] * self.quantum_hash_length + block.set_hash() + assert block.validate_hash(), f"Block {block.hash} had invalid hash root after mining." + + if validate_zeroes(block.hash, self.n_zeroes): + block_score = self.calculate_confidence_score( + validation_bits, self.allowable_err, dot_vector + ) + else: + block_score = MIN_SCORE + + return block, block_score, self.current_solver.solver_name + + def score_block(self, block: Block) -> float: + """Calculates the score for a single block by recalculating the quantum hash for the + block and comparing the recalculated result to the value stored in the block. + Currently the scoring is done exclusively via the calculate_confidence_score + function, but other scoring schemas can be used in its place without affecting + the functionality of the rest of the codebase. + + Args: + block (Block): the Block object to be scored + + Returns: + block_score: the score of the block assigned by the stored scoring function.""" + + received_hash = block.quantum_hash + + if self.quantum_hash_length > 0: + calculated_hash, dot_vector = self.calculate_quantum_hash(block) + assert len(received_hash) == len( + calculated_hash + ), f"Expected quantum hash of length {len(calculated_hash)}, received hash of length {len(received_hash)}" + validation_bits = compare_hashes(received_hash, calculated_hash) + + else: + validation_bits = [1] + dot_vector = [] + + block_score = self.calculate_confidence_score( + validation_bits, self.allowable_err, dot_vector + ) + return block_score + + def calculate_confidence_score( + self, valid_bits: np.ndarray, allowable_err: int | float, dot_vector: np.ndarray + ) -> float: + """Confidence-based scoring, as defined in the quantum blockchain paper (see README for + details). In practice this is quite sensitive to quantum_hash_length, allowable_err, + solver schemas and num_reads. Some trial and error is required to find sets of values + that allow for reasonable validation rates. + + Args: + valid_bits (np.ndarray): a vector of binary values representing which bits of the + original hash appeared to be valid (i.e matched the validator's calculated hash). + Will hold 1 if the corresponding hash bit is valid, or a 0 otherwise. + allowable_err (int or float): the error tolerance of the scoring procedure. Roughly + speaking, increasing this by 1 compensates for one extra maximum-uncertainty bit + (i.e. a bit in which the confidence is 50%). A low value means only an extremely + high-confidence hash vector will earn a positive score. With a high value, a hash + vector with many highly-uncertain bits can still earn a positive score. However, + bit errors high-confidence bits will reduce the confidence by far more than 1, so + even a few serious errors can overwhelm this threshold even when set high. + dot_vector (np.ndarray): Vector that contains the dot products of the hash vector with + the normal vectors of hyperplanes chosen by the random projection operation. This + vector is used to calculate the bitwise confidence scores. If the value in some + coordinate is very far from the mean (W_0_ALPHA), it will have very high confidence + (close to 1). If it is near the mean, it will have low confidence (close to 0.5). + + Returns: + confidence_score (float): the validator's overall log confidence that the hash is correct to within + the error threshold defined by allowable_err""" + + min_confidence = MIN_SCORE + mean = W_0_ALPHA + std_dev = DELTA_W_0_ALPHA + + norm_dist = np.abs((dot_vector - mean) / std_dev) + bitwise_confidence = 0.5 * (1 + erf(norm_dist)) + # If a validation bit is 1, we use the confidence. If it's 0, we use 1 - the confidence + validation_confidence = [ + a * b + (1 - a) * (1 - b) for a, b in zip(valid_bits, bitwise_confidence) + ] + log_confidence = np.float64(allowable_err) + for idx, confidence in enumerate(validation_confidence): + if confidence < 0: + raise ValueError(f"Invalid confidence value {confidence} at index {idx} from bit\ + {valid_bits[idx]} and confidence value {bitwise_confidence[idx]}") + + if confidence == 0: + return min_confidence + + log_confidence += np.log2(confidence) + + return round(log_confidence.item(), 2) + + def calculate_quantum_hash(self, block: Block) -> tuple[str, np.ndarray]: + """Calculates the quantum hash for the block provided. This is the centerpiece of the + whole codebase and the place where QPU calls are made. Produces a hash of length + defined by self.quantum_hash_length (in turn determined by protocol settings) + + Args: + block (Block): the block whose quantum hash you wish to calculate. + + Returns: + quantum_hash: a np vector whose values should be exclusively 0s and 1s, defining the + quantum hash. Note that this will be processed into a hex string and stored as + such by the Block class. + dot_vector: a np vector encoding the hyperplane distance for each bit (that is, the dot + product of the hash vector and the hyperplane's normal vector)""" + + random_seed = int(block.hash_seed, 16) + self.set_random_solver() + + quantum_hash, dot_vector = self.current_solver.calculate_quantum_hash( + hash_length=self.quantum_hash_length, rng_seed=random_seed + ) + + return quantum_hash, dot_vector + + def set_random_solver(self): + """Returns a random solver and its corresponding DWaveSampler. If the + self.require_all_solvers flag is set, and one or more solvers is unavailable, the + method will check their availability again and again at at successively longer + intervals until they become available or the number of allowed attempts is exceeded. + + Modifies + self.current_solver: changes the current solver to one chose randomly from the list + of those available. + """ + solver_choice = random.choice(self.solver_list) + self.current_solver = solver_choice diff --git a/src/structures/block.py b/src/structures/block.py new file mode 100644 index 0000000..e80922a --- /dev/null +++ b/src/structures/block.py @@ -0,0 +1,352 @@ +# Copyright 2026 D-Wave +# +# 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. + +import json +from datetime import datetime + +from src.utilities.crypto_utils import calculate_hash +from src.values import EMPTY_QUANTUM_HASH + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ===================================================================================================== +# SECTION: Initialization and Special Methods | +# ===================================================================================================== +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +class Block: + """Class representing a block, the fundamental unit of the quantum blockchain. Most of the + logic in this class deals with block creation which is a complicated process with multiple + steps that must be completed in the right order, some of which require QPU access and must + be handled external to the Block class. Once a valid block has been created it should be + locked with the Block.lock() method, ensuring that all its data is now treated as immutable. + Once a block is locked in can be summarized as a dict with the Block.to_dict method, or + serialized into a JSON object with the Block.to_json method. A serialized block can be + recovered with the static Block.from_json method, which will return a locked Block object + (as no unlocked block should ever be serialized). The serialized block data will include a + hash value, but the deserialization process will allow the hash value to be recalculated. + The calculated and transmitted values are compared as a checksum to guard against data + corruption. This is important in real network conditions as even a single bit-error in a + block will render the entire Block invalid and incompatible with the wider blockchain. + + Note that this blockchain implementation does not include any transaction-handling. For + consistency, Block still contains a data field for transactions and a header entry for + a Merkle root, but does not have any methods for accept transactions or calculating + (non-trivial) Merkle roots. For someone wishing to add these back in, recommended practice + is for any method that adds or alters the transactions field to automatically update the + timestamp, recalculate the Merkle root and reset the hashes: otherwise the block header + data will not be properly reflective of the block contents. + + The steps to Block creation are as follows: + + 1. To instantiate a block, a previous block hash and a miner_id must be passed. A nonce and + timestamp can also be passed; if no timestamp is provided, Block will add one based on + the current time when the constructor is called. + 2. After a block is declared, the only header field that can be directly altered is the + nonce. A miner will generally perform one quantum experiment per nonce value, saving + the result via the Block.set_quantum_hash() method. + 4. Once a quantum hash is added, the miner can then call the Block.set_hash() function to + add the final classical hash value. Note that this is the point at which the miner + should test whether the classical hash passes the n_zeroes requirement; if not, they + should start over with a new nonce. + 5. Once the miner has found a classical hash value that passes the n_zeroes requirement for + this proof of work protocol, they can call Block.lock() to finalize the block. At this + point no other changes may be made to the block. It is considered immutable. If they need + to add or alter data, they will need to declare a new Block object and start the process of + finding a valid hash over from step 1. + + Note that any alteration made to transactions or nonce after step 4 will cause the currently- + stored quantum hash to be removed: this is important as any such alterations will render the + quantum hash invalid and prevent the block from passing validation. Likewise any alterations + made after step 5 will cause both the classical and quantum hashes to be removed, as none of + them are valid if the internal data is altered.""" + + def __init__( + self, + miner_id: str, + previous_block_hash: str, + timestamp: float | None = None, + nonce: int = 0, + ): + """Initializes a new Block object with a miner ID, a previous block hash and, optionally, + an initial nonce value. May also be passed a specific timestamp: if not, a timestamp + will be added based on the current time. Note that this does not produced a "finished" + block, as the quantum hash must be calculated by methods outside the Block class, + and only once it is added can the final block hash be computed. Once a valid nonce, + quantum hash and block hash combination are found, the Block should locked to with the + Block.lock() method to ensure data integrity and mark it as finalized.""" + + if timestamp is None: # If value is still at default, replace with current timestamp. + timestamp = datetime.timestamp(datetime.now()) + self._locked = False # Used to indicate a finalized block that should not be altered. + self._transactions = [] + + self._header = { + "previous_block_hash": previous_block_hash, + "merkle_root": calculate_hash(""), + "miner_id": miner_id, + "timestamp": timestamp, + "nonce": nonce, + } + + def __eq__(self, other): + try: + assert isinstance(other, Block) + + # Block hashes uniquely encode all block data: no need to compare anything but the hashes. + assert self.hash == other.hash + + return True + except AssertionError: + return False + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Core Property Definitions | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + @property + def hash(self) -> str: + return self._header["hash"] + + @property + def current_block_hash(self) -> bool: + return "hash" in self._header + + @property + def header(self) -> dict: + return self._header + + @property + def previous_hash(self): + return self._header["previous_block_hash"] + + @property + def merkle_root(self): + return self._header["merkle_root"] + + @property + def timestamp(self): + return self._header["timestamp"] + + @property + def nonce(self) -> int: + return self._header["nonce"] + + @property + def miner_id(self) -> str: + return self._header["miner_id"] + + @property + def hash_seed(self): + """This property defines the data fields and ordering used to calculate both quantum and + classical hashes. Important that this be consistent across all users or they will not + calculate comparable hash values.""" + seed_string = f"{self.previous_hash}{self.merkle_root}{self.timestamp}\ + {self.merkle_root}{self.miner_id}{self.nonce}" + return calculate_hash(seed_string) + + @property + def transactions(self): + return self._transactions + + @property + def quantum_hash(self) -> str: + """Returns the current value of the quantum hash as a hex-formatted string.""" + if self.current_quantum_hash: + return self._header["quantum_hash"] + + raise Exception("No quantum hash has been added.") + + @property + def current_quantum_hash(self) -> bool: + return "quantum_hash" in self._header + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Public Mutators | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + @nonce.setter + def nonce(self, value: int): + """Sets the nonce to the passed value, provided it is an integer and the block is not + locked. Raises an exception otherwise. + + Args: + value (int): value to set the nonce to. + + Modifies: + self._header["hash"]: removes the block hash and the quantum hash and the signature + as they will no longer be valid + self._header["quantum_hash"]: (see above)""" + + if isinstance(value, int): + if not self._locked: + self._header["nonce"] = value + self._reset_hashes() + else: + raise Exception("Attempted to alter the nonce of a locked block.") + else: + raise Exception( + f"Expected type int for nonce value, received type {type(value)} instead." + ) + + def set_quantum_hash(self, quantum_hash: str = EMPTY_QUANTUM_HASH): + """Sets value for the block's quantum hash, which must be a hex-formatted string. + + Args: + quantum_hash: the quantum hash formatted as a hexidecimal string. + + Modifies: + self._header["quantum_hash"]: stores the value in this field as a hexidecimal string. + """ + + if not self._locked: + self._reset_hashes() + self._header["quantum_hash"] = quantum_hash + else: + raise Exception("Attempted to set the quantum hash of a locked block.") + + def set_hash(self): + """Calculates the block hash, storing the result in the 'hash' entry of self._header. This + will overwrite any existing hash, though the result will be identical if the data has + not been altered since the last time this function was called. + + Modifies: + self._header["hash:]: sets the hash value.""" + if self._locked: + raise Exception("Attempted to set the hash of a locked block.") + elif not self.current_quantum_hash: + raise Exception( + "Block must have a current quantum hash before the block hash can be calculated." + ) + + block_hash = calculate_hash(self.hash_seed + self.quantum_hash) + self._header["hash"] = block_hash + + def lock(self): + """Checks to make sure the self.current_quantum_hash and self.current_block_hash flag are + True, and if so sets the block status to locked. All setters should check the + self._locked flag before making any alterations, so locking a block should prevent any + accidental alterations to any of the block data. + + A locked block should be treated as completely final. A single-bit alteration to any + data field will invalidate the whole block and cause any future check of the block's + quantum or classical hashes to fail.""" + + if self.current_quantum_hash and self.current_block_hash: + self._locked = True + else: + raise Exception( + f"Attempted to lock a block without current hashes. Current States are Quantum \ + Hash: {self.current_quantum_hash} Block Hash: {self.current_block_hash}" + ) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Private Mutators and Utilities | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def _reset_hashes(self): + """Removes both the block hash and the quantum hash. This method is called internally when + any operation is performed that alters block data and would invalidate the hashes.""" + + if self.current_quantum_hash: + self._header.pop("quantum_hash") + if self.current_block_hash: + self._header.pop("hash") + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Public Data Access and I/O | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + @property + def locked(self): + """Users should freely be able to check (but not alter) whether a block is locked.""" + return self._locked + + def validate_hash(self) -> bool: + """Recalculates the block hash and checks it against the stored value. Will automatically + return False if the block is not locked, as an unlocked block is not considered to have + a finalized hash value.""" + + block_hash = calculate_hash(self.hash_seed + self.quantum_hash) + return block_hash == self.hash + + @property + def to_dict(self): + block_data = { + "header": self.header, + "transactions": [t for t in self._transactions], + } + + return block_data + + @property + def to_json(self) -> str: + return json.dumps(self.to_dict) + + @staticmethod + def from_dict(block_dict: dict, validate_hash: bool = True) -> "Block": + """Reconstitutes a (locked) Block object from a dictionary of its data, such as that + created by the to_dict method. If validate_hash is left at its default value of True, + will raise an exception if the block hash calculated from the stored data does not + match the stored hash value. This is to ensure the integrity of the stored data, so + that invalid blocks are never treated as valid. + + Args: + validate_hash (bool). Defaults to True. Determines whether the function will check the + block hash stored in the passed dict against the hash value calculated for the + assembled block (raising an Exception if they don't match).""" + + header_dict = block_dict.pop("header") + block_hash = header_dict["hash"] + miner_id = header_dict["miner_id"] + quantum_hash = header_dict["quantum_hash"] + prev_hash = header_dict["previous_block_hash"] + nonce = int(header_dict["nonce"]) + timestamp = float(header_dict["timestamp"]) + + new_block = Block( + miner_id=miner_id, + previous_block_hash=prev_hash, + timestamp=timestamp, + nonce=nonce, + ) + new_block.set_quantum_hash(quantum_hash) + new_block.set_hash() + new_block.lock() + if validate_hash and block_hash != new_block.hash: + raise Exception( + f"When deserializing block, expected hash {block_hash} but calculated hash {new_block.hash}" + ) + + return new_block + + @staticmethod + def from_json(json_block: str, validate_hash: bool = True) -> "Block": + """Deserializes a blocked stored as JSON into a locked Block object. Calls from_dict, + which performs a check on the hash of the reconstructed Block to ensure integrity. + + Args: + validate_hash (bool): Defaults to True. Whether to check passed hash against + calculated hash of the reconstructed block. See .from_json for more details.""" + + block_dict = json.loads(json_block) + new_block = Block.from_dict(block_dict, validate_hash) + return new_block diff --git a/src/structures/block_score_tree.py b/src/structures/block_score_tree.py new file mode 100644 index 0000000..9612931 --- /dev/null +++ b/src/structures/block_score_tree.py @@ -0,0 +1,587 @@ +# Copyright 2026 D-Wave +# +# 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. + +import json + +from src.structures.score_tree_branch import BlockNode, ScoreTreeBranch + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ===================================================================================================== +# SECTION: Initialization and Special Methods | +# ===================================================================================================== +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +SHORT_HASH_LEN = 5 + + +class BlockScoreTree: + """Class for tracking structure and score of a blockchain. Each block is represented by a + 6-element named BlockNode tuple (see score_tree_branch.py for definition) formatted as + + (block_hash, previous_block_hash, block_score, total_score, block_height, block_number) + + where total_score is the total score of the chain that ends with that block, block_height + is the total length of the chain extending from the genesis block to this block and + block_number is the ordinal number in which the block was added to the chain. + + With the previous_block_hash references defining edges connecting one block to another, + the chain will take the form of a directed tree (in the graph-theory sense) and under + typical usage will have a single very long path starting at a leaf and extending back to + the root, with a number of much shorter branches joining this path at various points. Built + from this assumption, the main data structure is referred to as the "trunk" and stored in + the "self.trunk" list. A large part of the design and usage is build around the centrality + of the trunk. + + In blockchain terms, the trunk represents the canonical chain: the chain that the owner of + the object considers to be the authoritative one, containing valid blocks and transactions. + The decision on which blocks should end up in the trunk should thus be determined at a high + level, by the scores the user assigns the blocks before they are passed into BlockScoreTree. + This class is designed to depend as little as possible on the details of the users scoring + schema; the only assumptions encoded into the structure of the class are 1. higher scores + are preferred to lower scores, 2. total scores are determined additively (that is, the total + score of a block is the sum of its block_score and the block_score of all its predecessors) + and that 3. blocks with negative-scores default to being put in secondary branches rather + than in the trunk. + + Branches are instantiated as members of the ScoreTreeBranch class, with each maintained as + a single, linear list of blocks. Each list will contain only the blocks that diverge from + its predecessor, thus only the trunk will form a 'complete' chain while every non-trunk + chain will consist of multiple branch-sections terminating in a trunk section. + + Outside of the trunk, the choice of which section of blocks belong to a parent branch and + which belong to a child is largely arbitrary: both chains extending from the fork point + must be tracked, but neither has inherently special status compared to the other. + Parent-child relationships between the post-fork sections can be modified with the + self.promote_branch() method, exchanging the last section of the parent branch (everything + after the fork point) with the child branch.""" + + def __init__( + self, genesis_block: BlockNode | None = None, score_predicate: "function | None" = None + ): + + self.trunk = ScoreTreeBranch() + self.hash_to_branch_lookup = {} + self.branches = [self.trunk] + self.short_hash_len = SHORT_HASH_LEN + if score_predicate is None: + default_predicate = lambda x: bool(x > 0) + self.score_predicate = default_predicate + else: + if callable(score_predicate): + self.score_predicate = score_predicate + else: + raise Exception( + f"BlockScoreTree was passed non-callable score predicate {score_predicate}." + ) + + if genesis_block is not None: + self.add_block_as_node(genesis_block) + + def __str__(self): + """Represents the chain as lists of tuples, usually with hashes substantially truncated + (see short_block_rep() method). Each branch is written as its own line, with the trunk + as the first line.""" + + trunk_str = "Trunk: [" + for block in self.trunk: + trunk_str += self.short_block_rep(block) + + for idx, branch in enumerate(self.branches[1:]): + parent_idx = self.branches.index(branch.parent) + trunk_str += f"]\n + Branch {idx+1}({parent_idx}) [" + for block in branch: + trunk_str += self.short_block_rep(block) + trunk_str += "]" + return trunk_str + + def short_block_rep(self, block: BlockNode) -> str: + """Helper function for __str___ Returns a string that's a representation of an entry in the chain, with both + of the hashes truncated for space and readability. This is very useful when you want a human-readable + output, but dangerous to use in cases where you need to match the short representation to full blocks. + + Args: + block: a tuple representing a block + + Returns: + String: a string representing that block entry, with the hashes cut down to a length determined by + self.short_hash_len for brevity and readability""" + + short_hash = block.hash[: self.short_hash_len] + if block.prev_hash: + short_prev = block.prev_hash[: self.short_hash_len] + else: + short_prev = "" + return f"({short_hash},{short_prev},{block.block_score},{block.total_score},{block.block_number},{block.block_height})" + + @property + def tip_hash(self): + return self.trunk.tip.hash + + @property + def high_score(self): + return max([branch.high_score for branch in self.branches]) + + @property + def strongest_block_hash(self): + strongest_branch = max(self.branches, key=lambda x: x.high_score) + return strongest_branch.high_score_hash + + @property + def num_nodes(self): + return sum([len(branch) for branch in self.branches]) + + @property + def most_recent_block(self): + all_blocks = [block for branch in self.branches for block in branch] + most_recent_block = max(all_blocks, key=lambda node: node.block_number) + return most_recent_block + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Block I/O Operations | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def add_block_to_branch(self, block: BlockNode, branch: ScoreTreeBranch): + branch.append_block(block) + self.hash_to_branch_lookup.update({block.hash: branch}) + + def add_block( + self, block_hash: str, prev_block_hash: str, block_score: float, block_number: int = -1 + ): + """Adds an entry for a block based on its hash, its previous block hash and its score. + The function determines the proper place in the overall structure to insert the block + creating a new branch if necessary. It also checks if the block's total score is greater + than the currently standing high score, and updates the score and strongest block reference + if so. + + If the previous block hash is None the block will be either added to the trunk (if its empty) + or as the start of a new branch that doesn't actually join the trunk (i.e. its previous block + is None rather than a hash of the trunk or some lower level branch). This is somewhat + pathological and should be avoided completely as long as miners simply agree on an initial block rather + than mining it. But I didn't want to force a guarantee of that at this low level. + + Args: + block_hash: the hash of the new block to be added + prev_block_hash: the hash of the previous block in the chain + block_score: the score of the block to be added + block_number: int (optional). This should not be used except by the internal functions that rearrange + the tree. When adding blocks normally, block number is assigned automatically. It is only + necessary to set manually when moving existing parts of the tree around. + """ + + # By default, block number is one more than the number of blocks already in the tree. However, when re-arranging + # the tree it's necessary to pass in the existing block number instead. + if block_number < 0: + block_number = len(self.hash_to_branch_lookup) + 1 + + if len(self.trunk) == 0: # If trunk is empty, initialize tree with this as first block + new_block = BlockNode( + hash=block_hash, + prev_hash=prev_block_hash, + block_score=round(block_score, 3), + total_score=round(block_score, 3), + block_number=block_number, + block_height=0, + ) + self.add_block_to_branch(new_block, self.trunk) + elif block_hash in self.hash_to_branch_lookup: # otherwise, check for duplicates + raise Exception(f"Attempted to add duplicate block with hash {block_hash} to tree.") + # make sure block predecessor exists + elif prev_block_hash not in self.hash_to_branch_lookup: + raise Exception( + f"Block {block_hash} has predecessor {prev_block_hash} which is not found in the tree." + ) + else: # if it does, add it to the tree in the proper spot. + parent_branch = self.hash_to_branch_lookup[prev_block_hash] + prev_block = parent_branch.get_block(prev_block_hash) + new_block = BlockNode( + hash=block_hash, + prev_hash=prev_block_hash, + block_score=round(block_score, 3), + total_score=round(block_score + prev_block.total_score, 3), + block_number=block_number, + block_height=prev_block.block_height + 1, + ) + + # Before block can go in the trunk, it must be checked against score criterion + canonical = (parent_branch != self.trunk) or self.score_predicate(block_score) + if prev_block == parent_branch.tip and canonical: # Block goes on branch tip + self.add_block_to_branch(new_block, parent_branch) + else: # Block goes in new branch + new_branch = ScoreTreeBranch() + self.branches.append(new_branch) + self.add_block_to_branch(new_block, new_branch) + parent_branch.link_child_branch(new_branch) + + def add_block_as_node(self, block: BlockNode, force_trunk: bool = False): + """Counterpart to add_block for data already formatted as BlockNode named tuple. + + The key difference is that BlockNode objects already contain the computed attributes + "total_score", "block_number" and "block_height", which cannot be modified without + declaring a new BlockNode. This function simply keeps those values, assuming they are + correct for the tree (which will be the case e.g. when reconstructing the tree from a file). + + Args: + BlockNode: a BlockNode named tuple containing the block data + """ + + # If the trunk is empty, initialize the tree with this as the first block + if len(self.trunk) == 0: + self.add_block_to_branch(block, self.trunk) + elif block.hash in self.hash_to_branch_lookup: # otherwise, check for duplicates + raise Exception(f"Attempted to add duplicate block with hash {block.hash} to tree.") + # make sure block predecessor exists + elif block.prev_hash not in self.hash_to_branch_lookup: + raise Exception( + f"Block {block.hash} has predecessor {block.prev_hash} which is not found in the tree." + ) + else: # if it does, add it to the tree in the proper spot. + parent_branch = self.hash_to_branch_lookup[block.prev_hash] + prev_block = parent_branch.get_block(block.prev_hash) + + # Before block can go in the trunk, it must be checked against score criterion + canonical = (parent_branch != self.trunk) or ( + force_trunk or self.score_predicate(block.block_score) + ) + + if prev_block == parent_branch.tip and canonical: # Block goes on branch tip + self.add_block_to_branch(block, parent_branch) + else: # Block goes in new branch + new_branch = ScoreTreeBranch() + self.branches.append(new_branch) + self.add_block_to_branch(block, new_branch) + parent_branch.link_child_branch(new_branch) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Tree Restructuring | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def promote_to_trunk(self, branch_to_promote: ScoreTreeBranch) -> list[str]: + """Promotes a branch repeatedly until it is the trunk. Necessary because branches may be + arbitrarily deep, but can only be promoted one level at a time. + + Args: + better_branch: the branch of the tree being promoted to the trunk + + Returns: + block_hash_list: a list of the hashes of every block that is newly part of + the trunk. Important for maintaining the mempool. + """ + + trunk_join_index = self.get_trunk_join_index(branch_to_promote) + + if trunk_join_index is None: # If branch is the trunk, do nothing + block_hash_list = [] + else: + while branch_to_promote != self.trunk: # Otherwise, keep promoting to reach trunk + branch_to_promote = self.promote_branch(branch_to_promote) + block_hash_list = [block.hash for block in self.trunk[trunk_join_index + 1 :]] + + for branch in self.branches: + if branch != self.trunk: + try: + assert branch.root_hash in branch.parent + except: + self.to_text_file(f"error_tree\ + {self.trunk.tip.hash[:SHORT_HASH_LEN]}.txt") + branch_txt = [ + (br.hash[:SHORT_HASH_LEN], br.prev_hash[:SHORT_HASH_LEN]) + for br in branch.node_list + ] + parent_txt = [ + (br.hash[:SHORT_HASH_LEN], br.prev_hash[:SHORT_HASH_LEN]) + for br in branch.parent.node_list + ] + raise Exception( + f"Triggering branch: {branch_txt} with parent: {parent_txt}. Root hash is {branch.root_hash} " + ) + + self.branches.sort(key=lambda x: x.depth) + + return block_hash_list + + def promote_branch(self, branch_to_promote: ScoreTreeBranch) -> ScoreTreeBranch: + """Promotes a branch to be one level closer to the trunk: the blocks in the branch become + the tip of its parent branch, replacing all blocks after the point where they joined. The + tip of the parent is demoted to become a branch, replacing the promoted branch. Any branches + from either of the sections that are moved should be unaffected: their references will still + point to the same hashes as before, meaning they will branch off of the same blocks as always, + even if those blocks are now elsewhere in the tree structure. More generally, no data should + be added, removed or altered by the swap, only the structure of the tree should change. + + Args: + better_branch: the branch of the chain you're promoting. Intended use is to call this only + as part of a promote_to_trunk call, which a miner will call on a branch that has + achieved a higher score than their trunk. However this is not enforced, so as to allow + more future flexibility in Miner strategy. + + Returns: + the parent branch, which should now be updated by the promotion + """ + + # Can't promote the trunk. No other branches without no parents: if there are + # final 'else' clause will raise an exception + if branch_to_promote.parent is not None: + branch1_rep = [ + (bn.hash[:SHORT_HASH_LEN], bn.prev_hash[:SHORT_HASH_LEN]) + for bn in branch_to_promote + ] + branch2_rep = [ + (bn.hash[:SHORT_HASH_LEN], bn.prev_hash[:SHORT_HASH_LEN]) + for bn in branch_to_promote.parent + ] + assert ( + branch_to_promote.depth > 0 + ), f"Branch {branch1_rep} had depth {branch_to_promote.depth}, parent {branch2_rep}" + base_branch = branch_to_promote.parent + + # This chunk and the assert at the end of the 'if' are validation to give visibility + # in case of a logic error in the code. If it's working properly, they won't be relevant. + orig_len = len(base_branch) + promoted_len = len(branch_to_promote) + total_len = orig_len + promoted_len + demoted_len = 0 # Default value: overwritten if part of the base branch is demoted + # Leave the root block in place, remove the next block + join_loc = base_branch.hash_to_index_lookup[branch_to_promote.root_hash] + 1 + + for block in branch_to_promote: + self.hash_to_branch_lookup.update({block.hash: base_branch}) + base_branch.children.remove(branch_to_promote) + self.branches.remove(branch_to_promote) + + # If the base branch extends beyond the join location, the remainder must be cut + if join_loc < base_branch.tip_idx + 1: + demoted_section = base_branch.cut_branch_section(join_loc) + self.branches.append(demoted_section) # Cut section is added as its own branch. + for child in demoted_section.children: + assert child.parent == demoted_section, f"Child-parent mismatch. Child had \ + parent {child.parent}, expected {demoted_section}" + for block in demoted_section: + self.hash_to_branch_lookup.update({block.hash: demoted_section}) + base_branch.link_child_branch(demoted_section) + demoted_len = len(demoted_section) + assert demoted_section.parent == base_branch, f"Branch with root \ + {demoted_section.root} had parent \ + with root {demoted_section.parent.root}.\ + Expected {base_branch.root}" + + base_branch.concatenate_branch(branch_to_promote) + assert len(base_branch) + demoted_len == total_len, f"Missing blocks. Demoted: \ + {demoted_len}, promoted: {promoted_len},\ + Orig: {orig_len}, Final {len(base_branch)}" + return base_branch + + elif branch_to_promote == self.trunk: # Trying to promote trunk does nothing + return branch_to_promote + + else: + raise Exception("Branch has depth 0 but is not the trunk!") + + def refactor_branches(self): + """This function rearranges the branches of the tree to put branches with the highest final block number + at the lowest level. In this structure, trunk has special importance, but outside of that, which branch + is a parent and which is a child is arbitrary: promote_branch lets us swap between them at will. For graphing + it is useful to have the branches that will appear longest on the trunk (those that end with the highest block number) + to have the lowest depth. This function rearranges the branches to meet that criterion. + """ + + base_branches = [] + for branch in self.branches: + if branch.depth == 1: + base_branches.append(branch) + + for base_branch in base_branches: + branch_descendants = base_branch.get_descendants_by_depth() + + # Working from the highest to the lowest depth allows this to be done in one pass. + # Create longest possible branch in each layer + for layer in reversed(branch_descendants[:-1]): + layer_remaining = {branch.base.hash for branch in layer} + + # Which will ensure that one pass over the next lowest layer is also optimal + for branch in layer: + if branch.depth <= 1: # Want to stop just short of the bottom branch. + break + + longest_child_branch = branch.get_longest_child() + layer_remaining.remove(branch.base.hash) + if longest_child_branch is not None: + self.promote_branch(longest_child_branch) + + if len(layer_remaining) == 0: + break + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Getters and Data Access | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def get_block(self, block_hash: str) -> BlockNode: + if block_hash in self.hash_to_branch_lookup: + return self.hash_to_branch_lookup[block_hash].get_block(block_hash) + else: + raise Exception(f"Block with hash {block_hash} not found in tree.") + + def get_predecessor_list( + self, block_hash: str, stopping_hash: str = None, stopping_height: int = 0 + ) -> list[BlockNode]: + """Returns a list of all the blocknodes in the tree that are predecessors of the block with the passed hash. List + is populated starting from the passed block and walking backwards through the chain, so the list will be in + reverse order of chain height. Can be passed a termination condition, either in the form of a block hash or + a chain height: if so, will only find blocks up until the termination condition (if both conditions are passed + it will terminate as soon as at least one is met). If no condition is passed, will terminate at the root node + for the entire tree. + + Args: + block_hash (str): the hash of the block whose predecessors are to be found + stopping_hash (str, optional): Defaults to None. Termination condition. Will stop the search as soon as a node + with matching hash is found. + stopping_height (int, optional). Defaults to 0. Termination condition. Will stop the search as soon as a node + with matching height is found. Guaranteed to terminate at the default value of 0 if no other termination + condition is found, as the root of the tree has height 0 and is a predecessor of every node. + + Returns: + node_list (str): list of all the nodes in the tree that are predecessors of the block with the passed hash, up + to the termination condition, in reverse order of block height.""" + + if stopping_hash is None: + stopping_hash = self.trunk.base.hash + + current_block = self.get_block(block_hash) + node_list = [current_block] + while current_block.hash != stopping_hash and current_block.block_height > stopping_height: + current_block = self.get_block(current_block.prev_hash) + if current_block is not None: + node_list.append(current_block) + else: + raise Exception(f"Reached a dead end in the tree before reaching a termination \ + condition. Last node accessed was {node_list[-1].hash}") + + return node_list + + def get_trunk_join_index(self, branch: ScoreTreeBranch) -> int | None: + """Finds the index where a branch or one of its parent branches joins the trunk. + + Args: + branch: a branch + + Returns: + index: the index of the block in the trunk where the branch or a parent branch + joins the trunk. Returns None if the branch is the trunk.""" + + current_branch = branch + root_hash = None + while current_branch.parent is not None: + root_hash = current_branch.root_hash + current_branch = current_branch.parent + + assert current_branch == self.trunk, "No path found from branch to trunk." + + if root_hash is None: # Only way root hash remains unset is if starting branch was trunk + index = None + else: + index = self.trunk.hash_to_index_lookup[root_hash] + + return index + + def to_text_file(self, filename: str, truncate: bool = True): + """Writes a string representation of the BlockScoreTree object to file. + + Args: + filename (str): name of the file to write + truncate (bool): whether to shorten to hashes in the string representations + of the BlockNodes for easy human-readability or to leave them + at full length for accuracy.""" + + short_hash_len = self.short_hash_len + if not truncate: + self.short_hash_len = len(self.trunk.tip.hash) + with open(filename, "w") as file_obj: + file_obj.write(str(self)) + + if not truncate: + self.short_hash_len = short_hash_len + + def to_json_file(self, filename: str): + with open(filename, "w") as f: + branches = [b.node_list for b in self.branches] + json.dump(branches, f) + + def to_json(self) -> str: + branches = [b.node_list for b in self.branches] + return json.dumps(branches) + + @staticmethod + def from_node_and_score_list( + node_list: list[tuple[str, str]], score_list: list[float] + ) -> "BlockScoreTree": + assert len(node_list) == len( + score_list + ), f"Passed lists of incompatible sizes {len(node_list)} and {len(score_list)} respectively." + new_tree = BlockScoreTree() + for hash_pair, score in zip(node_list, score_list): + new_tree.add_block( + block_hash=hash_pair[0], prev_block_hash=hash_pair[1], block_score=score + ) + return new_tree + + @staticmethod + def from_json_file(filename: str, cutoff: int | None = None) -> "BlockScoreTree": + """Given an appropriately formatted file, loads a BlockScoreTree object. + Ought to keep the same graph structure and scores under realistic circumstances + (but this is difficult to guarantee in all cases). If provided a positive value for + cutoff parameter, will reconstruct the graph block by block so that the scores and + structure can be re-calculated to match the older graph state (rather than using) + the structural info from the current version of the graph. + + Args: + filename: the name of the file (including path) containing the graph info + cutoff: how many blocks of the graph to reconstruct. If left at -1, will + reconstruct the whole graph, and used the saved structural information to + determine the new graph structure. + + Returns: + BlockScoreTree object that is a reconstruction of the saved data + """ + + new_tree = BlockScoreTree() + with open(filename, "r") as f: + branch_list = json.load(f) + + node_list = [] + new_branch_list = [] + for branch in branch_list: + branch_nodes = [] + for element in branch: + new_node = BlockNode(*element) + node_list.append(new_node) + branch_nodes.append(new_node) + new_branch_list.append(branch_nodes) + + for branch in new_branch_list: + if branch == new_branch_list[0]: + trunk = True + else: + trunk = False + for element in branch: + if cutoff is not None and element.block_number > cutoff: + pass + else: + new_tree.add_block_as_node(element, force_trunk=trunk) + + return new_tree diff --git a/src/structures/score_tree_branch.py b/src/structures/score_tree_branch.py new file mode 100644 index 0000000..40afeaf --- /dev/null +++ b/src/structures/score_tree_branch.py @@ -0,0 +1,382 @@ +# Copyright 2026 D-Wave +# +# 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. + +from collections import namedtuple + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ===================================================================================================== +# SECTION: Initialization and Special Methods | +# ===================================================================================================== +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +BlockNode = namedtuple( + "BlockNode", ["hash", "prev_hash", "block_score", "total_score", "block_number", "block_height"] +) + + +class ScoreTreeBranch: + """This class serves to encapsulate the fundamental structural units of the BlockScoreTree class, namely branches. The + structure of a typical probabilistic blockchain - and thus of a BlockScoreTree object - will be a series of linear + chains of blocks with occasional forks in which a second chain diverges from the first. A ScoreTreeBranch is intended + to represent and store the data for a single such linear section, while tracking useful metadata such as the locations + of other chains that fork off this one, a score summary for the section and a reference for the branch's parent (the branch + it forked off of). This also provides a convenient platform for lower-level manipulations of the chain state - those that + involve one block or a small, contiguous series of blocks - rather than having them as BlockScoreTree methods. + + For a better understanding of the logic of the global tree structure, refer to the documentation for the BlockScoreTree + class.""" + + def __init__(self, base_block: BlockNode = None): + """ + Args: + base_block (BlockNode): the BlockNode object that will serve as the first node of this branch. Once + added, it should never be removed or modified. It's self.hash attribute can + effectively serve as a unique identifier for this branch, as no other block + in any branch should share it. If no base_block is passed in the constructor, + the first block added with self.append_block will be used instead. + + Attributes: + self.node_list: this is the central data structure of the class. A list of BlockNode objects corresponding to a + single, linear section of the blockchain. + self.hash_to_index_lookup: dict that allows quickly finding the location of any BlockNode in this ScoreTreeBranch + based on its hash value. + self.children: list of ScoreTreeBranches whose roots are BlockNodes in self.node_list + self.parent: ScoreTreeBranch containing the predecessor to the first node in self.NodeList + self.depth: number of ScoreTreeBranch objects that are parents or further ancestors of this one. The trunk + of a given BlockScoreTree should always have depth 0, and any other branches should have depth 1 or + greater. + """ + self.node_list = [] + self.hash_to_index_lookup = {} + self.children = [] # Children and parent will be dynamically linked for easy access + self.parent = None + self.depth = 0 + if base_block is not None: + self._initialize_first_block(base_block) + + def _initialize_first_block(self, base_block: BlockNode): + if not isinstance(base_block, BlockNode): + raise Exception( + f"Expected input argument to have type BlockNode. Received type {type(base_block)} instead." + ) + elif len(self.node_list) > 0: + raise Exception("Attempted to initialize non-empty branch.") + + self.node_list.append(base_block) + self.hash_to_index_lookup.update({base_block.hash: 0}) + self.root_hash = base_block.prev_hash + + @property + def tip(self) -> BlockNode: + """Returns the last entry in the node list (i.e. the 'tip' of the branch).""" + return self.node_list[-1] + + @property + def tip_idx(self) -> int: + """Returns the index of the last entry in self.node_list (i.e. the 'tip' of the branch).""" + return len(self.node_list) - 1 + + @property + def base(self) -> BlockNode: + """Returns the BlockNode object that is the first entry in the self.node_list.""" + return self.node_list[0] + + @property + def root(self) -> BlockNode: + """Returns the BlockNode object that is the immediate predecessor to self.base + (that is, the predecessor the first block in the branch). Only a branch of depth 0 + (one with no predecessor) should return None; the only branch + this should be true of is the trunk.""" + if self.parent is not None: + return self.parent.get_block(self.root_hash) + elif self.depth != 0: + raise Exception("Branch has no root but depth is greater than 0.") + + return None + + @property + def high_score(self) -> float: + """Returns the highest total_score of any block currently stored in this branch""" + return max([node.total_score for node in self.node_list]) + + @property + def high_score_hash(self) -> float: + """Returns the hash of the block with the highest total score in this branch""" + max_block = max(self.node_list, key=lambda x: x.total_score) + return max_block.hash + + def __getitem__(self, index: int) -> BlockNode: + return self.node_list[index] + + def __iter__(self): + return iter(self.node_list) + + def __len__(self) -> int: + return len(self.node_list) + + def __contains__(self, item) -> bool: + # Supporting membership checking by both hash and object, as hashes should be unique identifiers + if isinstance(item, str): + return item in self.hash_to_index_lookup + + elif isinstance(item, BlockNode): + return item.hash in self.hash_to_index_lookup + + return False + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Branch Construction | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + def append_block(self, new_block_node: BlockNode): + """Appends a new block to the end of the branch. This will raise an exception unless + the branch is empty or .prev_hash attribute of the added block matches the hash of the block + at the branch tip. Determining where a new block should be added and whether it should form + a new branch or extend an existing branch needs to be handled at the tree level. This validation + is important to ensure the correctness of the branch, so this method is the only way blocks + should be added to the branch: other methods that manipulate the branch should call this one + as necessary. + + Args: + new_block (BlockNode): a BlockNode object, which must be a valid choice to add to the branch (see above) + """ + + if not isinstance(new_block_node, BlockNode): + raise Exception( + f"Invalid input. Expected type BlockNode, received type {type(new_block_node)}." + ) + + if len(self.node_list) == 0: + self._initialize_first_block(new_block_node) + elif new_block_node.prev_hash == self.tip.hash: + self.node_list.append(new_block_node) + self.hash_to_index_lookup.update({new_block_node.hash: self.tip_idx}) + else: + raise Exception( + f"Invalid block. Root hash {new_block_node.prev_hash} cannot connect to tip hash {self.tip.hash}" + ) + + def update_depth(self): + """Updates the branch depth to one more than that of its parent. Called recursively on all + children to ensure the update propagates properly.""" + + if self.parent is not None: + self.depth = self.parent.depth + 1 + else: + self.depth = 0 + for child in self.children: + child.update_depth() + + def set_parent(self, parent_branch: "ScoreTreeBranch"): + """Sets the passed branch as the parent of the current branch + (provided that is a legal assignment). Will not set the other end + of the relationship (this is intended to be called by link_child_branch, + rather than on its own). + + Args: + parent_branch: a branch that is the parent of the current branch (that is, + it contains a block whose hash matches the branch's root hash).""" + + if self.root_hash in parent_branch: + self.parent = parent_branch + self.update_depth() + else: + raise Exception( + f"Attempted to set branch {self.node_list} as child of branch {parent_branch.node_list}" + ) + + def link_child_branch(self, child_branch: "ScoreTreeBranch"): + """Links a ScoreTreeBranch to this branch as a child. The child then calls set_parent on + this branch to complete the linkage. + + Args: + child_branch (ScoreTreeBranch): the branch that this branch will add to self.children""" + + if child_branch.root_hash in self: + self.children.append(child_branch) + child_branch.set_parent(self) + else: + raise Exception("Attempted to link branch that was not a child.") + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Getters and Data Access | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def get_block(self, block_hash: str) -> BlockNode: + if block_hash in self.hash_to_index_lookup: + return self.node_list[self.hash_to_index_lookup[block_hash]] + + return None + + def get_score_map(self): + score_map = [node.total_score for node in self.node_list] + return score_map + + def get_longest_child(self): + """Returns the child of the current branch with the highest-numbered tip block, + if it is higher than the block number of this branch's tip. This is a helper + function for BlockScoreTree.refactor_branches, used to find the branches that + will extend the farthest (when drawn with the graphing logic in SpiralPlotter), + so they can be positioned to avoid overlaps. + + Returns: + longest_child (ScoreTreeBranch): child branch of this branch whose + tip has the highest block_number""" + + highest_block_num = self.tip.block_number + longest_child = None + for child in self.children: + if child.tip.block_number > highest_block_num: + highest_block_num = child.tip.block_number + longest_child = child + + return longest_child + + def get_descendants_by_depth(self) -> list[list["ScoreTreeBranch"]]: + """Compiles a list of all the descendants (children and their children and so on) + of a branch, sorted into sub-lists by depth. The branch itself will always be the + first and only item in the first list. Used when restructuring the whole tree, + as it allows branches to be queried and moved in optimal order.""" + + descendants = [[self]] + for child in self.children: + if len(descendants) == 1: + descendants.append([]) + descendants[1].append(child) + later_descendants = child.get_descendants_by_depth() + while len(descendants) < len(later_descendants) + 1: + descendants.append([]) # Add enough entries in descendants to hold all the output + + for i in range(1, len(later_descendants)): # 0th entry will be the child again + descendants[i + 1] += later_descendants[i] + + return descendants + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # ===================================================================================================== + # SECTION: Branch Restructuring | + # ===================================================================================================== + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + def pop(self) -> tuple[BlockNode, list["ScoreTreeBranch"]]: + """Removes a single block from the tip of the branch, updating all data and properties as necessary. + Blocks should never be altered or removed by any means other than the pop function (if a more + extensive change is necessary, it should be done by means of repeated calls of pop() and + append_block(), as in is done in the other methods in this section). + + Returns: + removed_block (BlockNode): block removed from branch tip + removed_children: list of all child branches with root at the + removed block (there will usually be zero or one + but in theory could be arbitrarily many)""" + + if len(self) < 1: + raise Exception("Cannot pop last block in branch") + + removed_block = self.node_list.pop() + self.hash_to_index_lookup.pop(removed_block.hash) + removed_children = [] + + for child in self.children: + if child.root_hash == removed_block.hash: + removed_children.append(child) + + for child in removed_children: + self.children.remove(child) + + return removed_block, removed_children + + def concatenate_branch(self, new_branch_section: "ScoreTreeBranch"): + """Concatenates a new branch section to the tip of the current branch. + + Args: + new_branch_section (ScoreTreeBranch): a ScoreTreeBranch object. The + root hash of the object must match this branch's tip hash or the operation + will fail and throw an exception.""" + + if new_branch_section.root_hash == self.tip.hash: + for block in new_branch_section: + self.append_block(block) + for child in new_branch_section.children: + self.link_child_branch(child) + else: + raise Exception( + "Cannot concatenate a branch whose root doesn't match this branches tip." + ) + + def cut_branch_section(self, cut_idx: int) -> "ScoreTreeBranch": + """Removes all blocks from a specified index or hash forward (including the block with the matching hash or index). + Returns a branch containing the removed blocks, with any child branches that belong to it already linked. + + Args: + cut_idx (int): index of the first block in the cut. Will be ignored in favor of cut_hash if a non-default value of cut-hash is passed. + In keeping with Python convention for lists, this index can be negative (negative indices will be counted backwards from the end + of the list, starting with the last element at index -1). + + Returns: + new_branch (ScoreTreeBranch): a branch containing all the blocks from the cut index + forward, linked to any children rooted in those blocks.""" + + if cut_idx < 0: + # Convert negative indices to positive so they don't mess up other calculations. + cut_idx = len(self) + cut_idx + + if cut_idx > self.tip_idx or cut_idx < 1: + raise Exception( + f"Error, invalid cut index of {cut_idx} provided. Cut index cannot be 0 and must be within branch bounds." + ) + + moving_blocks = [] + moving_children = [] + num_removals = len(self) - cut_idx + assert ( + num_removals > 0 + ), f"Attempted to cut at index {cut_idx} from branch with base {self.base.hash}, but branch was length {len(self)}" + + for i in range(num_removals): + block, children = self.pop() + # Block appended to moving_blocks in reversed order, newest blocks first, oldest blocks last + moving_blocks.append(block) + moving_children += children + + new_branch = ScoreTreeBranch() + for i in range(num_removals): + next_block = moving_blocks.pop() + # Thus when we pop them off of moving_blocks, we get them in the correct order to add them to the new branch. + new_branch.append_block(next_block) + + assert ( + len(moving_blocks) == 0 + ), f"Some blocks didn't get removed from staging. Blocks with hashes {[b.hash for b in moving_blocks]} remain in staging list." + assert ( + len(new_branch) == num_removals + ), f"Some blocks didn't get added to new branch. Missing {num_removals - len(new_branch)} blocks." + + moved_children = [] + for child in moving_children: + moved_children.append(child) + new_branch.link_child_branch(child) + + assert len(moving_children) == len( + moved_children + ), f"{len(moved_children)} reported moved but {len(moving_children)} were staged to move." + + for child in moved_children: + assert ( + child.parent == new_branch + ), f"Child branch with base hash {child.base.hash} has parent root {child.parent}. Should have {new_branch}" + + return new_branch diff --git a/src/utilities/crypto_utils.py b/src/utilities/crypto_utils.py new file mode 100644 index 0000000..ed58b44 --- /dev/null +++ b/src/utilities/crypto_utils.py @@ -0,0 +1,88 @@ +# Copyright 2026 D-Wave +# +# 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. + +import binascii +from hashlib import sha256 + +import numpy as np + + +def calculate_hash(data_in: str) -> str: + """Basic function SHA256 hashes. A wrapper handling + formatting data to and from strings to call hashlib's sha256. + + Args: + data_in (str): the data to be hashed, formatted as a hexidecimal string + + Returns: + output_hash (str): the hash of the passed data, formatted as a hex string.""" + + if type(data_in) != str: + raise Exception(f"Passed non-string data {data_in} of type {type(data_in)}") + + byte_data = bytearray(data_in, "utf-8") + hash_data = sha256(byte_data) + return hash_data.hexdigest() + + +def validate_zeroes(hash: str, num_zeroes: int = 0) -> bool: + """Validates that a hash value (formatted as a hex string) meets the criterion of having + a certain number of leading zeroes. This function handles reformatting the hex string + into a byte array so that the number of zeroes can be checked directly. + + Args: + hash (str): the hash to be validated + num_zeroes (int): the number of leading zeroes required to pass validation + + Returns: + passes_validation (bool): True if the hash passes, False otherwise.""" + + if 4 * len(hash) < num_zeroes: + raise Exception(f"Passed {num_zeroes} 0s, but hash {hash} with length {len(hash)} \ + represents only {4*len(hash)} binary digits.") + + q_hash_bytes = binascii.unhexlify(hash.encode(encoding="utf-8")) + numpy_bytes = np.frombuffer(np.array(q_hash_bytes), dtype="B") + numpy_bits = np.unpackbits(numpy_bytes) + + return not np.any(numpy_bits[:num_zeroes]) + + +def compare_hashes(first_hash: str, second_hash: str) -> np.ndarray: + """Performs a bitwise comparison of two hashes, applying the XNOR logical operation to each pair of bits, yielding + a 1 if the bits are the same and a 0 if they differ. Expects both hashes to be formatted as hexidecimal strings, + and returns the resulting bitstring in the same format. + + Args: + first_hash (str): a hash formatted as a hexidecimal string + second_hash (str): a second hash, with the same length and format as the first + + Returns: + hash_comparison (str): a hexidecimal string encoding the bits where the two hashes match and those where they don't. + """ + + hash_bytes = [binascii.unhexlify(hash_bits) for hash_bits in (first_hash, second_hash)] + numpy_bytes = [np.frombuffer(np.array(q_hash_bytes), dtype="B") for q_hash_bytes in hash_bytes] + numpy_bits1 = np.unpackbits(numpy_bytes[0]) + numpy_bits2 = np.unpackbits(numpy_bytes[1]) + assert len(numpy_bits1) == len( + numpy_bits2 + ), f"Attempted to compare hashes of different lengths, {len(numpy_bits1)} vs {len(numpy_bits2)}" + comparison_vector = np.zeros(shape=(len(numpy_bits1)), dtype=np.int8) + + for i in range(len(numpy_bits1)): + if numpy_bits1[i] == numpy_bits2[i]: + comparison_vector[i] = 1 + + return comparison_vector diff --git a/src/utilities/display_update.py b/src/utilities/display_update.py new file mode 100644 index 0000000..07aa7ca --- /dev/null +++ b/src/utilities/display_update.py @@ -0,0 +1,76 @@ +# Copyright 2026 D-Wave +# +# 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. + +from dash import html + +from demo_configs import MINER_NAMES + + +def render_miner_status(current_block_data: dict, num_miners: int, show_solvers=False) -> list: + """Renders the status of the miners in the current trial. Each miner will be named + "Miner n" where n is one more than their ID in TrialManager (because numbering + starting from Miner 0 is less aesthetic), and will have a status of "Mining, Mined, + Validating, Valid" if they've started acting this round, or "..." if not. + + Args: + block_number: The current block. + miner_status: The current statuses of all the miners. + show_solvers: Whether to have a third column showing the solver used. + + Returns: + str: Header to show above status table. + list: Miner status table. + """ + + mining_id = current_block_data["miner_id"] + + miner_status_dict = {MINER_NAMES[i]: ["", ""] for i in range(num_miners)} + for miner_id, score in current_block_data["scores"].items(): + status = "Validated" if score > 0 else "Rejected" + miner_status_dict[miner_id][0] = status + + miner_status_dict[mining_id][0] = "Mined" + + for miner_id, solver in current_block_data["solvers"].items(): + if "simulated_" in solver: + solver_str = solver.replace("simulated_", "") + else: + solver_substrings = solver.split("_system") + solver_str = f"{solver_substrings[0]} {solver_substrings[1]}" + miner_status_dict[miner_id][1] = solver_str + + table_head = html.Thead( + html.Tr( + [ + html.Th("Miner"), + html.Th("Status"), + html.Th("Solver") if show_solvers else (), + ], + ) + ) + + miner_entries = [ + (miner_id.replace("_", " "), *status[: 2 if show_solvers else 1]) + for miner_id, status in miner_status_dict.items() + ] + + table_rows = [] + for row in miner_entries: + new_row = [] + for cell in row: + new_row.append(html.Td(cell, className=f"{cell.lower()}-cell")) + + table_rows.append(html.Tr(new_row)) + + return [table_head, html.Tbody(table_rows)] diff --git a/src/utilities/get_solvers.py b/src/utilities/get_solvers.py new file mode 100644 index 0000000..70f1548 --- /dev/null +++ b/src/utilities/get_solvers.py @@ -0,0 +1,50 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ + + +from src.protocols.hash_calculator import SolverName, initialize_solver +from src.values import DEFAULT_ENERGY_TIME_RESCALING + + +def get_solver_lists(): + + qpu_solver_list = [] + simulated_list = [] + + for solver_name in SolverName: + name = str(solver_name.value) + if "simulated" in name: + next_solver = initialize_solver(name) + simulated_list.append(next_solver) + else: + try: + if name not in DEFAULT_ENERGY_TIME_RESCALING: + raise Exception("Solver energy scale not found!") + next_solver = initialize_solver(name) + qpu_solver_list.append(next_solver) + except: + print( + f"Initialization failed for a parameterized solver, likely unavailable through the client: {name}" + ) + + if len(qpu_solver_list) <= 0: + raise Exception("Cannot connect to any solvers. Unable to run.") + + return qpu_solver_list, simulated_list diff --git a/src/utilities/quantum_cubic_utils.py b/src/utilities/quantum_cubic_utils.py new file mode 100644 index 0000000..7249587 --- /dev/null +++ b/src/utilities/quantum_cubic_utils.py @@ -0,0 +1,420 @@ +# Copyright 2026 D-Wave +# +# 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. +# +# The use of code in the quantum-blockchain repository with a quantum computing system is protected +# by the intellectual property rights of D-Wave Quantum Inc. and its affiliates. +# +# The use of the quantum blockchain implementations below (including the Miner, Block, and Hash +# methods) with D-Wave's quantum computing system will require access to D-Wave’s LeapTM quantum +# cloud service and will be governed by the Leap Cloud Subscription Agreement available at: +# https://cloud.dwavesys.com/leap/legal/cloud_subscription_agreement/ + +import hashlib +import os +import pickle +from itertools import product + +import dimod +import dwave +import dwave_networkx as dnx +import networkx as nx +import numpy as np +from dwave.experimental.automorphism import ( + AutomorphismComposite, +) # Module location within dwave-ocean-sdk could be subject to change. +from dwave.preprocessing.composites import SpinReversalTransformComposite +from dwave.system import DWaveSampler +from dwave.system.composites import ParallelEmbeddingComposite +from minorminer.utils.parallel_embeddings import find_multiple_embeddings + +from src.values import ( + DEFAULT_CUBIC_BOUNDARY_CONDITIONS, + DEFAULT_CUBIC_LATTICE_SHAPE, + EMBEDDINGS_PATH, +) + + +def get_embeddings_filename( + edge_list_source: list[tuple], + edge_list_target: list[tuple], + embedding_directory: str = EMBEDDINGS_PATH, +) -> str: + """Generate a filename unique to the source and target graph pairs + + Sorted edge lists uniquely identify graphs (up to ordering of nodes within edges). + The hashes of the source and target edgelists can be used to + identify embeddings for which valid embeddings are known: + + Args: + edge_list_source: edges defining the source graph. Edges should be sortable. + edge_list_target: edges defining the target graph. Edges should be sortable. + embedding_directory: path to the canonical (repository) embeddings. + Returns: + embedding_filename (str): Unique file name based on the passed source and target edgelists. + """ + + els_hash = hashlib.sha256(str(tuple(sorted(edge_list_source))).encode()).hexdigest() + elt_hash = hashlib.sha256(str(tuple(sorted(edge_list_target))).encode()).hexdigest() + return os.path.join(embedding_directory, f"emb_S{els_hash}_T{elt_hash}.pkl") + + +def get_embeddings( + edge_list_source: list[tuple], + edge_list_target: list[tuple], + *, + embedding_directory: str = EMBEDDINGS_PATH, + embedding_timeout: int = 0, + max_num_emb: int | None = None, + load_from_cache: bool = True, + save_to_cache: bool = True, + verify_embeddings: bool = True, + find_subgraph_kwargs: bool = None, +) -> list[dict]: + """Return embeddings compatible with source and target edge lists. + + By default loaded from a directory. If not present, create + given a time allocation and target max number of parallel embeddings. Can + be saved to the embedding cache for reuse. + + Embeddings are dictionaries that define minor embeddings (logical variables) + on a processor graph. + Each dictionary key in an embedding defines a variable of a binary + quadratic model to be simulated. The dictionary value is a tuple, indicating the + set of processor qubits used to represent the variable. + + Args: + edge_list_source: edges defining the source graph. Edges should be sortable. + edge_list_target: edges defining the target graph. Edges should be sortable. + embedding_directory: path to the canonical (repository) embeddings. + embedding_timeout: timeout applied to embedding search when loading + from saved files fails. This parameter is ignored if loading + succeeds. A value of zero can be used to disable generation on the fly. + The value is applied to find_multiple_embeddings, if find_subgraph_kwargs + is None, it is also used as the timeout for find_subgraph_kwargs. + max_num_emb: the max number of embeddings to seek when loading from files fails. This + parameter is ignored if loading succeeds. + load_from_cache: attempt to load from the src.static.embeddings directory. + save_to_cache: save new embeddings to the src.static.embeddings directory. + verify_embeddings: Whether to test embeddings validity, this is only + necessary when using embeddings from an untested source. + find_subgraph_kwargs: kwargs passed to the find_subgraph routine. + Returns + A list of dictionaries, each dictionary defines an embedding. + + """ + embedding_filename = get_embeddings_filename( + edge_list_source, edge_list_target, embedding_directory + ) + if os.path.isfile(embedding_filename) and load_from_cache: + with open(embedding_filename, "rb") as f: + embeddings = pickle.load(f) + else: + if embedding_timeout > 0: + if find_subgraph_kwargs is None: + find_subgraph_kwargs = {"timeout": embedding_timeout} + + embeddings = find_multiple_embeddings( + S=nx.from_edgelist(edge_list_source), + T=nx.from_edgelist(edge_list_target), + timeout=embedding_timeout, + max_num_emb=max_num_emb, + embedder_kwargs=find_subgraph_kwargs, + ) + # Subgraph isomorphisms (1 to 1 dictionaries) must be converted to + # embeddings (1 to iterable dictionaries) + embeddings = [ + {source_node: (target_node,) for source_node, target_node in emb.items()} + for emb in embeddings + ] # Reformat 1:1 to 1:iterable + if len(embeddings) and save_to_cache: + with open(embedding_filename, "wb") as f: + pickle.dump(embeddings, f) + print(f"Embeddings are saved to {embedding_filename} for reuse.") + else: + embeddings = [] + + if verify_embeddings and len(embeddings) > 0: + assert all( + dwave.embedding.verify_embedding(emb, edge_list_source, edge_list_target) + for emb in embeddings + ), "An embedding provided (or created) was invalid for the target graph" + return embeddings + + +def dimerize_coupling_3d( + node1: tuple[int, int, int], + node2: tuple[int, int, int], + z_parity: int, + lattice_dims: tuple[int, int, int] = DEFAULT_CUBIC_LATTICE_SHAPE, +) -> tuple[tuple, tuple]: + """Convert a simple cubic lattice to a dimerized cubic lattice. + + The nodes are specified as a coordinates, a 3-tuple. The pattern + of couplings between dimers (pairs of qubits) is chosen to be compatible + with Zephyr and Pegasus (QPU processor) subgraph isomorphism (aka 1:1 + embedding). + + Args: + node1: first node in the edge. + node2: second node in the edge. + z_parity: 0 or 1, specifies one of two isomorphic graphs conventions. + lattice_dims: The maximum simple-cubic lattice dimension in each of three dimensions. + Boundary spanning edges (for periodic dimensions) require special handling. + Returns: + A node and edge list + """ + # x are 0,0; y are 1,1 ; z are (0,1), (1,0); both or random. The former two cases break reflection symmetry in x,y + if (node2[0] - node1[0]) % lattice_dims[0] == 1 or (node2[0] - node1[0]) % lattice_dims[ + 0 + ] == lattice_dims[0] - 1: + d1 = d2 = 0 # Vertical-vertical dimer coupling (dimension 0) + elif (node2[1] - node1[1]) % lattice_dims[1] == 1 or (node2[1] - node1[1]) % lattice_dims[ + 1 + ] == lattice_dims[1] - 1: + d1 = d2 = 1 # Horizontal-horizontal dimer coupling (dimension 1) + elif (node2[2] - node1[2]) % lattice_dims[2] == 1: + d1 = z_parity + d2 = 1 - z_parity + elif (node2[2] - node1[2]) % lattice_dims[2] == lattice_dims[2] - 1: + d1 = 1 - z_parity + d2 = z_parity + else: + raise ValueError("Displacements must be compatible with a simple cubic lattice.") + return node1 + (d1,), node2 + (d2,) + + +def displace_n_by_c(n: tuple, c: tuple, lattice_dims: tuple | None = None): + """Displace a coordinate by c, lattice_dims a lattice dimension. + + Args: + n: Node coordinates specified as tuple of integers. + c: Relative displacement of lattice neighbors, specified as + a tuple of integers. + lattice_dims: Lattice dimensions (for wrapping around a + periodic boundary condition). If None, then there + is no wrapping. + + Returns: + Displaced coordinate, a tuple of integers + """ + if lattice_dims is None: + return tuple((n[i] + c[i]) for i in range(len(n))) + + return tuple((n[i] + c[i]) % lattice_dims[i] for i in range(len(n))) + + +def create_lattice( + lattice_dims: tuple = DEFAULT_CUBIC_LATTICE_SHAPE, + dim_periodicity=DEFAULT_CUBIC_BOUNDARY_CONDITIONS, +) -> tuple[list, list]: + """Creates a square, cubic or hyper-cubic simple or dimerized lattice + + Args: + lattice_dims: lattice dimensions + dim_periodicity: periodic boundary specification in each of the dimensions. + A tuple of bools of length matching lattice_dims each indicating if the + dimension is periodic or open. + + Returns: + A tuple of node and edge lists + """ + if len(dim_periodicity) != len(lattice_dims): + raise ValueError("There should be a periodicity setting for every dimension") + + ndim = len(lattice_dims) + node_list = list(product(*[range(l) for l in lattice_dims])) + node_set = set(node_list) + # We generate 3 edges per node, wrap around those corresponding to periodic dimensions, and set + # non-periodic dimensions big enough to avoid wrap-around (dimension + 1) + dim_wrap_around = tuple((1 + (not i)) * l for l, i in zip(lattice_dims, dim_periodicity)) + geometric_displacements = tuple(tuple(int(i == j) for i in range(ndim)) for j in range(ndim)) + edge_list = [ + tuple(sorted([n, displace_n_by_c(n, c, dim_wrap_around)])) + for n in node_list + for c in geometric_displacements + if displace_n_by_c(n, c, dim_wrap_around) + in node_set # Exclude open boundary spanning edges. + ] + # Expand simple cubic lattice edges onto dimers (one extra dimension to indicate position in the dimer) + edge_set = {(node + (0,), node + (1,)) for node in node_list} + node_list = [node + (t,) for node in node_list for t in range(2)] + edge_set |= { + dimerize_coupling_3d(node1, node2, z_parity=0, lattice_dims=lattice_dims) + for node1, node2 in edge_list + } + + return node_list, sorted(edge_set) + + +def create_model( + seed: int | None = None, + problem_energy_scale=1.0, +) -> tuple[dict, dict]: + """Create binary quadratic models compatible with DOI: 10.1126/science.ado6285 + + The set of binary quadratic models are chosen for compatibility with demonstrations + of quantum supremacy in approximate sampling. A subset of the models are supported. + + Args: + seed: A seed for the coupler specification + problem_energy_scale: A rescaling of couplings and fields, required + to emulate evolution on one solver with another. + + Returns + An Ising model specified by h and J dictionaries keyed by nodes + and edges respectively. + + """ + prng = np.random.default_rng(seed) + node_list, edge_list = create_lattice() + J = {ij: (2 * prng.integers(2) - 1) / problem_energy_scale for ij in edge_list} + h = {i: 0 for i in node_list} + + return h, J + + +def build_stats(response: dimod.SampleSet, edge_list: list) -> np.ndarray: + """This function builds the statistics from the sampled output. + + The special case of pairwise correlations is considered. + + Args: + sampleset: An unembedded dimod sampleset + edge_list: edges on which to estimate correlations + + Returns: + stats: An array of the output statistics. The inputs for + witness construction (locality sensitive hashing). + """ + if response.record.sample.dtype != np.float64: + _samples = response.record.sample.astype(float) + else: + _samples = response.record.sample + node_list_to_linear = {n: idx for idx, n in enumerate(response.variables)} + corrs = np.array( + [ + np.sum( + _samples[:, node_list_to_linear[i]] + * _samples[:, node_list_to_linear[j]] + * response.record.num_occurrences + ) + / np.sum(response.record.num_occurrences) + for i, j in edge_list + ] + ) + + return corrs + + +def target_dimer_orientation(qpu: DWaveSampler) -> dict: + """A labeling of qubits by orientation + + Args: + qpu: The DWaveSampler + + Returns: + A mapping from the node to the orientation ('horizontal' or 'vertical') + """ + + if qpu.properties["topology"]["type"] == "zephyr": + to_coordinates = dnx.zephyr_coordinates( + *qpu.properties["topology"]["shape"] + ).linear_to_zephyr + dim_orientation = 0 + elif qpu.properties["topology"]["type"] == "pegasus": + to_coordinates = dnx.pegasus_coordinates( + *qpu.properties["topology"]["shape"] + ).linear_to_pegasus + dim_orientation = 0 + elif qpu.properties["topology"]["type"] == "chimera": + to_coordinates = dnx.chimera_coordinates( + *qpu.properties["topology"]["shape"] + ).linear_to_chimera + dim_orientation = 2 + else: + raise ValueError("Unknown orientation") + + int_to_str = {0: "vertical", 1: "horizontal"} + return {n: int_to_str[to_coordinates(n)[dim_orientation]] for n in qpu.nodelist} + + +def source_dimer_orientation(node_list: list) -> dict: + """A mapping from the node to the expected qubit-orientation on processor. + + Args: + node_list: A list of nodes. + Returns: + A mapping of nodes to the orientations ('horizontal' or 'vertical') + """ + int_to_str = {0: "vertical", 1: "horizontal"} + return {n: int_to_str[n[-1]] for n in node_list} + + +def generate_default_sampler( + source_edge_list: list, + qpu: DWaveSampler, + *, + embedding_directory: str = EMBEDDINGS_PATH, + embedding_timeout: float | int = 0, + automorphism_per_component: bool = True, +) -> dimod.Sampler: + """This function generates a sampler (either a QPU or a SA sampler), appropriately + parameterized based on the input to this function. + + Args: + source_edge_list: A list of couplers relevant to the programmed Hamiltonian + qpu: A DWaveSampler + embedding_directory: Path to saved embeddings. + embedding_timeout: Timeout for on-the-fly embedding. Embeddings can be + created as one-time work per QPU using calibration/get_qpu_embeddings.py. By + default the timeout is zero and an error is thrown if the embedding is not + precalculation. + automorphism_per_component: If True, each embedding has an independent + automorphism applied (matching arxiv:2503.14462). If False, independent + automorphisms are applied to each component, which is faster in the current + implementation. + Returns: + A sampler aggregating samplesets from random parallel QPU embeddings + """ + embeddings = get_embeddings( + source_edge_list, + qpu.edgelist, + embedding_directory=embedding_directory, + embedding_timeout=embedding_timeout, + ) + if len(embeddings) == 0: + raise Exception( + f"Embeddings not found at {embedding_directory}" + "Use calibration/get_qpu_embeddings to generate embeddings/" + ) + if automorphism_per_component: + # This should be much faster subject to https://github.com/dwavesystems/dwave-experimental/pull/38 + embedded_edge_list = [ + (emb[v1], emb[v2]) for emb in embeddings for v1, v2 in source_edge_list + ] + sampler = ParallelEmbeddingComposite( + AutomorphismComposite( + SpinReversalTransformComposite(qpu), G=nx.from_edgelist(embedded_edge_list) + ), + embeddings=embeddings, + source=nx.from_edgelist(source_edge_list), + ) + else: + sampler = AutomorphismComposite( + ParallelEmbeddingComposite( + SpinReversalTransformComposite(qpu), + embeddings=embeddings, + source=nx.from_edgelist(source_edge_list), + ) + ) + + return sampler diff --git a/src/utilities/random_projection.py b/src/utilities/random_projection.py new file mode 100644 index 0000000..c1681bb --- /dev/null +++ b/src/utilities/random_projection.py @@ -0,0 +1,61 @@ +# Copyright 2026 D-Wave +# +# 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. + +import numpy as np + + +class RandomProjectionHasher: + def __init__( + self, + *, + random_seed: int = 0, + num_bits_out: int = 32, + input_dimension: int = 64, + forced_orthogonal_vector: np.ndarray | None = None, + ): + """This is a class that implements a simple random projection hash function. + + Args: + random_seed: The random seed to use for generating the plane norms. + num_bits_out: The number of bits to output. + input_dimension: The dimension of the input vector. + forced_orthogonal_vector (np.ndarray or None): Defaults to None. If passed, + forces all hyperplanes to be orthogonal to this vector.""" + + prng = np.random.default_rng(random_seed) + self.plane_norms = prng.normal(size=(num_bits_out, input_dimension)) + + if forced_orthogonal_vector is not None: + forced_orthogonal_vector /= np.sqrt(np.sum(forced_orthogonal_vector**2)) + coeffs = self.plane_norms @ forced_orthogonal_vector[:, np.newaxis] + self.plane_norms = self.plane_norms - forced_orthogonal_vector[np.newaxis, :] * coeffs + + def hash_vector(self, input_vector: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """This function hashes a vector using the random projection hash function. + + Args: + input_vector (np.nd_array): An input vector of floats which will be hashed + and reshaped via a locality-sensitive random projection hash. + Returns: + binary_vector (np.ndarray): The result of applying the random projection hashing, + which should be a np.ndarray whose components are exclusively 1s and 0s. The + length is defined by the num_bits_out parameter in the class constructor. + dot_vector (np.ndarray): The dot product of the input vector with the plane norms. This + should have the same length as the binary vector, but the outputs are signed floats + indicating distance and direction from the hyperplanes of the random projection.""" + + dot_vector = np.dot(input_vector, self.plane_norms.T) + bool_vector = dot_vector > 0 + binary_vector = bool_vector.astype(int) + return binary_vector, dot_vector diff --git a/src/utilities/spiral_plotter.py b/src/utilities/spiral_plotter.py new file mode 100644 index 0000000..c93d3ce --- /dev/null +++ b/src/utilities/spiral_plotter.py @@ -0,0 +1,594 @@ +# Copyright 2026 D-Wave +# +# 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. + +import math + +import plotly.graph_objects as go + +from demo_configs import ( + ABANDONED_BRANCH_EDGE_COLOR, + ABANDONED_BRANCH_POINT_COLOR, + ACTIVE_BRANCH_EDGE_COLOR, + ACTIVE_BRANCH_POINT_COLOR, + GRAPH_BRANCH_POINT_SCALING, + GRAPH_LOOP_SCALING, + GRAPH_MAX_BRANCH_DISTANCE, + GRAPH_MAX_POINTS_PER_REVOLUTION, + GRAPH_MAX_RADIUS, + GRAPH_MIN_POINTS_PER_REVOLUTION, + GRAPH_POINT_MAX_SIZE, + GRAPH_POINT_MIN_SIZE, + GRAPH_RADIAL_LINE_COLOR, + GRAPH_RADIAL_LINE_WIDTH, + GRAPH_SEGMENTS_PER_REVOLUTION, + MINING_BLOCK_BORDER_COLOR, + TRUNK_EDGE_COLOR, + TRUNK_POINT_COLOR, + TRUNK_TIP_COLOR, +) +from src.structures.block_score_tree import BlockScoreTree +from src.structures.score_tree_branch import BlockNode, ScoreTreeBranch + + +class GraphBranch(ScoreTreeBranch): + """This class holds a single branch of a BlockScore tree object, storing necessary + data to plot that branch in a spiral plot alongside the standard branch data from + the ScoreTreeBranch class. Coordinates for points and edge sections will initialize + to empty lists (they must be computed and appended by the relevant methods from + the SpiralPlotter class).""" + + def __init__( + self, + branch: ScoreTreeBranch, + point_color: str = ABANDONED_BRANCH_POINT_COLOR, + edge_color: str = ABANDONED_BRANCH_EDGE_COLOR, + ): + """Initializes the graph branch. + + Args: + branch (ScoreTreeBranch): the branch to be graphed + point_color (str): the color assigned to the branch's points + edge_color (str): the color assigned to the branch's edges""" + + super().__init__() + for node in branch.node_list: + self.append_block(node) + + self.depth = branch.depth + self.x_edges = [] + self.y_edges = [] + self.x_points = [] + self.y_points = [] + self.point_colors = [point_color] * len(self.node_list) + self.edge_color = edge_color + self.edge_color_cutoff = -1 + self.depth_adjustment = 0 + + @property + def start_idx(self) -> int: + if self.parent is None: + return 0 + + return self.root.block_number + 1 + + @property + def final_idx(self) -> int: + return self.tip.block_number + + @property + def adjusted_depth(self) -> int: + return self.depth + self.depth_adjustment + + def create_size_chart(self, master_size_chart: list, size_scale: float = 1.0): + """Creates a size chart, assigning a size to each point in the branch. This + must be based on a master size chart, which defines the size progression. + This method allows all the points in the branch to be proportionally + scaled down. + + Args: + master_size_chart (list): size chart for the trunk. Branch size charts are computed relative + to the trunk. + size_scale (float): Defaults to 1.0. Factor by which to scale the points in the branch. + In general, non-trunk branches should be passed values less than 1, so they are drawn + smaller than the points on the trunk.""" + + self.size_chart = [ + size_scale * size + for idx, size in enumerate(master_size_chart) + if idx in [b.block_number for b in self] + ] + + def assign_depth_adjustment(self, parent_adjusted_depth: int, depth_limits: list[int]): + """Assigns a depth adjustment to the branch, indicating how far away from the trunk + it must be drawn so as not to collide with any other branches. This function is + called recursively on all the children of the branch, as the children of a branch + should not be assigned independently of one-another (optimal assignment depends on + assigning them in order).""" + + local_depth_adjustment = None + for depth in range(parent_adjusted_depth, len(depth_limits)): + bound = depth_limits[depth] + if self.tip.block_number < bound: + local_depth_adjustment = depth - self.depth + depth_limits[depth] = self.root.block_number + break + + # In this case, we exceeded the max depth in depth_limits without finding a space + if local_depth_adjustment is None: + depth_limits.append(self.root.block_number) # So we extend depth_limits to accommodate + local_depth_adjustment = len(depth_limits) - self.depth + + self.depth_adjustment = local_depth_adjustment + sorted_children = [x for x in self.children] + sorted_children.sort(key=lambda x: len(self) - x.root.block_number) + for child in sorted_children: + child.assign_depth_adjustment(self.adjusted_depth, depth_limits) + + +class SpiralPlotter: + """This class facilitates plotting blockchain graphs as a collection of concentric spiral sections, + with the longest chain (the "trunk") extending from the center to the edge of the plot and smaller, + soft forks paralleling the main spiral to the inside.""" + + def __init__(self): + self.fig_width = 1 + self.center = (self.fig_width / 2, self.fig_width / 2) + self.coord_dict = {} + + @property + def trunk(self): + return self.tree.trunk + + @property + def branches(self): + return self.tree.branches + + def _create_master_size_chart(self) -> list[float]: + """Creates a size chart for the points in the trunk, which determines how large each point + will appear on the chart. Points closer to the center will appear smaller, points + closer to the tip will appear larger. + + Returns: + master_size_chart (list[float]): A list of floats indicating how to scale the sizes + points in the trunk. Points nearer the center (earlier in the trunk) will be + close to GRAPH_MIN_POINT_SIZE. Moving out from there, points will grow + smoothly larger as they approach the trunk tip. The points at the tip + should be close to GRAPH_MAX_POINT_SIZE.""" + + step_size = (GRAPH_POINT_MAX_SIZE - GRAPH_POINT_MIN_SIZE) / max(self.num_nodes - 1, 1) + return [GRAPH_POINT_MIN_SIZE + i * step_size for i in range(self.num_nodes + 1)] + + def import_plotting_data(self, tree_data: BlockScoreTree): + """Takes a BlockScoreTree object and processes the data to prepare it to be plotted. + When the data is imported, it is stored in a data structure similar to the original + BlockScoreTree, but modified to included meta-data important for plotting. In + addition, certain pre-processing calculations are done as the data is loaded, + such as calculating how many turns the spiral graph will have, and how many + individual line segments will be used to build up the curves. + + Args: + tree_data (BlockScoreTree): a BlockScoreTree object containing data to be + plotted on a spiral plot.""" + + self.tree = tree_data + self.tree.refactor_branches() + self.num_nodes = tree_data.num_nodes + self.master_size_chart = self._create_master_size_chart() + self.points_per_rev = self.calculate_points_per_rev() + self.num_revs = (self.num_nodes + 1) / self.points_per_rev + self.segs_per_point = math.ceil(GRAPH_SEGMENTS_PER_REVOLUTION / self.points_per_rev) + + angle_step = 2 * math.pi / self.points_per_rev + self.angles = [i * angle_step for i in range(1, self.points_per_rev + 1)] + self.fractional_angles = [ + [(i + j / self.segs_per_point) * angle_step for j in range(1, self.segs_per_point)] + for i in range(1, self.points_per_rev + 1) + ] + self.radii = [self._calculate_r(i) for i in range(self.num_nodes + 1)] + self.fractional_radii = [ + [self._calculate_r(i + j / self.segs_per_point) for j in range(1, self.segs_per_point)] + for i in range(self.num_nodes) + ] + + for idx, branch in enumerate(self.tree.branches): + if branch == self.tree.trunk: + new_graph_branch = GraphBranch( + branch, point_color=TRUNK_POINT_COLOR, edge_color=TRUNK_EDGE_COLOR + ) + new_graph_branch.create_size_chart(self.master_size_chart) + self.tree.branches[0] = new_graph_branch + self.tree.trunk = new_graph_branch + self.tree.trunk.point_colors[-1] = TRUNK_TIP_COLOR + + else: + new_graph_branch = GraphBranch(branch) + new_graph_branch.create_size_chart( + self.master_size_chart, GRAPH_BRANCH_POINT_SCALING + ) + self.tree.branches[idx] = new_graph_branch + for node in branch: + self.tree.hash_to_branch_lookup[node.hash] = new_graph_branch + + for branch in self.tree.branches: + if branch != self.trunk: + parent_branch = self.tree.hash_to_branch_lookup[branch.root_hash] + parent_branch.link_child_branch(branch) + + def calculate_points_per_rev(self): + """Calculates how many points will be drawn in a single turn of the spiral. This changes + dynamically so that graphs with small numbers of points will still have a distinctly + spiral shape, but graphs with large numbers will be compressed enough to display data + efficiently. The specific algorithm is intended to change the view relatively smoothly, + not making too many adjustments to the spacing, but still ensuring that each adjustment + is not too big of a change from the previous graph. + + Returns: + points_per_rev (int): the number of points that will be drawn in a single revolution.""" + + allowed_vals = list( + range(GRAPH_MIN_POINTS_PER_REVOLUTION, GRAPH_MAX_POINTS_PER_REVOLUTION + 1, 4) + ) + if self.num_nodes <= GRAPH_MIN_POINTS_PER_REVOLUTION * 1.5: + return GRAPH_MIN_POINTS_PER_REVOLUTION + elif self.num_nodes >= GRAPH_MAX_POINTS_PER_REVOLUTION * 1.5: + return GRAPH_MAX_POINTS_PER_REVOLUTION + + allowed_index = 0 + for idx, val in enumerate(allowed_vals): + if self.num_nodes < val * 1.5: + break + + allowed_index = idx + + return allowed_vals[allowed_index] + + def _calculate_r(self, node_num: int | float) -> float: + """Calculates the distance from the center at which a point should be drawn. The logic + is chosen such that the furthest-out turn of the spiral will take up 1/3 of the total + radius, while the next turn in will take up 1/3 of the remainder. A correction factor + is added to this so that points very near the beginning of the spiral will converge + more quickly towards the center (which would otherwise only happen in the limit of + very many revolutions). + + Args: + node_num (int or float): the block number (order in the blockchain) of the node + being computed. Allows for fractional node numbers to assist in drawing + graph lines, which requires plotting points in between the actual graph nodes. + + Returns: + radius: distance from the center at which this graph point should be drawn.""" + + if node_num == 0: + r_exp = -math.inf + else: + node_rev_num = node_num / self.points_per_rev + r_exp = node_rev_num - 1 / node_rev_num + + r_scale = GRAPH_LOOP_SCALING ** (self.num_revs - r_exp) + return GRAPH_MAX_RADIUS * r_scale + + def _arrange_branches(self): + """Queries the overall structure of the tree, and modifies the depth_adjustment + attribute of branches as necessary to allow every branch to be graphed on the + tree without any crossing or overlapping. This relies partially on the + refactor_branches() method of BlockScoreTree, which ensures that the branches are + arranged such that this can be done simply and efficiently.""" + + bottom_level_branches = [branch for branch in self.branches if branch.depth == 1] + bottom_level_branches.sort(key=lambda x: self.num_nodes - x.root.block_number) + max_depth = max(b.depth for b in self.branches) + + # Depth 0 will always be fully occupied by trunk, but including it makes list indices line up to depth values + depth_limits = [0] + [self.num_nodes + 1 for _ in range(max_depth)] + + for branch in bottom_level_branches: + branch.assign_depth_adjustment(0, depth_limits) + + self.max_branch_depth = max(len(depth_limits) - 1, 3) + + def _calculate_depth_adjustment(self, branch_depth: int) -> float: + """Calculates the adjustment factor used to scale the radii of points in a branch. Each + should be drawn slightly farther inward towards the center of the graph than the + trunk; how much farther depend on how many other branches are between it and + the trunk, passed as the branch_depth argument. + + Args: + branch_depth (int): The number of branches (self included) between this branch and + the trunk. The trunk should always be depth 0, a branch with no other branches + near it will be depth 1, and so on.""" + adjustment_fraction = branch_depth * (1 - GRAPH_MAX_BRANCH_DISTANCE) + return (self.max_branch_depth - adjustment_fraction) / self.max_branch_depth + + def _plot_spiral_points(self, branch: GraphBranch): + """Computes and records the x and y coordinates for each node on a particular branch. + + Args: + branch (GraphBranch): the GraphBranch object to be plotted + """ + + adjustment = self._calculate_depth_adjustment(branch.depth + branch.depth_adjustment) + + for node in branch: + r_node = self.radii[node.block_number] * adjustment + theta_node = self.angles[node.block_number % self.points_per_rev] + x_node = self.center[0] + r_node * math.cos(theta_node) + y_node = self.center[1] + r_node * math.sin(theta_node) + branch.x_points.append(x_node) + branch.y_points.append(y_node) + self.coord_dict.update({node.block_number: (x_node, y_node)}) + + def _plot_spiral_curves(self, branch: GraphBranch, trunk: bool = True): + """For a given branch, adds the points defining the 'curves' connecting the points + on that branch. Each such 'curve' will be made up of a number of line segments + defined by the self.segs_per_point attribute. Plotly accepts these as lists + of x- and y-coordinates, between which it will draw the lines. These coordinates + include all of the coordinates of points on the graph, but also many points + between them so as to create a smooth curve. + + Args: + branch (GraphBranch): the branch to be plotted + trunk (bool): Defaults to 'True'. Flag to signal whether the branch + is the trunk: non-trunk branches need a 'stem' segment drawn + to connect them to their parent branch.""" + + if not trunk: # Adds straight "stem" segment connecting branch to parent + root_idx = branch.parent.hash_to_index_lookup[branch.root_hash] + root_x = branch.parent.x_points[root_idx] + root_y = branch.parent.y_points[root_idx] + branch.x_edges.append(root_x) + branch.y_edges.append(root_y) + + start_idx = branch.start_idx + stop_idx = branch.tip.block_number + adjustment = self._calculate_depth_adjustment(branch.depth + branch.depth_adjustment) + for i in range(start_idx, stop_idx + 1): + r_i = self.radii[i] * adjustment + theta_i = self.angles[i % self.points_per_rev] + x_i = self.center[0] + r_i * math.cos(theta_i) + y_i = self.center[1] + r_i * math.sin(theta_i) + branch.x_edges.append(x_i) + branch.y_edges.append(y_i) + if i == stop_idx: + break + + for j in range(self.segs_per_point - 1): + r_ij = self.fractional_radii[i][j] * adjustment + theta_ij = self.fractional_angles[i % self.points_per_rev][j] + x_ij = self.center[0] + r_ij * math.cos(theta_ij) + y_ij = self.center[1] + r_ij * math.sin(theta_ij) + branch.x_edges.append(x_ij) + branch.y_edges.append(y_ij) + + def _color_for_global_view(self, active_blocks: list[str], trunk_cutoff: int): + """Performs the necessary computations to recolor the graph according to + the global view coloring scheme. In this scheme, the trunk is divided into + two different colors, and active branches are also recolored to match the second trunk + color. Finally, the terminal points of active branches are colored to match the + trunk tip. + + Args: + trunk_cutoff (int): the block number of the last shared block in all miner's trunks. This + will determine the point at which the trunk is recolored.""" + cutoff_index = None + for idx, block in enumerate(self.trunk): + if block.block_number == trunk_cutoff: + cutoff_index = idx + break + + if cutoff_index is None: + raise Exception( + f"No block number matching provided cutoff {trunk_cutoff} found in trunk" + ) + + for branch in self.branches: + if branch != self.trunk and branch.root.block_number >= trunk_cutoff: + branch.point_colors = [ACTIVE_BRANCH_POINT_COLOR for _ in range(len(branch))] + branch.edge_color = ACTIVE_BRANCH_EDGE_COLOR + for block_hash in active_blocks: + if block_hash in branch: + block_index = branch.hash_to_index_lookup[block_hash] + branch.point_colors[block_index] = TRUNK_TIP_COLOR + + if cutoff_index == len(self.trunk) - 1: + return + + self.trunk.point_colors = [TRUNK_POINT_COLOR for _ in range(cutoff_index + 1)] + [ + ACTIVE_BRANCH_POINT_COLOR for _ in range(cutoff_index + 1, len(self.trunk)) + ] + self.trunk.point_colors[-1] = TRUNK_TIP_COLOR + for block_hash in active_blocks: + if block_hash in self.trunk: + block_index = self.trunk.hash_to_index_lookup[block_hash] + self.trunk.point_colors[block_index] = TRUNK_TIP_COLOR + + self.trunk.edge_color_cutoff = trunk_cutoff * self.segs_per_point + + def draw_radial_lines(self) -> list[go.Scatter]: + """Draws radial lines at the pre-defined angles at which blocks will be plotted. These are + very simple straight segments extending from the center of the plot to the edge, + lining up with the angles at which points are drawn. + + Return: + traces (list[go.Scatter]). A list of Plotly Graph Objects Scatter objects, + each one containing a single radial line.""" + traces = [] + for angle in self.angles: + x_end = self.center[0] + GRAPH_MAX_RADIUS * math.cos(angle) + y_end = self.center[1] + GRAPH_MAX_RADIUS * math.sin(angle) + x_edge = [self.center[0], x_end] + y_edge = [self.center[1], y_end] + new_trace = go.Scatter( + x=x_edge, + y=y_edge, + mode="lines", + line={"color": GRAPH_RADIAL_LINE_COLOR, "width": GRAPH_RADIAL_LINE_WIDTH}, + ) + traces.append(new_trace) + + return traces + + def draw_spiral( + self, + active_blocks: list[str], + active_block_cutoff: int | None = None, + mining_block: BlockNode | None = None, + ) -> go.Figure: + """Assuming all the points and edges have been plotted, draws them on the figure, coloring and sizing them + according to the pre-defined color and size schema. This will draw two distinct sorts of elements onto the + graph area: points and lines. Each branch of the graph will have one set of points (indicating the blocks + that are part of that branch) and one set of lines, arranged so as to connect those points in a curving spiral + shape. + + active_block_cutoff (int): the block number of the last shared block in all miner's trunks. If a value + is passed, this will cause the _color_for_global_view() function to be called with that value, + recoloring the graph to represent a global view + + Returns: + fig: The Plotly figure with the spiral graphed as determined by the data stored in the branches. + """ + + self._arrange_branches() + + self._plot_spiral_points(self.trunk) + for branch in self.branches: + self._plot_spiral_points(branch) + + for branch in self.branches: + self._plot_spiral_curves(branch, trunk=bool(branch == self.trunk)) + + if active_block_cutoff is not None: + self._color_for_global_view( + active_blocks=active_blocks, trunk_cutoff=active_block_cutoff + ) + + plot_data = self.draw_radial_lines() + + if mining_block is not None: + if mining_block.hash in self.tree.hash_to_branch_lookup: + mining_branch = self.tree.hash_to_branch_lookup[mining_block.hash] + else: + mining_branch = self.tree.hash_to_branch_lookup[mining_block.prev_hash] + + if mining_branch == self.trunk or active_block_cutoff is not None: + mining_block_color = TRUNK_TIP_COLOR + else: + mining_block_color = ABANDONED_BRANCH_POINT_COLOR + + mining_x = [mining_branch.x_points.pop()] + mining_y = [mining_branch.y_points.pop()] + mining_size = mining_branch.size_chart.pop() + mining_trace = go.Scatter( + x=mining_x, + y=mining_y, + mode="markers", + marker={ + "color": mining_block_color, + "opacity": 1, + "size": mining_size, + "line": {"width": 4, "color": MINING_BLOCK_BORDER_COLOR}, + }, + ) + else: + mining_branch = None + + trunk_edge_traces = [] + if active_block_cutoff is None or active_block_cutoff >= self.trunk.tip.block_number: + edge_section = go.Scatter( + x=self.trunk.x_edges, + y=self.trunk.y_edges, + mode="lines", + line={"color": self.trunk.edge_color}, + ) + trunk_edge_traces.append(edge_section) + else: + edge_section_1 = go.Scatter( + x=self.trunk.x_edges[: self.trunk.edge_color_cutoff + 1], + y=self.trunk.y_edges[: self.trunk.edge_color_cutoff + 1], + mode="lines", + line={"color": self.trunk.edge_color}, + ) + trunk_edge_traces.append(edge_section_1) + edge_section_2 = go.Scatter( + x=self.trunk.x_edges[self.trunk.edge_color_cutoff :], + y=self.trunk.y_edges[self.trunk.edge_color_cutoff :], + mode="lines", + line={"color": ACTIVE_BRANCH_EDGE_COLOR}, + ) + trunk_edge_traces.append(edge_section_2) + trunk_node_trace = go.Scatter( + x=self.trunk.x_points, + y=self.trunk.y_points, + mode="markers", + marker={"size": self.trunk.size_chart, "color": self.trunk.point_colors, "opacity": 1}, + ) + plot_data += trunk_edge_traces + + node_traces = [trunk_node_trace] + if self.trunk == mining_branch: + node_traces.append(mining_trace) + + for branch in self.branches: + if branch != self.trunk: + edge_section = go.Scatter( + x=branch.x_edges, + y=branch.y_edges, + mode="lines", + line={"color": branch.edge_color}, + ) + plot_data.append(edge_section) + branch_node_trace = go.Scatter( + x=branch.x_points, + y=branch.y_points, + mode="markers", + marker={"color": branch.point_colors, "opacity": 1, "size": branch.size_chart}, + ) + node_traces.append(branch_node_trace) + if branch == mining_branch: + node_traces.append(mining_trace) + + for trace in node_traces: + plot_data.append(trace) + + fig = go.Figure(plot_data) + return fig + + def create_plot_from_tree( + self, + tree: BlockScoreTree, + active_blocks: list[str], + active_block_cutoff: int | None = None, + mining_block: BlockNode | None = None, + ) -> go.Figure: + """Given a BlockScoreTree object, creates a spiral plot displaying that tree. Calls all + the necessary SpiralPlotter functions in order. + + Args: + tree (BlockScoreTree): the BlockScoreTree object you wish to plot. + active_block_cutoff (int): Defaults to None. The block number + of the last block that all miners have in their trunk. Used to recolor + the graph as a global view. + + Returns: + plot (go.Figure): a Plotly Graph Objects figure, containing the spiral plot for + the tree. + + """ + + self.import_plotting_data(tree_data=tree) + plot = self.draw_spiral( + active_blocks=active_blocks, + active_block_cutoff=active_block_cutoff, + mining_block=mining_block, + ) + return plot diff --git a/src/values.py b/src/values.py new file mode 100644 index 0000000..38fd4f1 --- /dev/null +++ b/src/values.py @@ -0,0 +1,75 @@ +# Copyright 2026 D-Wave +# +# 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. + +import os +from datetime import datetime # To set genesis block + +import numpy as np # To set effective number of samples (sampling noise) + +# =================================================================================== +# PoW Protocol Definitions +# =================================================================================== + +EMPTY_QUANTUM_HASH = "" + +# Large enough to outweigh any legitimate score, small enough to work on all platforms. +MIN_SCORE = -(2**14) + +# =================================================================================== +# Unitary dynamics parameterization +# =================================================================================== +# Microseconds of evolution for the quench as executed on Advantage2_prototype2 +DEFAULT_ANNEALING_TIME = 0.005 +DEFAULT_CUBIC_LATTICE_SHAPE = (4, 4, 4) # Default dimensions of dimerized cubic lattice. +DEFAULT_CUBIC_BOUNDARY_CONDITIONS = (False, False, True) # Open, Open, Periodic +# Energy time rescalings required to emulate Advantage2_system2.6 at +# full problem energy scale (see calibration/). For systems of lower energy scale, +# anneals must be run for longer, for systems of higher energy scale, the +# problem Hamiltonian (energy) scale is reduced. +DEFAULT_ENERGY_TIME_RESCALING = { + "Advantage_system4.1": (1.0, 0.535), + "Advantage_system6.4": (1.0, 0.488), + "Advantage2_system1.11": (1.34, 1.0), +} +# =================================================================================== +# Global Trial Definitions +# =================================================================================== + +MAX_MINING_ATTEMPTS = 100000 +W_0_ALPHA = 0.0 +DEFAULT_NUM_READS = 600 # NB - Smaller than arXiv:2503.14462. + +# Value used for Advantage_system4.1 in arXiv:2503.14462. Num reads was fixed to use 1 second of QPU +# access time (maximum for single-programming). For the simulated data, this is the relevant value. +SIMULATED_DATA_NUM_READS = 3860 + +# Set per the description of arXiv:arXiv:2503.14462 subject to two difference: +# (1) NUM_READS can be (is by default) smaller, so variance is scaled accordingly (conservatively: +# in line with sampling noise and ignoring control noise) +# (2) the generally available compute environment is different (measured d_Walpha=0.16 over 3 +# solvers available January 21 2026, as opposed to 0.18 the 4 GA solvers at the time of the paper). +DELTA_W_0_ALPHA = 0.16 * np.sqrt(SIMULATED_DATA_NUM_READS / DEFAULT_NUM_READS) + +GENESIS_BLOCK_TIMESTAMP = datetime.timestamp(datetime.fromisoformat("2025-01-01 00:00:00.000")) +GENESIS_BLOCK_PREV_HASH = "begin_blockchain" +GENESIS_MINER_ID = "genesis" + +# =================================================================================== +# Directory Definitions +# =================================================================================== + +REPO_PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +STATIC_PATH = os.path.join(REPO_PATH, "static") +SIMULATED_PATH = os.path.join(STATIC_PATH, "simulated_data") +EMBEDDINGS_PATH = os.path.join(STATIC_PATH, "embeddings") diff --git a/static/Consensus_infographic.png b/static/Consensus_infographic.png new file mode 100644 index 0000000..64bdd39 Binary files /dev/null and b/static/Consensus_infographic.png differ diff --git a/static/Quantum_hash_infographic.png b/static/Quantum_hash_infographic.png new file mode 100644 index 0000000..bf075ec Binary files /dev/null and b/static/Quantum_hash_infographic.png differ diff --git a/static/__init__.py b/static/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/static/demo.png b/static/demo.png index 4969c82..2c67c2e 100644 Binary files a/static/demo.png and b/static/demo.png differ diff --git a/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_T6e4161cc04d2f755cefed6225649f01433d861beb7bf9e9173795ac6fba26284.pkl b/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_T6e4161cc04d2f755cefed6225649f01433d861beb7bf9e9173795ac6fba26284.pkl new file mode 100644 index 0000000..220e1a5 Binary files /dev/null and b/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_T6e4161cc04d2f755cefed6225649f01433d861beb7bf9e9173795ac6fba26284.pkl differ diff --git a/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_T92a74cb5b3b52d8984dc7a95bf71ff7923efd5f5111a9415b1335f57aebcafa6.pkl b/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_T92a74cb5b3b52d8984dc7a95bf71ff7923efd5f5111a9415b1335f57aebcafa6.pkl new file mode 100644 index 0000000..c52b5ff Binary files /dev/null and b/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_T92a74cb5b3b52d8984dc7a95bf71ff7923efd5f5111a9415b1335f57aebcafa6.pkl differ diff --git a/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_Tec37e640f63bf0e87e6dc9d543357437f34580a6ed62e6eaa99f9111226ddb02.pkl b/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_Tec37e640f63bf0e87e6dc9d543357437f34580a6ed62e6eaa99f9111226ddb02.pkl new file mode 100644 index 0000000..899b907 Binary files /dev/null and b/static/embeddings/emb_Sc23cd6ab1177c90a3323cebf52038db85144b077a45be24444610e0c28671e25_Tec37e640f63bf0e87e6dc9d543357437f34580a6ed62e6eaa99f9111226ddb02.pkl differ diff --git a/static/simulated_data/simulated_Advantage2_prototype2.6_mean.npy b/static/simulated_data/simulated_Advantage2_prototype2.6_mean.npy new file mode 100644 index 0000000..ce75982 Binary files /dev/null and b/static/simulated_data/simulated_Advantage2_prototype2.6_mean.npy differ diff --git a/static/simulated_data/simulated_Advantage2_prototype2.6_var.npy b/static/simulated_data/simulated_Advantage2_prototype2.6_var.npy new file mode 100644 index 0000000..c368e2b Binary files /dev/null and b/static/simulated_data/simulated_Advantage2_prototype2.6_var.npy differ diff --git a/static/simulated_data/simulated_Advantage_system4.1_mean.npy b/static/simulated_data/simulated_Advantage_system4.1_mean.npy new file mode 100644 index 0000000..de1cd6c Binary files /dev/null and b/static/simulated_data/simulated_Advantage_system4.1_mean.npy differ diff --git a/static/simulated_data/simulated_Advantage_system4.1_var.npy b/static/simulated_data/simulated_Advantage_system4.1_var.npy new file mode 100644 index 0000000..9e1d364 Binary files /dev/null and b/static/simulated_data/simulated_Advantage_system4.1_var.npy differ diff --git a/static/simulated_data/simulated_Advantage_system6.4_mean.npy b/static/simulated_data/simulated_Advantage_system6.4_mean.npy new file mode 100644 index 0000000..1f5f764 Binary files /dev/null and b/static/simulated_data/simulated_Advantage_system6.4_mean.npy differ diff --git a/static/simulated_data/simulated_Advantage_system6.4_var.npy b/static/simulated_data/simulated_Advantage_system6.4_var.npy new file mode 100644 index 0000000..339f0bd Binary files /dev/null and b/static/simulated_data/simulated_Advantage_system6.4_var.npy differ diff --git a/static/simulated_data/simulated_Advantage_system7.1_mean.npy b/static/simulated_data/simulated_Advantage_system7.1_mean.npy new file mode 100644 index 0000000..52e10e0 Binary files /dev/null and b/static/simulated_data/simulated_Advantage_system7.1_mean.npy differ diff --git a/static/simulated_data/simulated_Advantage_system7.1_var.npy b/static/simulated_data/simulated_Advantage_system7.1_var.npy new file mode 100644 index 0000000..e924137 Binary files /dev/null and b/static/simulated_data/simulated_Advantage_system7.1_var.npy differ diff --git a/tests/class_tests/__init__.py b/tests/class_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/class_tests/conftest.py b/tests/class_tests/conftest.py new file mode 100644 index 0000000..50d746d --- /dev/null +++ b/tests/class_tests/conftest.py @@ -0,0 +1,58 @@ +# Copyright 2026 D-Wave +# +# 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. + +import os + +import pytest + +CUR_DIR = os.path.dirname(os.path.realpath(__file__)) + +from tests.data_generation.block_generation import BlockGenerator +from tests.data_generation.tree_generation import ( + append_scores, + generate_binary_layered_tree_dicts, + generate_random_tree, + generate_simple_tree_dicts, + generate_tree_from_dicts, +) + + +@pytest.fixture(scope="module") +def block_gen(): + return BlockGenerator() + + +@pytest.fixture(scope="module") +def tree_from_dicts(): + yield generate_tree_from_dicts + + +@pytest.fixture(scope="module") +def random_tree(): + yield generate_random_tree + + +@pytest.fixture(scope="module") +def bin_layered_dicts(): + yield generate_binary_layered_tree_dicts + + +@pytest.fixture(scope="module") +def simple_dicts(): + yield generate_simple_tree_dicts + + +@pytest.fixture(scope="module") +def add_scores(): + yield append_scores diff --git a/tests/class_tests/test_block.py b/tests/class_tests/test_block.py new file mode 100644 index 0000000..7ebc8d2 --- /dev/null +++ b/tests/class_tests/test_block.py @@ -0,0 +1,186 @@ +# Copyright 2026 D-Wave +# +# 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. + +import os +from datetime import datetime + +import pytest + +CUR_DIR = os.path.dirname(os.path.realpath(__file__)) + +from src.structures.block import Block +from src.utilities.crypto_utils import calculate_hash + +NUM_BLOCKS = 5 +blocks = [] +hash_seed = "a1b2c3e4f5" +owner_keysets = [] +transactions = [] + + +def test_creation(): + """Partly intended to test basic initialization and locking behavior for Block class, + partly to create Block objects to support later tests. Only one explicit assert, but + Block class has internal validation logic that will throw errors if parts of the + initialization aren't working properly. + + Input: + transaction_gen: fixture for SingleSourceTransactionGenerator object (see data_generation + folder), which will generate Transaction objects with which to create the Blocks. + + Expected Output: + blocks: list of Block objects (length defined by NUM_BLOCKs), properly initialized, each + with one more transaction than the previous, as generated by the fixture""" + + timestamps = [] + for i in range(1, NUM_BLOCKS + 1): + nonce = i + if len(blocks) > 0: + prev_hash = blocks[-1].previous_hash + else: + prev_hash = "" + if i % 2 == 0: + next_timestamp = datetime.timestamp(datetime.now()) + timestamps.append(next_timestamp) + new_block = Block( + miner_id="test_miner", + previous_block_hash=prev_hash, + nonce=nonce, + timestamp=next_timestamp, + ) + else: + new_block = Block( + miner_id="test_miner", + previous_block_hash=prev_hash, + nonce=nonce, + ) + default_timestamp = new_block.timestamp + timestamps.append(default_timestamp) + lock_fails = [] + try: + new_block.lock() + lock_fails.append(1) + except: + pass + + # don't need an actual quantum hash for tests, a SHA256 over a short string will do fine + quantum_hash = calculate_hash(hash_seed[: min(i, len(hash_seed))]) + + new_block.set_quantum_hash(quantum_hash) + + try: + new_block.lock() + lock_fails.append(2) + except: + pass + + assert len(lock_fails) == 0, f"Block with previous hash {new_block.previous_hash} allowed \ + improper locking at steps {lock_fails}" + + new_block.set_hash() + new_block.lock() + assert new_block.locked, f"Block {i} did not lock properly." + assert new_block.quantum_hash == quantum_hash, f"Quantum hash for block {i} was \ + {new_block.quantum_hash} expected {quantum_hash}" + blocks.append(new_block) + + assert len(blocks) == NUM_BLOCKS, f"Wrong number of blocks. Expected \ + {NUM_BLOCKS}, received {len(blocks)}" + + for i in range(len(timestamps) - 1): + assert timestamps[i] < timestamps[i + 1], f"Timestamp {i} with value {timestamps[i]} and \ + timestamp {i+1} with value {timestamps[i+1]} out of order." + + +def test_validation(): + for idx, block in enumerate(blocks): + assert block.validate_hash(), f"Block {idx+1} failed to validate its hash." + assert block.current_quantum_hash, f"Block {idx+1} is missing quantum hash." + assert block.current_block_hash, f"Block {idx+1} is missing block hash" + + +def test_data_protection(): + """This function tests the data protection functionality of Block, specifically checking that + certain operations properly strip away previously-set quantum hashes and prevent the block hash + from being set until a current quantum hash is added back.""" + + for i in range(NUM_BLOCKS): + nonce = 2 * NUM_BLOCKS - i + + if len(blocks) > 0: + prev_hash = blocks[-1].previous_hash + else: + prev_hash = "" + + new_block = Block(miner_id="test_miner", previous_block_hash=prev_hash, nonce=nonce) + quantum_hash = calculate_hash(hash_seed[: -min(i, len(hash_seed) - 1)]) + new_block.set_quantum_hash(quantum_hash) + + assert new_block.current_quantum_hash, f"On check 1, Block {i+1} lacked quantum hash." + + try: + new_block.set_hash() + assert False, f"On check 1, Block {i+1} set block hash without current quantum hash." + except: + pass + + quantum_hash = calculate_hash(quantum_hash) + new_block.set_quantum_hash(quantum_hash) + assert new_block.current_quantum_hash, f"On check 2, block {i+1} lacked quantum hash." + new_block.nonce += 1 + assert new_block.current_quantum_hash == False + try: + new_block.set_hash() + assert False, f"On check 2, block {i+1} set block hash without current quantum hash." + except: + pass + + quantum_hash = calculate_hash(quantum_hash) + new_block.set_quantum_hash(quantum_hash) + new_block.set_hash() + assert new_block.current_block_hash, f"Block {i+1} lacked current hash after it was set." + new_block.lock() + assert new_block.locked, f"Block {i+1} didn't lock properly." + + +def test_io(tmpdir): + """This function tests the serialization and deserialization functions of Block. The from_json + method includes a check almost identical to the final assert which raises an Exception if it + fails, so unless something has gone very wrong, the test should either pass or stop executing + before the assert has a chance to fail. Either way, the hash calculation requires every single + bit in the original block and saved block to be identical, so any logic errors in Block's data + handling should break this test. + + Input: + blocks: list of Block objects created by previous test + Expected Output: + received_block: for each Block in blocks, received_block should be assigned + an exact copy of that block by writing to and then reading back + the block data from a file (created in its own temporary directory + by the tmpdir fixture). Each received block should exactly match + its original block in type and hash.""" + + block_files = [] + for idx, block in enumerate(blocks): + tempfile = tmpdir.mkdir(f"blockdir{idx}").join(f"block{idx}.txt") + block_files.append(tempfile) + block_data = block.to_json + with open(tempfile, "w") as f: + f.write(block_data) + + for i in range(len(block_files)): + with open(block_files[i], "r") as f: + received_block_data = f.read() + received_block = Block.from_json(received_block_data) + assert blocks[i] == received_block, "Received block and written block don't match" diff --git a/tests/class_tests/test_score_tree.py b/tests/class_tests/test_score_tree.py new file mode 100644 index 0000000..a9d1f7f --- /dev/null +++ b/tests/class_tests/test_score_tree.py @@ -0,0 +1,115 @@ +# Copyright 2026 D-Wave +# +# 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. + +import os +from pathlib import Path + +import pytest + +from src.structures.block_score_tree import BlockScoreTree + +CUR_DIR = os.path.dirname(os.path.realpath(__file__)) +test_dir = os.path.join(CUR_DIR, "score_tree_test") +if not os.path.exists(test_dir): + os.mkdir(test_dir) +else: + p = Path(test_dir) + for file in p.iterdir(): + if not os.path.isdir(file): + os.remove(file) + +trees = [] + + +def test_tree_write(tree_from_dicts, random_tree, bin_layered_dicts, simple_dicts, add_scores): + """Procedurally defines a variety of trees according the the functions in data_generation.tree_generation.py + Testing on a variety of different tree structures is more likely to expose situational issues with the code logic. + Writes a json-formatted copy of each try to the dedicated test directory--if tests fail it is often useful to examine + the structure of the tree responsible, as it can help pinpoint the issue.""" + + simple_trees = [ + tree_from_dicts(add_scores(simple_dicts())), + tree_from_dicts(simple_dicts(128, 16, 2)), + ] + bin_layered_trees = [ + tree_from_dicts(add_scores(bin_layered_dicts())), + tree_from_dicts(bin_layered_dicts(2, 6)), + tree_from_dicts(add_scores(bin_layered_dicts(8, 3))), + ] + random_trees = [random_tree(50), random_tree(100), random_tree(200), random_tree(400)] + + for tree in simple_trees: + trees.append(tree) + + for tree in bin_layered_trees: + trees.append(tree) + + for tree in random_trees: + trees.append(tree) + + for idx, tree in enumerate(trees): + tree_path = os.path.join(test_dir, f"test_tree{idx}.json") + tree.to_json_file(tree_path) + assert os.path.exists(tree_path), f"Tree file for tree {idx} wrote improperly" + + +def test_tree_structure(): + for idx, tree in enumerate(trees): + for branch in tree.branches: + if branch == tree.trunk: + assert branch.depth == 0, f"Trunk of tree {idx} depth is not 0" + assert ( + tree.get_trunk_join_index(branch) is None + ), f"Trunk of tree {idx} returned a join index for itself." + else: + assert ( + tree.get_trunk_join_index(branch) >= 0 + ), f"Branch rooted at {branch.root_hash} returned improper trunk join index for tree {idx}" + assert ( + branch.root.hash == branch.root_hash + ), "Root hash {branch.root_hash} doesn't match root block for tree {idx}." + depth_diff = branch.depth - branch.parent.depth + assert ( + depth_diff == 1 + ), f"Branch rooted at {branch.root_hash} depth differs from parent by {depth_diff} for tree {idx}" + for child in branch.children: + depth_diff = branch.depth - child.depth + assert ( + depth_diff == -1 + ), f"Branch rooted at {branch.root_hash} depth differs from child by {depth_diff} for tree {idx}" + assert ( + child.root.hash in branch.hash_to_index_lookup + ), "Child rooted at {child.root_hash} of branch rooted at {branch.root_hash} not rooted in branch for tree {idx}." + + +def test_tree_io(): + for idx, tree in enumerate(trees): + original_tree = tree + original_tree.to_json_file(os.path.join(test_dir, f"orig_tree{idx}.txt")) + reconstructed_tree = BlockScoreTree.from_json_file( + os.path.join(test_dir, f"test_tree{idx}.json") + ) + reconstructed_tree.to_text_file(os.path.join(test_dir, f"recon_tree{idx}.txt")) + assert ( + original_tree.high_score == reconstructed_tree.high_score + ), f"High scores don't match for tree {idx}." + assert len(original_tree.branches) == len( + reconstructed_tree.branches + ), f"Different numbers of branches for tree {idx}." + assert len(original_tree.trunk) == len( + reconstructed_tree.trunk + ), f"Trunks are different lengths for tree {idx}." + assert ( + original_tree.trunk.tip.hash == reconstructed_tree.trunk.tip.hash + ), "Trunks hold different values." diff --git a/tests/data_generation/__init__.py b/tests/data_generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data_generation/block_generation.py b/tests/data_generation/block_generation.py new file mode 100644 index 0000000..e74750a --- /dev/null +++ b/tests/data_generation/block_generation.py @@ -0,0 +1,50 @@ +import random +from datetime import datetime + +from src.structures.block import Block + + +class BlockGenerator: + """Uses a transaction generator to generate blocks for addition to + a blockchain. Can generate a list of sequential blocks with the + generate_chain method.""" + + def __init__(self): + + timestamp = datetime.timestamp(datetime.now()) + genesis_block = Block(miner_id="genesis", previous_block_hash="", timestamp=timestamp) + genesis_block.set_quantum_hash() + genesis_block.set_hash() + genesis_block.lock() + self.genesis_block = genesis_block + + def generate_block(self, previous_block=None): + + if previous_block: + prev_hash = previous_block.hash + else: + prev_hash = self.genesis_block.hash + + timestamp = datetime.timestamp(datetime.now()) + nonce = random.randint(1, 2**30) + new_block = Block( + miner_id="test_miner", previous_block_hash=prev_hash, timestamp=timestamp, nonce=nonce + ) + + new_block.set_quantum_hash() + new_block.set_hash() + new_block.lock() + + return new_block + + def generate_chain(self, num_blocks=2, initial_block=None): + blocks = [] + + prev_block = initial_block + + for i in range(num_blocks): + new_block = self.generate_block(prev_block) + blocks.append(new_block) + prev_block = new_block + + return blocks diff --git a/tests/data_generation/tree_generation.py b/tests/data_generation/tree_generation.py new file mode 100644 index 0000000..9d9aec1 --- /dev/null +++ b/tests/data_generation/tree_generation.py @@ -0,0 +1,223 @@ +import math +import os +import random +import sys + +sys.path.append(os.path.join("..", "..")) + +from src.structures.block_score_tree import BlockScoreTree + + +def dummy_hash(num: int): + """Returns a three-character string in a set sequence (depending only on the input number), of the form + A0a and cycling through all possible triples of that form (Capital Letter/digit/lowercase letter), for + a total of 6760 possible 'hash' values. This guarantees several thousand unique values while keeping them + short and well-ordered so to be convenient to read for debugging purposes. Will start to cause errors + if a tree of size 6761 or larger is tested (as hashes will repeat).""" + + cap_block = list(range(65, 91)) + digit_block = list(range(48, 58)) + lower_block = list(range(97, 123)) + return ( + chr(cap_block[num % 26]) + + chr(digit_block[((num + 1) // 26) % 10]) + + chr(lower_block[((num + 2) // 260) % 26]) + ) + + +def generate_simple_tree_dicts( + num_nodes: int = 256, num_primary_branches: int = 8, max_depth: int = 3 +): + """Generates data for a tree with a simple branching structure: each branch has exactly one child branch, up + to the limit defined by the max_depth parameter. Each child has half as many blocks as its parent. + If the number of blocks allocated exceeds num_nodes because of rounding issues or if the depth + is high enough that new branches would have 0 blocks, algorithm will stop prematurely, returning + the tree generated thus far. Branch placement on the parent branch is random.""" + + trunk_length = num_nodes // 2 + blocks_remaining = num_nodes - trunk_length + primary_branch_blocks = blocks_remaining // 2 + primary_branch_length = primary_branch_blocks // num_primary_branches + tree_data = [{"length": trunk_length}] + + for depth in range(max_depth): + for branch_num in range(1, num_primary_branches + 1): + length = primary_branch_length // (2**depth) + if length == 0 or blocks_remaining < length: + break + parent = depth * num_primary_branches + max(depth, 1) * branch_num + if length > 1: + root = random.randint(1, 2 * length - 2) + else: + root = 1 + tree_data.append({"parent": parent, "root": root, "length": length}) + blocks_remaining -= length + + return tree_data + + +def generate_binary_layered_tree_dicts(prim_branches: int = 4, num_layers: int = 5): + """Generates data for a tree with a trunk and a set number of primary branches. Each primary + branch and later will have exactly two branches, each with half the number of + blocks of the previous layer, up to num_layers total branches (including the trunk). + Branch size is chosen so that the bottom-level branches have exactly 2 blocks.""" + + tree_data = [] + prim_branches = 4 + num_layers = 5 + primary_branch_length = 2**num_layers + tree_data.append({"length": 2 * primary_branch_length}) + for j in range(prim_branches): + root = random.randint(0, 2 * primary_branch_length - 2) + tree_data.append({"parent": 0, "root": root, "length": primary_branch_length}) + + for i in range(1, num_layers): + length = primary_branch_length // (2**i) + for j in range( + prim_branches * (2 ** (i - 1)) - prim_branches + 1, + prim_branches * (2**i) - prim_branches + 1, + ): + parent = j + for k in range(2): + root = random.randint(0, 2 * length - 2) + tree_data.append({"parent": parent, "root": root, "length": length}) + + return tree_data + + +def append_scores( + tree_dict, low_score_prob: float = 0.3, high_score: float = 1.0, low_score: float = -1.0 +): + """Adds randomized scores to the dictionaries generated by the dictionary generation functions.""" + + for dict in tree_dict[1:]: + scores = [] + for i in range(dict["length"]): + score_roll = random.randint(1, 1000) + if score_roll <= int(low_score_prob * 1000): + score = low_score + else: + score = high_score + scores.append(score) + dict.update({"score": scores}) + + return tree_dict + + +def generate_tree_from_dicts(tree_dicts): + """Generates a BlockScoreTree object from a dictionary list generated by one of the dictionary-generation functions. + Adds blocks in a random order, but only to branches of the tree that are 'active' in the sense that their parent + branch exists and has been extended at least one block past their root block.""" + + for i in range(len(tree_dicts)): + tree_dicts[i].update({"branch number": i}) + + tree = BlockScoreTree() + num_nodes = 1 + genesis_hash = dummy_hash(num_nodes) + tree.add_block(genesis_hash, None, 1) + trunk_dict = tree_dicts[0] + trunk_dict.update({"branch": tree.trunk}) + active_branches = [trunk_dict] + waiting_branches = tree_dicts[1:] + num_active = 1 + + while num_active > 0: + next_branch_data = random.choice(active_branches) + if "branch" in next_branch_data: + prev_hash = next_branch_data["branch"].tip.hash + else: + prev_hash = next_branch_data.pop("root hash") + num_nodes += 1 + new_hash = dummy_hash(num_nodes) + block_score = 1 + if "scores" in next_branch_data: + if "branch" in next_branch_data: + if len(next_branch_data["scores"]) > len(next_branch_data["branch"]): + block_score = next_branch_data["scores"][len(next_branch_data["branch"])] + else: + block_score = next_branch_data["scores"][0] + tree.add_block(new_hash, prev_hash, block_score) + extended_branch = tree.hash_to_branch_lookup[new_hash] + next_branch_data.update({"branch": extended_branch}) + + for branch_dict in waiting_branches: + if branch_dict["parent"] == next_branch_data["branch number"]: + if len(extended_branch) > branch_dict["root"] + 1: + branch_dict.update({"root hash": prev_hash}) + active_branches.append(branch_dict) + waiting_branches.remove(branch_dict) + num_active += 1 + + if len(extended_branch) >= next_branch_data["length"]: + active_branches.remove(next_branch_data) + num_active -= 1 + + return tree + + +def generate_random_tree( + num_nodes: int, + branch_probability: float = 0.1, + branch_range: int = 2, + branch_end_prob=0.25, + earliest_branch: int = 1, +): + """Procedurally generates a random tree with the specified number of nodes. Each time a new + block is added, a branch among the active branches (which always include the trunk) is selected + at random. The block is either added to the end of that branch or (with probability defined by + branch_probability param), added as the start of a child branch of that branch, at a random + location. To ensure that the trunk is overall the dominant branch, each branch has a chance of + becoming inactive each time a block is added, at which point it will never be extended (though + its children might). + + Args: + num_nodes (int): how many BlockNodes will be added to the tree in total + branch_probability (float): the chance that a newly added block starts a new branch. + Increments of less than 0.01 do nothing in practice. + branch_range (int): controls how far back along the parent branch a new branch is likely to + form. Probability is a falling exponential: there's a 50% chance of a branch forming in + the first branch_range blocks, a 25% in the next branch_range blocks and so on. + branch_end_prob (float): Chance that a branch will become inactive each time a block is + added to it. This keeps the number of branches from growing too large. + earliest_branch (int): Lowest index a child branch can appear on a parent branch. + """ + + tree = BlockScoreTree() + + prev_hash = None + active_branches = [tree.trunk] + for i in range(num_nodes): + node_hash = dummy_hash(i) + active_branch_roll = random.randint(0, len(active_branches) - 1) + active_branch = active_branches[active_branch_roll] + unbranched = True + if len(active_branch) > earliest_branch: + branch_roll = random.randint(1, 100) + if branch_roll < branch_probability * 100: + unbranched = False + if not unbranched: # Find where to start the new branch + pred_idx = -len(active_branch) + while len(active_branch) + pred_idx < earliest_branch: + branch_loc_roll = random.randint(1, 2**10 - 1) + pred_idx = -math.ceil(branch_range * (10 - math.log2(branch_loc_roll))) + else: + pred_idx = -1 + + if len(active_branch) > 0: + prev_hash = active_branch[pred_idx].hash + else: + prev_hash = None + + tree.add_block(block_hash=node_hash, prev_block_hash=prev_hash, block_score=1.0) + + if not unbranched: + new_branch = tree.branches[-1] + active_branches.append(new_branch) + + if active_branch_roll > 0: + branch_end_roll = random.randint(1, 100) + if branch_end_roll < branch_end_prob * 100: + active_branches.pop(active_branch_roll) + + return tree diff --git a/tests/test_crypto_utils.py b/tests/test_crypto_utils.py new file mode 100644 index 0000000..4c1117e --- /dev/null +++ b/tests/test_crypto_utils.py @@ -0,0 +1,105 @@ +# Copyright 2026 D-Wave +# +# 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. + +import binascii + +from src.utilities import crypto_utils + + +def test_hash_functions(): + """Tests that the core hash function, SHA256, is working as it should. Specifically, this + test iterates twice over all sequential slices of a regular string, hashing the iteration and + testing that the outputs have the correct length, and match one another if and only if the + inputs used are identical.""" + + input_string = "abc123abc123abc" + for i_2 in range(len(input_string) - 1): + for i_1 in range(i_2, len(input_string)): + msg_1 = input_string[i_1:i_2] + hash_1 = crypto_utils.calculate_hash(msg_1) + assert len(hash_1) == 64, f"Received {hash_1}, hex length 64, received {len(hash_1)}" + + for j_2 in range(len(input_string) - 1): + for j_1 in range(j_2, len(input_string)): + msg_2 = input_string[j_1:j_2] + hash_2 = crypto_utils.calculate_hash(msg_2) + if msg_1 == msg_2: + assert hash_1 == hash_2, f"Expected equal hashes {hash_1} and {hash_2} \ + for {msg_1} and {msg_1}" + else: + assert hash_1 != hash_2, f"Expected unequal hashes {hash_1} and {hash_2} \ + for {msg_1} and {msg_1}." + + +def test_validate_zeroes(): + """Tests the validate zeroes function over several bit strings with varying numbers of + leading zeroes, ensuring both that they validate for values less than or equal to the + number of leading zeroes in the string and that they fail to validate for the next + higher value.""" + + val_1 = int.to_bytes(int("0000011101101011", 2), 2) # Five zeroes + val_2 = int.to_bytes(int("0000000010101110", 2), 2) # Eight zeroes + val_3 = int.to_bytes(int("0000000000011010", 2), 2) # Eleven zeroes + str_1 = binascii.hexlify(val_1).decode(encoding="utf-8") + str_2 = binascii.hexlify(val_2).decode(encoding="utf-8") + str_3 = binascii.hexlify(val_3).decode(encoding="utf-8") + + for i in range(6): + assert crypto_utils.validate_zeroes( + str_1, i + ), f"Failed to validate with values {str_1} for {i} zeroes" + + for i in range(9): + assert crypto_utils.validate_zeroes( + str_2, i + ), f"Failed to validate with values {str_2} for {i} zeroes" + + for i in range(12): + assert crypto_utils.validate_zeroes( + str_3, i + ), f"Failed to validate with values {str_3} for {i} zeroes" + + assert not crypto_utils.validate_zeroes( + str_1, 6 + ), f"Erroneously validated for values {str_1} and 6 zeroes." + assert not crypto_utils.validate_zeroes( + str_1, 9 + ), f"Erroneously validated for values {str_2} and 9 zeroes." + assert not crypto_utils.validate_zeroes( + str_1, 12 + ), f"Erroneously validated for values {str_3} and 12 zeroes." + + +def test_compare_hashes(): + """Tests the compare hashes function, to ensure that it correctly identifies the matching and non-matching + bits between a pair of similar bitstrings.""" + + bytes_1 = int.to_bytes(int("000000000000111111111111", 2), length=3) # 12 0s followed by 12 1s. + bytes_2 = int.to_bytes( + int("001001001001110110110110", 2), length=3 + ) # Every 3rd digit should be different + hash_1 = binascii.hexlify(bytes_1).decode(encoding="utf-8") + hash_2 = binascii.hexlify(bytes_2).decode(encoding="utf-8") + assert len(hash_1) == 6, print( + f"Test hash had length {len(hash_1)}, expected 6" + ) # Make sure our encoding worked as expected, otherwise + assert len(hash_2) == 6, print( + f"Test hash had length {len(hash_2)}, expected 6" + ) # nothing else will work + for i in range(6, 1, -2): + bit_comparison = crypto_utils.compare_hashes(hash_1[:i], hash_2[:i]) + for j in range(3, 4 * i, 3): + assert bit_comparison[j - 1] == 0 + assert bit_comparison[j - 2] == 1 + assert bit_comparison[j - 3] == 1 diff --git a/tests/test_hash_calculator.py b/tests/test_hash_calculator.py new file mode 100644 index 0000000..1b2abb2 --- /dev/null +++ b/tests/test_hash_calculator.py @@ -0,0 +1,113 @@ +# Copyright 2026 D-Wave +# +# 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. + +import dimod +import numpy as np +import pytest +from dwave.system import DWaveSampler + +from src.protocols.hash_calculator import ( + QuantumHashSolver, + SimulatedHashSolver, + SolverName, + initialize_solver, +) + +client_supported_solver_name = None + +for sv in SolverName: + try: + DWaveSampler(solver=sv.value) + client_supported_solver_name = sv.value + print(sv.value) + break + except: + pass + + +def test_SolverName(): + assert "Advantage" in SolverName.SOLVER1.value + assert "simulated" in SolverName.SIMULATED1.value + assert SolverName.SOLVER1.name == "SOLVER1" + assert SolverName.SOLVER1 in SolverName + + +def test_initialize_solver_simulated(): + initialize_solver(solver_name=SolverName.SIMULATED1.value) + pass + + +def test_SimulatedHashSolver(): + mean_witnesses = np.array([0, 1]) + var_witnesses = np.array([0, 1e-6]) # Exactly 0, and close to 1 + bhs1 = SimulatedHashSolver(mean_witnesses=mean_witnesses) + hash_length = 100 + str_id, hash1 = bhs1.calculate_quantum_hash(hash_length, rng_seed=0) + print(str_id) + assert hash1.size == hash_length + assert np.array_equal( + np.unique(hash1), np.arange(2) + ), "0. and 1. should be only resampled values. Both occurring with high probability" + + bhs2 = SimulatedHashSolver( + mean_witnesses=mean_witnesses, var_witnesses=var_witnesses, var_rescaling_factor=0.0 + ) + _, hash2a = bhs2.calculate_quantum_hash(hash_length, rng_seed=0) + assert np.array_equal(hash1, hash2a), "Same seed, but not same result" + _, hash2b = bhs2.calculate_quantum_hash(hash_length, rng_seed=1) + assert not np.array_equal(hash2a, hash2b), "Different seed, same result" + + bhs3 = SimulatedHashSolver(mean_witnesses=mean_witnesses, var_witnesses=var_witnesses) + # With high probability, all values bigger than 1, 1 absent and 0. present. + _, hash3 = bhs3.calculate_quantum_hash(hash_length, rng_seed=2) + assert np.all(hash3 >= 0), "Values are 0. and 1 + small random, shouldn't be negative numbers" + assert np.any(hash3 == 0.0), "Value 0. should be present with high probability" + assert not np.any(hash3 == 1.0), "Value 1. should be absent with high probability" + + +def test_QuantumHashSolver(): + # Instantiation at defaults with client, exercising the default directory + # structure is already tested, here we use a MockSampler + sampler = dimod.RandomSampler() + sampler_kwargs = {"num_reads": 100} + qhs = QuantumHashSolver( + sampler=sampler, + energy_time_rescaling=(1.0, 1.0), + embedding_directory="./", + sampler_kwargs=sampler_kwargs, + ) + assert qhs.solver_parameters.solver_name == None + assert qhs.solver_parameters.profile == None + for hash_length in [32, 64]: + ascii_hash, qhs_hash = qhs.calculate_quantum_hash(hash_length=hash_length, rng_seed=0) + assert len(qhs_hash) == hash_length, "ascii_hash should be ceil(hash_length /4)" + assert len(ascii_hash) * 4 == hash_length, "ascii_hash should be ceil(hash_length /4)" + + +@pytest.mark.skipif(client_supported_solver_name is None, reason="supported QPU client unavailable") +def test_initialize_solver_qpu_client(): + qhs = initialize_solver(solver_name=client_supported_solver_name) + hash_length = 1024 + _, qhs_hash1a = qhs.calculate_quantum_hash(hash_length=hash_length, rng_seed=0) + _, qhs_hash1b = qhs.calculate_quantum_hash(hash_length=hash_length, rng_seed=0) + _, qhs_hash2 = qhs.calculate_quantum_hash(hash_length=hash_length, rng_seed=1) + overlap11 = qhs_hash1a @ qhs_hash1b + overlap12a = qhs_hash1a @ qhs_hash2 + overlap12b = qhs_hash1b @ qhs_hash2 + assert ( + overlap11 > overlap12a + ), "Verification for the same problem, should be better than for random pairs, with high probability" + assert ( + overlap11 > overlap12b + ), "Verification for the same problem, should be better than for random pairs, with high probability" diff --git a/tests/test_quantum_cubic_utils.py b/tests/test_quantum_cubic_utils.py new file mode 100644 index 0000000..64e0407 --- /dev/null +++ b/tests/test_quantum_cubic_utils.py @@ -0,0 +1,172 @@ +# Copyright 2026 D-Wave +# +# 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. + +import dimod +from dwave.system import DWaveSampler +from dwave.system.testing import MockDWaveSampler + +from src.utilities.quantum_cubic_utils import ( + build_stats, + create_lattice, + create_model, + dimerize_coupling_3d, + displace_n_by_c, + generate_default_sampler, + get_embeddings, + get_embeddings_filename, + source_dimer_orientation, + target_dimer_orientation, +) + +try: + qpu_client = DWaveSampler() + client_unavailable = False +except: + client_unavailable = True + + +def test_get_embeddings_filename(): + edge_list_source = [(0, 1), (1, 2)] # source nodes are ints. + edge_list_target = [(-1, -2)] + [ + (n1 + 3 * i, n2 + 3 * i) for i in range(2) for n1, n2 in edge_list_source + ] # Target nodes are ints + embeddings_filename = get_embeddings_filename( + edge_list_source=edge_list_source, + edge_list_target=edge_list_target, + embedding_directory="./", + ) + assert ( + embeddings_filename + == "./emb_S4abb167c7ffc10734f8b864460b50c6a8127177cc2edf0fdd3de9b2cd1960b57_T17bdefd0180e09b59c098e35bb9fc510355f075d9b06a3dad0f94dec3e05cb9f.pkl" + ) + + +def test_get_embeddings(): + # Improvement, test some graph known to be in the repo and verify embeddings. + edge_list_source = [(0, 1), (1, 2)] # source nodes are ints. + edge_list_target = [(-1, -2)] + [ + (n1 + 3 * i, n2 + 3 * i) for i in range(2) for n1, n2 in edge_list_source + ] # Target nodes are ints + # There are only 2 viable disjoint embeddings: + # e.g. {i: i for i in range(3)} or {i: 3+i for i in range(3)} + # or reflections thereof + embs = get_embeddings( + edge_list_source=edge_list_source, + edge_list_target=edge_list_target, + embedding_directory="./", # absent + embedding_timeout=10, # Plenty of time. + max_num_emb=None, + save_to_cache=False, + verify_embeddings=True, + ) + assert len(embs) == 2 + assert all(len(emb) == 3 for emb in embs) + assert set(v[0] for v in embs[0].values()) == set(range(3)) or set( + v[0] for v in embs[1].values() + ) == set(range(3)) + embs2 = get_embeddings( + edge_list_source=edge_list_source, + edge_list_target=edge_list_target, + embedding_directory="./", # absent + embedding_timeout=0, + max_num_emb=0, + save_to_cache=True, + verify_embeddings=True, + ) # Loads successfully + assert all(e1 == e2 for e1, e2 in zip(embs, embs2)) + + +def test_dimerize_coupling_3d(): + for z_parity in [0, 1]: + e = dimerize_coupling_3d((0, 0, 1), (1, 0, 0), z_parity=z_parity, lattice_dims=(2, 2, 2)) + assert all(len(n) == 4 for n in e) + + +def test_displace_n_by_c(): + n = (0, 1, 2, 3) + c = (1, 1, 3, 0) + modulo = (2, 2, 4, 4) + n2 = displace_n_by_c(n, c, modulo) + assert n2 == (1, 0, 1, 3) + + +def test_create_lattice(): + node_list, edge_list = create_lattice(lattice_dims=(4, 4), dim_periodicity=(False, False)) + for L in [3, 4]: + for dim_periodicity in [(False, False, False), (False, False, True)]: + node_list, edge_list = create_lattice( + lattice_dims=(L, L, L), + dim_periodicity=dim_periodicity, + ) + node_list, edge_list = create_lattice( + lattice_dims=(L, L, L), + dim_periodicity=dim_periodicity, + ) + + assert len(node_list) == L * L * L * 2 + simple_num_edges = 3 * L * L * (L - 1) + int(dim_periodicity[2]) * L * L + assert ( + len(edge_list) == simple_num_edges + L**3 + ), "Expected simple-lattice edges plus one additional edge per dimer" + + +def test_create_model(): + h, J = create_model(seed=10) + assert type(h) is dict + assert type(J) is dict + h2, J2 = create_model(seed=10) + + assert len(list(h2.keys())[0]) == 4, "dimerized cubic" + assert all(n == n1 for n, n1 in zip(h.items(), h2.items())) + assert all(e == e1 for e, e1 in zip(J.items(), J2.items())) + + +def test_build_stats(): + sampler = dimod.ExactSolver() + response = sampler.sample_ising({i: 1 for i in range(3)}, {}) + + for edge_list in [[(0, 1)], list((i, j) for i in range(3) for j in range(i))]: + corrs = build_stats(response, edge_list=edge_list) + assert all(0 == c for c in corrs) + + +def test_target_dimer_orientation(): + # First qubit is always vertical, final qubit is always horizontal + # defect free cases + for topology_type in ["chimera", "pegasus", "zephyr"]: + qpu = MockDWaveSampler(topology_type=topology_type) + orientations = target_dimer_orientation(qpu) + assert orientations[qpu.nodelist[0]] == "vertical" + assert orientations[qpu.nodelist[-1]] == "horizontal" + + +def test_source_dimer_orientation(): + int_to_str = {0: "vertical", 1: "horizontal"} + for args in [[], [(4, 4), (True, False)]]: + node_list, _ = create_lattice(*args) + orientations = source_dimer_orientation(node_list) + assert all( + int_to_str[k[-1]] == v for k, v in orientations.items() + ), "dimer (final) index does not match orientation" + + +def test_generate_default_sampler(): + source_edge_list = [((0, 0, 0, 0), (0, 0, 0, 1))] + t = 4 + qpu = MockDWaveSampler(topology_type="chimera", topology_shape=[1, 1, t]) + sampler = generate_default_sampler( + source_edge_list, qpu=qpu, embedding_timeout=10 # To avoid QPU default + ) # find embeddings + # This is correct for automorphism_per_embedding = True, otherwise, need to test len(sampler.child.embeddings) == t + assert len(sampler.embeddings) == t