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 |
— (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:
- a set of fragments, in real space or orbital space;
- an expansion:
(fragment, signed coefficient)terms whose weighted sum reconstructs a supersystem property; - a solver per fragment, classical or quantum;
- 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 — a benchmark record should
state which service it ran on — but 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.