QPUBench

Fragmentation: decomposing the problem

Fragmentation makes an intractable molecule tractable by splitting it into subsystems, solving each one, and recombining. qpubench models this with one general schema plus two project mirrors:

Module Upstream Role
qpubench.schemas.catalogs.fragmentation n/a (framework-general) The shared vocabulary every method reduces to
qpubench.schemas.mirrors.fragmentqc_fragment fragment-qc/fragment MBE / GMBE via PIE trees, multilevel layers, adaptive screening
qpubench.schemas.mirrors.qiskitcommunity_fragment_methods qiskit-community/quantum-fragment-methods EWF embedding with per-fragment quantum solvers

Neither upstream package is a dependency. These are Pydantic v2 mirrors; pytest tests/ passes with pip install . alone.

The shared abstraction

Every fragmentation method (MBE, GMBE, DMET, EWF, ONIOM) is:

  1. a set of fragments, in real space or orbital space;
  2. an expansion: (fragment, signed coefficient) terms whose weighted sum reconstructs a supersystem property;
  3. a solver per fragment, classical or quantum;
  4. optionally screening rules that drop terms below a threshold (the adaptive part) and layers solving different orders at different levels of theory (the multilevel part).

FragmentExpansionTerm is deliberately the most general form: an arbitrary real coefficient on an arbitrary fragment. A 2-body MBE, a generalized MBE over overlapping fragments and an ONIOM subtractive scheme all fit without a method-specific schema.

from qpubench.schemas.catalogs.fragmentation import (
    FragmentationScheme, FragmentationSpec, FragmentExpansionTerm, FragmentSpec,
)

spec = FragmentationSpec(
    name="water-trimer-2body",
    scheme=FragmentationScheme.MBE,
    max_order=2,
    basis="cc-pVDZ",
    fragments=[
        FragmentSpec(fragment_id=f"m{i}", order=1, atom_indices=[3 * i, 3 * i + 1, 3 * i + 2])
        for i in range(3)
    ] + [
        FragmentSpec(fragment_id="d01", order=2, primary_ids=["m0", "m1"]),
        FragmentSpec(fragment_id="d02", order=2, primary_ids=["m0", "m2"]),
        FragmentSpec(fragment_id="d12", order=2, primary_ids=["m1", "m2"]),
    ],
    expansion=[
        *(FragmentExpansionTerm(fragment_id=f"m{i}", coefficient=-1.0, order=1) for i in range(3)),
        FragmentExpansionTerm(fragment_id="d01", coefficient=1.0, order=2),
        FragmentExpansionTerm(fragment_id="d02", coefficient=1.0, order=2),
        FragmentExpansionTerm(fragment_id="d12", coefficient=1.0, order=2),
    ],
)

spec.coefficient_sum      # 0.0 ... wait, see below
spec.terms_by_order()     # {1: 3, 2: 3}

Completeness is a diagnostic, not a constraint

A valid expansion covering the whole supersystem has coefficients summing to 1 (every atom counted exactly once). For a 3-monomer 2-body MBE the monomer coefficient is -(n-2) = -1, giving 3·(-1) + 3·(+1) = 0, which is correct for the interaction energy, not the total. Use coefficient_sum and is_complete() to see which of the two you built:

spec.is_complete()        # False -> this expansion is not covering the supersystem

A shortfall on a run that should be complete usually means screening dropped terms, which is expected for an adaptive run. That is why it is reported rather than enforced.

Adaptive screening

Screening is what makes high-order expansions tractable: terms whose metric falls outside the threshold are never submitted. Thresholds are keyed by n-body order because screening almost always tightens with order.

from qpubench.schemas.catalogs.fragmentation import FragmentScreeningRule, ScreeningMetric

rule = FragmentScreeningRule(
    name="energy_trimming",
    metric=ScreeningMetric.ENERGY_DELTA,
    thresholds={2: 1e-4, 3: 1e-5, 4: 1e-6},
    backend="xtb",                    # the *cheap* estimator
)
rule.threshold_for(3)                 # 1e-05
rule.threshold_for(9)                 # None (falls back to `cutoff`)

Screening is only worthwhile when the estimator is far cheaper than the solver it protects; hence the explicit backend field.

Multilevel layers

Layers stack a high-accuracy method on a small fragment set over a cheaper one on a larger set, with sign=-1.0 on the subtractive layer so the cheap contribution inside the accurate region cancels.

from qpubench.schemas.catalogs.fragmentation import FragmentationLayer, SolverKind

layers = [
    FragmentationLayer(level=0, max_order=4, method="mp2",     basis="cc-pVDZ"),
    FragmentationLayer(level=1, max_order=2, method="ccsd(t)", basis="cc-pVTZ", sign=-1.0),
    FragmentationLayer(level=2, max_order=2, method="sqd",     solver_kind=SolverKind.QUANTUM),
]

Solver assignment

Rules are tried highest priority first; the first match wins. A quantum solver claims the fragments that fit the QPU, a classical fallback catches the rest.

from qpubench.schemas.catalogs.fragmentation import FragmentSolverAssignment, SolverKind

spec.solver_rules = [
    FragmentSolverAssignment(
        solver_name="sqd", solver_kind=SolverKind.QUANTUM,
        priority=10, max_qubits=48, backend_name="ibm_pittsburgh",
    ),
    FragmentSolverAssignment(solver_name="ccsd", priority=0, condition="default fallback"),
]

spec.assign_solver(spec.fragments[0]).solver_name   # "sqd"
spec.quantum_fragments()                            # fragments headed for the QPU
spec.max_fragment_qubits                            # the QPU width this plan needs

matches() only evaluates the machine-checkable max_* limits; condition is documentation. A limit compared against an unset fragment field does not reject, so a fragment with no num_qubits estimate is not excluded by max_qubits.

Fragme∩t (fragmentqc_fragment)

Fragme∩t is driven by a declarative strategy.yaml. The mirror’s field names match it verbatim, so a real file round-trips:

import yaml
from qpubench.schemas.mirrors.fragmentqc_fragment import FragmentStrategy

strategy = FragmentStrategy.model_validate(yaml.safe_load(open("strategy.yaml")))
spec = strategy.to_fragmentation_spec("my_calculation")
spec.is_multilevel, spec.is_adaptive

The idea worth importing wholesale is the PIE tree: nodes keyed by a set of primary indices, each with an integer coefficient. Every expansion is the same object; only the coefficients differ.

from qpubench.schemas.mirrors.fragmentqc_fragment import FragmentPIENode, FragmentPIETree

tree = FragmentPIETree(
    primaries=[[0, 1, 2], [3, 4, 5], [6, 7, 8]],
    nodes=[
        FragmentPIENode(key=[0, 1], coefficient=1),
        FragmentPIENode(key=[0, 2], coefficient=1),
        FragmentPIENode(key=[1, 2], coefficient=1),
        FragmentPIENode(key=[0],    coefficient=-1),
        FragmentPIENode(key=[1],    coefficient=-1),
        FragmentPIENode(key=[2],    coefficient=-1),
    ],
    order=2,
)
tree.to_expansion()     # -> list[FragmentExpansionTerm]
tree.to_fragments()     # -> list[FragmentSpec], atoms unioned from the primaries

Nodes with coefficient == 0 are structural: they exist so their children’s overlaps resolve, and are never submitted. nonzero_nodes is what actually runs.

Mods map to screening rules where they are screening rules:

mod.to_screening_rule()      # -> FragmentScreeningRule, or None

Basis mods (UseSupersystemBasis, ClusterBasis, …) and MIC mods change how a term is computed, not whether it is, so they return None; map over every mod and filter.

Upstream’s own fragment/schemas/ models are Pydantic v1 (pydantic.validator, orm_mode). These are independent v2 mirrors.

quantum-fragment-methods (qiskitcommunity_fragment_methods)

The mirror that connects fragmentation to a QPU. Load a real config.yaml:

import yaml
from qpubench.schemas.mirrors.qiskitcommunity_fragment_methods import QFMWorkflowConfig

cfg = QFMWorkflowConfig.from_config_dict(yaml.safe_load(open("config.yaml")))
cfg.sqd.total_samples            # n_batches * samples_per_batch
cfg.ewf.to_screening_rule()      # bath truncation as a general screening rule
spec = cfg.to_fragmentation_spec()

Credentials are dropped, deliberately

The upstream qpu.credentials block holds an API token and a CRN instance id. QFMQPUConfig mirrors channel and instance, since a benchmark record should state which service it ran on, but it has no token field, and from_config_dict() never reads one. You can pass a real config file in verbatim without leaking a secret into a stored record.

RDMs are not stored

QFMSolverResult records has_rdm1 / has_rdm2 but not the matrices themselves: benchmark records must stay JSON-serialisable, and RDMs at protein scale are far too large.

Composing with distributed execution

Fragmentation decomposes the problem; distributed execution decomposes the circuit solving each fragment. FragmentResult.record_id is the join:

frag_result.record_id            # -> BenchmarkRecord.experiment_id

That record may itself carry a CircuitCutSpec or CircuitPartitionSpec; a fragment too wide for one QPU is cut or partitioned like any other circuit.

Attaching to a benchmark record

from qpubench import CircuitSpec, QuantumResult
from qpubench.schemas.catalogs.fragmentation import FragmentationSpec

circuit = CircuitSpec(num_qubits=48, fragmentation=spec)     # model or dict
result  = frag_result.to_quantum_result()                    # vendor_results key set

# rehydrate
FragmentationSpec.model_validate(circuit.fragmentation)

to_quantum_result() takes an explicit computing_model because a fragmented calculation may be entirely classical, or mix classical and quantum fragments.