HQS Quantum Simulations integration
HQS maintains a stack of small, sharply-scoped packages rather than one framework, and qpubench mirrors four of them:
| Package | Schema module | What it is |
|---|---|---|
| struqture | schemas/mirrors/hqs_struqture.py |
A typed serialisation format for operators, Hamiltonians and open quantum systems |
| qoqo / roqoqo | schemas/mirrors/hqs_qoqo.py |
Circuit representation and measurement post-processing — no transpiler, no algorithms |
| qoqo_qasm | schemas/mirrors/hqs_qoqo_qasm.py |
qoqo ⇄ OpenQASM translation, and its dialects |
| ActiveSpaceFinder | schemas/mirrors/hqs_active_space_finder.py |
Automatic active-space selection for CASSCF / NISQ chemistry |
The stack composes: ASF picks an active space, struqture holds the resulting fermionic Hamiltonian and maps it to qubits, qoqo runs a circuit measuring it, and qoqo_qasm exports that circuit. A qpubench record can now carry every step of that chain.
None of these are imported by qpubench — as everywhere in the schema layer, these are data-shape bridges only.
struqture — operator algebra with provenance
struqture is not a simulator. A struqture object is a sparse map from an index (a normal-ordered product of single-site operators) to a coefficient, plus type information saying which algebra the index lives in. Four algebras (spins, bosons, fermions, mixed), each with the same stack: Product → Operator → Hamiltonian → LindbladNoiseOperator / LindbladOpenSystem.
Why it earns a module
SparsePauliObservable covers exactly one cell of that grid — qubit operators
with numeric coefficients — because that is all a benchmark measures. Two
struqture capabilities have no representation anywhere else in qpubench:
Second-quantised operators before a qubit mapping is chosen. A
SparsePauliObservable is the output of Jordan-Wigner; recording only it
throws away what produced it. VQAConfig.mapper names the mapping but has
nothing to name it on:
from qpubench.schemas import (
StruqtureMappedHamiltonian, StruqtureMapping, StruqtureOperator,
StruqtureSerialisationMeta, StruqtureTerm, StruqtureType, StruqtureValue,
StruqtureAlgebra, ModeProductSpec, SparsePauliObservable,
)
fermionic = StruqtureOperator(
meta=StruqtureSerialisationMeta(type_name=StruqtureType.FERMION_HAMILTONIAN.value),
algebra=StruqtureAlgebra.FERMIONS,
terms=[
StruqtureTerm(
mode_product=ModeProductSpec(creators=(0,), annihilators=(0,)),
coefficient=StruqtureValue.from_float(-1.25),
),
],
)
record = StruqtureMappedHamiltonian(
source=fermionic,
mapping=StruqtureMapping.JORDAN_WIGNER,
mapped=StruqtureOperator.from_sparse_pauli_observable(qubit_hamiltonian),
)
Now “the same molecule under Jordan-Wigner” is a checkable claim rather than a
label. The validator rejects a spin operator as source (there is no mapping
to record) and a non-spin operator as mapped.
Lindblad dissipators as data. A LindbladNoiseOperator is keyed by an
ordered pair of products (L_i, L_j) with rate M_ij — the double-index
structure of the master equation:
dρ/dt = Σ_ij M_ij ( L_i ρ L_j† − ½ { L_j† L_i , ρ } )
No core qpubench type has that shape. The diagonal entries are the familiar
per-channel rates; the off-diagonal ones carry coherences between decay
channels that a flat rate list cannot express. has_off_diagonal_rates exists
so a consumer can see what it is about to drop:
if noise.has_off_diagonal_rates:
... # a channel-list simulator will silently lose these
Symbolic coefficients
struqture coefficients are CalculatorComplex: a number or an expression
string. A parametric Hamiltonian is a first-class struqture object.
StruqtureValue carries both cases, and to_sparse_pauli_observable() raises
rather than inventing a value for an unbound parameter:
StruqtureValue.from_float(0.5) # numeric
StruqtureValue(symbolic_re="2 * theta") # free parameter
Bases that look alike and are not
Three single-spin bases appear in struqture and they are not interchangeable:
| Type | Basis | Used by |
|---|---|---|
PauliProductSpec |
X, Y, Z | Hamiltonians; converts losslessly with PauliTerm |
DecoherenceProductSpec |
X, iY, Z | Lindblad noise — the real-valued basis that keeps rate matrices real |
PlusMinusProductSpec |
σ⁺, σ⁻, Z | qoqo’s ContinuousDecoherenceModel, where damping and excitation are single terms |
Converting a DecoherenceProduct to a Pauli string introduces a factor of i
per iY. The types are kept separate so that conversion has to be deliberate.
Versioning — worth stealing
Every serialised struqture object embeds {type_name, min_version, version}
and refuses a load when the reader is older than the writer’s declared
minimum. That is a per-type contract: adding a field to one type does not
invalidate stored payloads of every other type, which a single library-wide
version number cannot express.
meta.can_be_read_by((2, 1, 0)) # applies struqture's own compatibility rule
struqture 2.0 renamed the 1.x *System types (SpinSystem → PauliOperator,
…) and dropped the fixed particle-count layer. STRUQTURE_1_TO_2_NAMES maps
the old names for records written against 1.x.
qoqo — the measurement is part of the program
qoqo represents circuits and the classical post-processing that turns raw readout into expectation values, and explicitly nothing else: no transpiler, no optimiser, no algorithm library.
The post-processing rule
CircuitSpec.observables says what to measure. Nothing in qpubench says how
counts become that number — a record shows ShotResult.counts and
ExpectationResult.value with no stated relationship between them. qoqo makes
it explicit:
from qpubench.schemas import (
QoqoExpectationRule, QoqoPauliProductMask, QoqoPauliZProductInput,
)
measurement_input = QoqoPauliZProductInput(
number_qubits=2,
use_flipped_measurement=True,
pauli_products=[
QoqoPauliProductMask(readout="ro_z", index=0, qubit_mask=[0]),
QoqoPauliProductMask(readout="ro_z", index=1, qubit_mask=[0, 1]),
],
expectation_rules=[
QoqoExpectationRule(name="energy", linear={0: -0.47, 1: 0.18}),
],
)
measurement_input.num_circuits_per_evaluation # 2 — flipped doubles it
After the basis-rotation circuit runs, every observable is diagonal, so a
Pauli product is just the set of measured bits XORed together — that set is
qubit_mask. linear is the combination that produces the energy. The
validator rejects a rule referencing a product that was never measured, which
is a silently wrong energy rather than a missing one.
use_flipped_measurement is worth noting: it cancels readout asymmetry
(p(1|0) ≠ p(0|1)) by running each basis twice with all qubits flipped. That is
error mitigation expressed as measurement structure, which is why it has no
ErrorMitigationStrategy value — and why the doubled circuit count belongs in
any comparison against a package that does not do it.
PRAGMAs are positioned in the circuit
qoqo puts simulator- and hardware-specific directives in the circuit as
first-class operations. qpubench splits the same information between
ExecutionOptions and the QASM string, which loses ordering: “damp for 20 µs
after gate 4” is not an execution option.
QoqoPragma enumerates all 27, grouped by what they do — readout, state
preparation, noise, control flow, scheduling. QoqoCircuitSpec.pragmas
returns the ones a circuit uses, in order.
Note that QoqoCircuitSpec.num_qubits is derived: a qoqo Circuit declares no
width, so its size is whatever its operations imply.
Devices: time-based, not error-rate-based
QoqoDeviceSpec stores gate durations plus a per-qubit 3×3 Lindblad rate
matrix over (σ⁺, σ⁻, σᶻ), and lets noise follow from their product. That is a
physically stronger statement than an error-rate table — it predicts the error
of a gate the device was never characterised on.
to_backend_spec() projects onto BackendSpec with T1 = 1/M₀₀ and
T2 = 1/M₂₂, and leaves error_rate unset rather than inventing one:
backend = device.to_backend_spec()
backend.qubit_t1(0) # 1 / damping rate
backend.gate_error("CNOT", (0, 1)) # None — qoqo has no error rates
Keep the QoqoDeviceSpec alongside the projection: off-diagonal M_ij has no
BackendSpec representation and is dropped.
Noise models, by when they apply
QoqoNoiseModelType distinguishes continuous / on-gate / on-idle / at-readout
/ overrotation. That axis is missing from qpubench’s core entirely:
BackendSpec records characteristics a device has, with no way to say a
simulation should apply them.
QoqoOverrotationDescription is the one that does not fit a rate at all — a
miscalibrated rotation is a coherent error that accumulates over repetitions
instead of averaging out, and no Lindblad rate expresses it.
The Lindblad operator behind a decoherence model is stored as a plain dict so this mirror does not import another mirror; rehydrate it explicitly:
from qpubench.schemas import StruqtureNoiseOperator
StruqtureNoiseOperator.model_validate(model.lindblad_noise)
QuantumProgram
QoqoQuantumProgramSpec is a measurement plus the ordered names its free
parameters are substituted from — qoqo’s answer to “what is the callable unit
of quantum work”. The ordering is the load-bearing part:
circuit.ParameterBinding is name-keyed and therefore order-free, which
cannot express “the same function, called with a different vector” — the shape
every variational outer loop has.
qoqo_qasm — “OpenQASM 3.0” is not one format
roqoqo_qasm::QasmVersion is a version and a dialect:
QasmDialect |
Portable? | Pragmas? |
|---|---|---|
2.0Vanilla |
yes | — |
2.0Qulacs |
no — omits gate definitions | — |
3.0Vanilla |
yes | dropped |
3.0Roqoqo |
no — roqoqo pragma syntax | kept |
3.0Braket |
no — Braket pragma syntax | kept |
CircuitFormat has QASM2 and QASM3 and stops there, so two records can
both claim format=QASM3 while one is portable and the other only loads under
Braket. The enum values are the exact strings upstream parses, so a stored
config is handed back verbatim.
QoqoQasmTranslationResult separates two failure modes that are easy to
conflate:
result.succeeded # False if any operation had no QASM equivalent —
# qoqo_qasm refuses rather than emitting a partial circuit
result.is_faithful # False if it succeeded but dropped noise pragmas —
# a valid circuit describing a different experiment
A run whose PragmaDamping silently vanished into a Vanilla dialect produced
noiseless numbers from what was recorded as a noisy program. That is the exact
class of error a benchmark record exists to catch.
ActiveSpaceFinder — the selection, and the orbitals it refers to
ASF answers the question every quantum-chemistry benchmark must answer and almost none record: which orbitals are active, and why?
SCF (UHF, even for singlets — broken symmetry exposes the correlation)
→ MP2 natural orbitals (pick a tractable initial subspace)
→ cheap screening DMRG (maxM ≈ 500; entropies, not energies)
→ entropy + cumulant analysis → several candidate spaces
The MO-basis trap
ASF’s own documentation is blunt: do not combine the returned MO indices with
orbitals from anywhere else. The MP2-natural-orbital step reorders and remixes
the MOs, so index 7 in an ASF result is not index 7 in the preceding SCF.
ASFActiveSpace is the only active-space type in qpubench that carries the
link:
from qpubench.schemas import ASFActiveSpace
space = ASFActiveSpace(nel=6, mo_list=[3, 4, 5, 6, 7, 8], mo_coeff_ref="chk:mp2no")
space.cas_label # "CAS(6,6)"
space.num_qubits_jordan_wigner # 12
space.to_gsopt_active_space() # index-only form — drops mo_coeff_ref
mo_coeff itself (an AO×MO matrix — hundreds of thousands of floats for a
real molecule) is not stored inline; mo_coeff_ref is a path, checkpoint
key or content hash, and mo_coeff_shape lets a reader sanity-check that the
reference resolves to the right thing. This is the same choice
molssi_qcschema makes for wavefunction data.
Four active-space types, one per pipeline stage
They are not redundant:
| Where | Type | What it holds |
|---|---|---|
| record level | VQAConfig.active_electrons / .active_orbitals |
the two numbers, for cross-run filtering |
| chosen space | bestquark_gsopt.ActiveSpaceSpec |
occupied + active MO indices |
| one selector | microsoft_qdk.ActiveSpaceSelectionConfig / …Result |
spin-resolved alpha/beta indices |
| selection provenance | hqs_active_space_finder.ASFActiveSpace |
indices plus the MO basis they refer to |
Recording the choice, not just the answer
The distinguishing ASF feature is returning several candidates.
ASFSelectionResult stores what the chosen space was chosen over:
from qpubench.schemas import ASFSelectionConfig, ASFSelectionMode
# "give me the best 8 orbitals" — the usual NISQ framing, size is an input
config = ASFSelectionConfig(mode=ASFSelectionMode.SIZED, requested_size=8)
# "what does the molecule need?" — size is an output
config = ASFSelectionConfig(mode=ASFSelectionMode.ENTROPY, entropy_threshold=0.14)
ASFCandidateSpace.max_entropy_excluded is the diagnostic that matters when
choosing: the highest single-orbital entropy left out. A large value means
the candidate cuts through correlated orbitals and is probably too small.
The default entropy_threshold is -0.1 · ln(0.25) ≈ 0.1386 — a natural-log
entropy scale, not a tuned constant. It is the single most consequential knob,
and the one a bare “CAS(6,6)” hides.
Installation
pip install struqture-py qoqo qoqo_qasm # HQS stack
pip install . # ActiveSpaceFinder, from source;
# then ./init_dmrgscf_settings.sh
ActiveSpaceFinder needs Python 3.9–3.11 (Block2 is not packaged for newer),
and PySCF’s dmrgscf plugin needs a config file — the repo ships
init_dmrgscf_settings.sh to write it. qpubench’s schema modules have no such
constraint: they are Pydantic models and import on any supported Python with
none of these packages installed.
Reference
Full type tables: schemas.md — hqs_struqture,
hqs_qoqo, hqs_qoqo_qasm, hqs_active_space_finder.