Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ coverage.xml

# Local scratch notes and generated docs, deliberately untracked.
/docs/

# Outputs
/output
/data

# Logging and Experiments
execute.sh
plot_cas_pes.py
87 changes: 80 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ from embasi_qiskit_integration.solvers import FCISolver

ham = fcidump.read("tests/data/n2_8o10e.fcidump")
res = FCISolver().solve(ham)
print(res.energy) # -108.9585095430 Ha; res.rdm1 / res.rdm2 populated
print(res.energy) # -108.9585095430 Ha; res.rdm1 / res.rdm2 populated
```

### 2. SQD with Aer (noiseless)
Expand All @@ -79,7 +79,7 @@ from embasi_qiskit_integration.solvers import SQDSolver
# SQDSolver builds the SqDRIFT ansatz, samples it, and runs the SQD loop.
# (AerSampler + SqDRIFT needs the `quantum` + `fermions` extras.)
res = SQDSolver(AerSampler(), shots=100_000, seed=24).solve(ham)
print(res.energy) # within 2e-3 Ha of FCI; res.diagnostics carries provenance
print(res.energy) # within 2e-3 Ha of FCI; res.diagnostics carries provenance
```

For CI / offline runs, replay frozen counts with `MockSampler` instead of
Expand All @@ -91,7 +91,7 @@ chain at 2000 shots, 30 qubits takes 0.01 s under MPS against 23.94 s under
`statevector`, whose memory doubles per qubit.

```python
AerSampler(method="statevector") # exact, small spaces
AerSampler(method="statevector") # exact, small spaces
AerSampler(method="matrix_product_state", mps_max_bond_dimension=64) # capped MPS
```

Expand All @@ -108,6 +108,7 @@ The sampler is the only thing that changes.

```python
from qiskit_ibm_runtime import QiskitRuntimeService

QiskitRuntimeService.save_account(channel="ibm_quantum_platform", token="<IBM_TOKEN>")
```

Expand All @@ -117,7 +118,7 @@ or export `QISKIT_IBM_TOKEN` in your environment. Then swap in `RuntimeSampler`:
from embasi_qiskit_integration.circuit_run.runtime import RuntimeSampler
from embasi_qiskit_integration.solvers import SQDSolver

sampler = RuntimeSampler() # least-busy real backend
sampler = RuntimeSampler() # least-busy real backend
# sampler = RuntimeSampler(backend="ibm_kingston") # or pick one explicitly
# sampler = RuntimeSampler(optimization_level=2) # ISA-transpile level (default 3)
res = SQDSolver(sampler, shots=100_000).solve(ham)
Expand Down Expand Up @@ -181,8 +182,12 @@ SQD):

```python
from qiskit import QuantumCircuit
qc = QuantumCircuit(2); qc.h(0); qc.cx(0, 1); qc.measure_all()
print(RuntimeSampler().sample(qc, shots=1024)) # -> counts dict from hardware

qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()
print(RuntimeSampler().sample(qc, shots=1024)) # -> counts dict from hardware
```

### 4. Two-process CLI handoff
Expand All @@ -207,6 +212,30 @@ uv run embasi-qiskit-integration solve <jobdir> --solver fci # classica

Defaults are `--shots 10000` per circuit, `--optimization_level 1`, `--seed 42`.

**`--sqd_method` picks how the SqDRIFT ansatz is built** — `qdrift` (the default) or
`exact`. It is the CLI name for the `method=` argument in §2/[Ansatz](#ansatz), and it
is accepted by both `solve` and `scripts/embedding_workflow.py`:

```bash
uv run embasi-qiskit-integration solve <jobdir> --sqd_method exact # one full-evolution circuit per time
uv run embasi-qiskit-integration solve <jobdir> --sqd_method qdrift \
--num_randomizations 500 --num_groups 15 --evolution_time 1.0 # randomized ensemble (default)
```

- `exact` synthesises **one** exact time-evolution circuit at `--evolution_time`. Deepest
circuit, no ensemble, and `--num_randomizations` / `--num_groups` are ignored.
- `qdrift` draws an **ensemble** of `--num_randomizations` randomized circuits, samples
each at `--shots`, and pools the counts. The real shot budget is therefore
`num_randomizations * shots`, not `shots`, which at a fixed total budget is
substantially more accurate than a single deep circuit.

The qDRIFT knobs only bite under `--sqd_method qdrift`: `--num_randomizations` (default
**500**) is the ensemble size, `--num_groups` (default **15**) the length of the
randomized product inside each circuit, and `--evolution_time` (default **1.0**) the
evolution time. Since the default is `qdrift` at 500 randomizations, a bare
`--shots 1000` run submits 500 circuits — cut `--num_randomizations` before pointing it
at real hardware.

**Error suppression (runtime sampler only).** `--measure_twirling` defaults to
**true** here — unlike bare `SamplerV2`, because a bare `solve` targets hardware
and readout error is what SQD is most sensitive to (see §3). Idle-qubit
Expand All @@ -232,6 +261,7 @@ reported through the standard `logging` module under the

```python
import logging

logging.basicConfig(level=logging.INFO)
```

Expand Down Expand Up @@ -265,6 +295,49 @@ uv run python scripts/embedding_workflow.py --solver fci # classical F
uv run python scripts/embedding_workflow.py --xc_hl PBE0 # DFT-in-DFT (solver inert)
```

### Per-cycle diagnostics to CSV (`--diagnostics_csv`)

The outer loop's per-cycle log normally only goes to the console. `--diagnostics_csv
<path>` additionally appends **one row per outer-loop cycle** to a CSV, so a sweep over
geometries or samplers lands in something you can load with pandas instead of parsing
stdout:

```bash
uv run python scripts/embedding_workflow.py --xyz data/12.inp --active_atoms '[0,1]' \
--max_cycles 30 --sqd_method qdrift --shots 1000 \
--diagnostics_csv output/diagnostics.csv
```

It is **off by default** (`None`). Behaviour worth knowing before you point it at a
sweep:

- **Append, not overwrite.** The header is written only when the file is missing or
empty, so pointing many runs at one path accumulates them — that is the intended use,
and `run_id` (a per-run UUID) is what separates them. To get a fresh file, delete it
or name a new one.
- **Flushed every row**, so a run killed at cycle 17 still leaves 17 usable rows.
- **Never fatal.** A bad path or missing datum is caught, reported as
`[diagnostics] skipped cycle N: ...`, and the embedding continues. A missing value is
an empty cell rather than an error.
- **Rank 0 only**, so an `mpirun` run writes one row per cycle, not one per rank.
- Parent directories are created for you.

Each row carries the run/geometry identity (`run_id`, `geometry_file`,
`geometry_parameter`, `algorithm`, `backend_name`), the cycle state (`cycle`,
`converged`, `total_cycles`), the active space (`cas_norb`, `cas_nelec`,
`active_space_indices`, `selector`, `selected_virtuals`, `frozen_occupied`, `norb`,
`nelec_alpha`, `nelec_beta`), every term of the embedding energy (`E_solver`,
`E_low_total`, `E_low_A`, `E_high_A`, `e_core`, `correction`, `footing_shift`,
`projector_leak`, `p_b_leak`, `embedding_energy`), the convergence deltas (`delta_E`,
`max_delta_gamma_A`), the density (`rdm1_trace`, `natural_occupations`, `rdm1`) and, for
SQD runs, `number_unique_bitstrings` and `final_sqd_subspace_dimension` (both empty under
`--solver fci`). Array-valued columns are JSON-encoded with floats rounded to 3 decimals.
The authoritative column list is `DIAGNOSTICS_CSV_COLUMNS` in
[`diagnostics_csv.py`](src/embasi_qiskit_integration/diagnostics_csv.py).

`--diagnostics_csv` is distinct from `--output_path`, which appends one summary line per
*run* rather than per cycle.

### Starting from an `.xyz`

Both workflows default to their built-in geometry (the s26 methanol monomer above,
Expand Down Expand Up @@ -346,7 +419,7 @@ from embasi_qiskit_integration.circuit_run import unpermute_counts_list

result = build_sqdrift_circuits(ham, method="qdrift", num_randomizations=8)
counts = sampler.run(result.circuits, shots)
counts = unpermute_counts_list(counts, result.permutations) # required!
counts = unpermute_counts_list(counts, result.permutations) # required!
```

By default the permutation is whatever the MILP solver returned, applied in a
Expand Down
2 changes: 1 addition & 1 deletion src/embasi_qiskit_integration/circuit_generator/sqdrift.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def _hf_occupation(norb: int, nelec: tuple[int, int]) -> list[bool]:
def build_sqdrift_circuits(
ham: EmbeddedHamiltonian,
*,
method: str = "exact",
method: str = "qdrift",
num_groups: int | Sequence[int] = 15,
num_randomizations: int = 500,
time: float | Sequence[float] = (1.0, 2.0, 3.0),
Expand Down
7 changes: 7 additions & 0 deletions src/embasi_qiskit_integration/circuit_run/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ def __init__(
# layout actually used. Populated by :meth:`run`.
self.hardware_characterisation: dict | None = None

@property
def resolved_backend_name(self) -> str | None:
"""The actual backend name used, once resolved (None before the first run())."""
if self._backend_obj is not None:
return getattr(self._backend_obj, "name", None)
return self.backend_name

def resolve_backend(self, *, num_qubits: int | None = None):
"""Resolve the backend to run on (cached on this instance after the first call).

Expand Down
11 changes: 10 additions & 1 deletion src/embasi_qiskit_integration/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@ class SolveCommand(BaseSettings):
counts: str | None = None
backend: str | None = None # runtime backend name; else least-busy
optimization_level: int = 1 # runtime ISA-transpile level (0-3)
shots: int = 10_000 # per circuit
shots: int = 1_000 # per circuit for SQD
seed: int = 42
optimize: bool | None = None
time_limit: float = 10.0 # per-solve wall-clock limit for the relabel MILP
# Processes used to build the circuit ensemble. 0 means one per CPU.
workers: int = 1
# SQD solver parameters
sqd_method: Literal["exact", "qdrift"] = "qdrift"
evolution_time: float = 1.0
num_groups: int = 15
num_randomizations: int = 500 # for method="qdrift", number of random circuits to sample
measure_twirling: bool = True
# Idle-qubit decoherence suppression; off by default (it lengthens the schedule).
dynamical_decoupling: bool = False
Expand Down Expand Up @@ -110,6 +115,10 @@ def _build_solver(self):
optimize=self.optimize,
time_limit=self.time_limit,
workers=self.workers,
method=self.sqd_method,
evolution_time=self.evolution_time,
num_groups=self.num_groups,
num_randomizations=self.num_randomizations,
)

def _sampler_options(self) -> dict | None:
Expand Down
Loading
Loading