Skip to content

🐛 Let the QDMI client trigger a calibration run - #2148

Merged
burgholzer merged 3 commits into
agent/2094-move-iqm-conversionfrom
agent/2094-calibration-jobs
Aug 18, 2026
Merged

🐛 Let the QDMI client trigger a calibration run#2148
burgholzer merged 3 commits into
agent/2094-move-iqm-conversionfrom
agent/2094-calibration-jobs

Conversation

@marcelwa

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Stacked on #2114. The base of this PR is agent/2094-move-iqm-conversion, so the diff shown here is only this change. Merge #2114 first.

Description

Follow-up to the discussion in #2114, where reviewing the program-format classification turned up a bug behind it.

Device::submitJob rejected CALIBRATION and BATCH_JOB together, under one predicate that read as "carries no program payload":

if (hasNoGenericProgramPayload(format)) {
  throw std::invalid_argument(
      "Calibration and batch jobs do not use a generic program payload");
}

They are not the same case, and the calibration half was wrong.

Calibration

QDMI declares the format as `void*` A calibration program and says of it:

Triggering a calibration run does not require a program to be set via QDMI_DEVICE_JOB_PARAMETER_PROGRAM.

That is the explicit exception to QDMI_DEVICE_JOB_PARAMETER_PROGRAM being "required" — the payload is optional, not absent, and QDMI-on-IQM sends one. MQT Core rejected the format before any device call, so a calibration run could not be started at all, with or without a payload. Meanwhile QDMI_DEVICE_PROPERTY_NEEDSCALIBRATION is exposed as Device::getNeedsCalibration() and needs_calibration(): we told a caller a device needs calibration and gave them no way to act on it, even though the spec says exactly how.

This adds a dedicated entry point:

device.submit_calibration_job()                 # no payload
device.submit_calibration_job("configuration")  # text payload
device.submit_calibration_job(b"\x01\x02")      # exact bytes

Device::submitCalibrationJob in C++, with std::optional<std::span<const std::byte>> and a std::string convenience overload. It sets no shot count, because a calibration run executes no circuit. Its own entry point rather than a special case in submitJob, since the payload is optional and the shot count does not apply — submitJob now points there instead of failing silently on intent.

Batch jobs

A different problem. The spec requires the program for a batch job, but types it as a QDMI_Job* list — an array of live job handles. submitJob takes std::span<const std::byte>, so it structurally cannot express one, whatever the check says. MQT Core has no support for batch jobs and no device currently implements the feature; per the discussion, it was added for the multi-chip neutral-atom demonstrator, whose stack left MQT Core in #2137.

So this states the limitation instead of describing it as a missing payload, and leaves the door open:

MQT Core does not support batch jobs. A batch job's program is a list of job
handles, which this API cannot express

Validation

  • ./build/release/test/qdmi/mqt-core-qdmi-test — 277 passed
  • uv run --no-sync pytest test/python — 626 passed, 4 skipped
  • uvx nox -s stubs — the stub diff contains only submit_calibration_job
  • uvx nox -s lint — passes, full prek hook set

The bundled DD simulator and SC device both report NEEDSCALIBRATION = 0 and decline the format themselves, so the new tests assert what is actually verifiable here: the client no longer refuses before asking, and the failure arrives as a device error rather than an argument error. A device that supports calibration is needed to exercise the happy path.

Checklist

  • The pull request only contains commits that are focused and relevant to this change.
  • I have added appropriate tests that cover the new/changed functionality.
  • I have updated the documentation to reflect these changes.
  • I have added entries to the changelog for any noteworthy additions, changes, fixes, or removals.
  • I have added migration instructions to the upgrade guide (if needed).
  • The changes follow the project's style guidelines and introduce no new warnings.
  • The changes are fully tested and pass the CI checks.
  • I have reviewed my own code changes.

If PR contains AI-assisted content:

  • Any agent that created, edited, or submitted GitHub content was explicitly authorized for that scope, as required by our AI Usage Guidelines.
  • Every agent-authored or agent-edited public text body begins with the visible disclosure 🤖 *AI text below* 🤖 (titles are exempt).
  • I have disclosed AI assistance in the PR description.
  • I confirm that I have personally reviewed and understood all AI-generated content, and accept full responsibility for it.

AI assistance: written by Claude Opus 5 via Claude Code, under my direction. The last checkbox is left for me to tick after my own review.

@marcelwa
marcelwa force-pushed the agent/2094-calibration-jobs branch from 3395125 to ed18697 Compare August 18, 2026 14:24
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@marcelwa marcelwa self-assigned this Aug 18, 2026
@marcelwa marcelwa added bug Something isn't working c++ Anything related to C++ code QDMI Anything related to QDMI labels Aug 18, 2026
@marcelwa
marcelwa requested a review from burgholzer August 18, 2026 15:31
@burgholzer burgholzer added this to the QDMI Support milestone Aug 18, 2026
@burgholzer burgholzer added fix Fix for something that isn't working and removed bug Something isn't working labels Aug 18, 2026
@burgholzer
burgholzer force-pushed the agent/2094-calibration-jobs branch from ed18697 to 9c8ffd9 Compare August 18, 2026 17:00

@burgholzer burgholzer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also feels ready. Thanks for the fix @marcelwa 🙏🏼
Let's get this in 🚀

marcelwa and others added 3 commits August 18, 2026 18:02
`Device::submitJob` rejected `CALIBRATION` and `BATCH_JOB` together, under
one predicate that read as "carries no program payload". The two are not
the same case, and the calibration half was wrong.

QDMI declares `QDMI_PROGRAM_FORMAT_CALIBRATION` as `void*` "A calibration
program" and says only that triggering a calibration run "does not require
a program to be set", so the payload is optional rather than absent. MQT
Core rejected the format outright, before any device call, so a calibration
run could not be started at all. The client meanwhile reports
`needs_calibration()`, telling a caller a device needs calibration while
giving no way to act on it.

Add `Device::submitCalibrationJob`, bound as `submit_calibration_job`. The
payload is optional and may be text or bytes, and no shot count is set,
because a calibration run executes no circuit.

A batch job is a different matter. Its program is a list of job handles
rather than a byte payload, so `submitJob` cannot express it whatever the
check says. State that MQT Core does not support batch jobs, and leave the
door open for real support once a device implements the feature.
Route both submission APIs through one helper that accepts optional program and shot parameters. Treat an empty calibration byte span as no payload so Python b"" does not reach QDMI as an invalid zero-sized parameter.

Assisted-by: GPT-5 via Codex
@burgholzer
burgholzer force-pushed the agent/2094-calibration-jobs branch from 9c8ffd9 to 479612b Compare August 18, 2026 18:04
@burgholzer
burgholzer merged commit 1cf85c2 into main Aug 18, 2026
44 of 46 checks passed
@burgholzer
burgholzer deleted the agent/2094-calibration-jobs branch August 18, 2026 19:41
burgholzer added a commit that referenced this pull request Aug 18, 2026
* 📝 Plan the remaining MQT Core v3 backports

Record the compatible maintenance, SpecAudit, PennyLane, QDMI, and OpenQASM backport boundary.

Assisted-by: GPT-5 via Codex

* ⬆️👨‍💻 Update astral-sh/setup-uv action to v9 (#2123)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 7bc99e3)

* ⬆️🩹 Update pre-commit hook adhtruong/mirrors-typos to v1.49.0 (#2122)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ee2e56b)

* ⬆️🩹 Update patch updates (#2121)

* ⬆️🩹 Update patch updates

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* 🐛 Include Qiskit C API sources in wheel tests

Assisted-by: GPT-5 via Codex

---------

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Lukas Burgholzer <burgholzer@me.com>

(cherry picked from commit 8893132)

* ⬆️🪝 Update pre-commit hook python-jsonschema/check-jsonschema to v0.38.0 (#2126)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit d273d53)

* ⬆️👨‍💻 Update munich-quantum-toolkit/workflows digest to b9706e7 (#2129)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 3d631c1)

* 🤖 Improve prose, terminology, and test guidance in AGENTS.md (#2128)

Improve prose, terminology, and test guidance in AGENTS.md

Assisted-by: Opus 5 via Claude Code
(cherry picked from commit d9e7e1c)

* ✨ Add SpecAudits for finding and removing spec debt (#2124)

* ✨ Add SpecAudits for finding and removing spec debt

Assisted-by: Claude Opus 5 via Claude Code

* 📝 Add the first SpecAudit for the PennyLane QDMI plugin

Assisted-by: Claude Opus 5 via Claude Code

* 📝 Add a changelog entry for SpecAudits

Assisted-by: Claude Opus 5 via Claude Code

* 🔧 Drop the removed `.agent/run.sh` wrapper from the SpecAudit tooling

`.agent/run.sh` was removed when the agent development workflow was
simplified, so the probe script can no longer route its commands through
it. Call `uv`, `cmake`, and `ctest` directly, matching the build and test
entry points `AGENTS.md` now documents, and update the coverage commands
in `.agent/AUDITS.md` to match.

Also refresh the `AGENTS.md` quotation in `.agent/AUDITS.md`, whose
wording changed in the meantime.

(cherry picked from commit 59174b1)

* 🧪 Narrow the parameter-rendering assertions to their value

`test_qasm3_resolves_ddsim_aliases_and_inverse_gates` pinned the exact
seventeen-digit spelling of three OpenQASM angle literals. Nothing published
states how the converter renders a parameter; the promise is that it keeps
double precision.

Parse the emitted literals and compare them as floats. Exact float equality
still fails on any real precision loss.

Applies verdict 1 of `.agent/audits/pennylane-plugin.md`.

(cherry picked from commit 945b03b)

* ♻️ Emit the shortest exact OpenQASM parameter literal

`_format_parameter` rendered every angle with `.17g`, so `IsingXX(0.1)` reached
the device as `rxx(0.10000000000000001)`. `repr` gives the shortest literal that
reads back as the same double, so every emitted program stays exact and becomes
readable.

The helper had one caller and one line left, so fold it into that call site.

Applies verdict 1 of `.agent/audits/pennylane-plugin.md`.

(cherry picked from commit 5d42d2b)

* 🧪 Assert shot multiplicity instead of a synthesized shot order

A counts-only QDMI device exposes a histogram, and the device expands it into
shots. QDMI relates histogram key order to shot order nowhere, so the expanded
order carries no meaning, and asserting it advertised a guarantee the device
does not make.

Assert what the expansion must preserve: every bit string repeats exactly as
often as its count. Every probability derived from a counts-only device depends
on that.

Applies verdict 3 of `.agent/audits/pennylane-plugin.md`. The expansion strategy
in `QDMIDevice._shots_or_counts` is now free to change; this commit does not
change it.

(cherry picked from commit 35dcae3)

* 🧪 Narrow the diagonalization assertion to the claim it can make

`test_qasm2_diagonalizes_observable_once` pinned the exact seventeen-digit angle
of the X-basis rotation. The angle was believed to guard `rotations=False` in
the QASM2 path, but re-running the audit probe at this commit confirms the flag
is inert there: by the time a tape reaches the serializer through the device,
`measurements_from_samples` has already replaced the observable with a
`SampleMP`, so no diagonalizing gate is left to add. Flipping the flag to
`rotations=True` fails no test.

Keep the claim the test can still make -- the rotation appears once -- and drop
the angle it cannot earn.

Applies verdict 4 of `.agent/audits/pennylane-plugin.md`. `rotations=False`
stays and remains protected by nothing; the reconciled audit records that.

(cherry picked from commit 68056a6)

* ♻️ Import the execution-time clock by name

`QDMIDevice` read the clock as `time.monotonic`, so a test could only replace it
by mutating the `time` module for every caller in the process. Import the
function by name instead, so a test can replace this device's clock alone and
see exactly two readings per submitted job.

Prepares the strengthened `execution_time` assertion for verdict 5 of
`.agent/audits/pennylane-plugin.md`.

(cherry picked from commit eb147f0)

* 🧪 Assert the accumulated execution time, not its sign

`device.execution_time >= 0.0` could not fail for its own purpose: delete the
accumulation in `_execute_tape` and the attribute stays `0.0`, so the assertion
still passed. It caught the property vanishing, being `None`, or going `NaN`,
and nothing else.

Freeze the clock over a three-job shot vector and assert the exact accumulated
total. Replacing the accumulation with `+= 0.0` now fails this test. The broad
execution test keeps an explicit finiteness check, which is what the old
assertion really did.

Applies verdict 5 of `.agent/audits/pennylane-plugin.md`.

(cherry picked from commit 90947b8)

* 🧪 Drive the conversion tests through the PennyLane device

`test_converter.py` called the public `convert_program(tape, device, wires)`
directly. That three-argument signature was the only reason the function had to
stay public, and the only reason it could not receive state the device had
already computed.

Rewrite the file against `QDMIDevice` and `StubDevice.submissions`, the
technique `test_device.py` already uses. Every test now asserts what a QDMI
device actually receives.

Three tests changed shape, because preprocessing now runs first:

- `test_rejects_device_without_qasm` is deleted. Its code path is about to stop
  existing, and `test_device.py::test_rejects_device_without_openqasm` already
  asserts the same rule at construction.
- `test_qasm3_failure_does_not_fall_back_to_qasm2` now uses `U3`, which
  PennyLane's OpenQASM 2 serializer can spell and the OpenQASM 3 operation table
  cannot. A fallback would now succeed silently, so the test detects one.
- That test and `test_qasm2_rejects_non_intersection_operation` also execute a
  tape directly, which is the path where conversion, not preprocessing, has to
  do the refusing.

Coverage of the plugin is unchanged except for `converter._preferred_format`,
which the next commit deletes.

Prepares verdicts 2 and 6 of `.agent/audits/pennylane-plugin.md`.

(cherry picked from commit 3be4f6f)

* ♻️ Read the advertised QDMI gate set once per device session

`supports_operation` and `convert_program` were free functions that took the
opened device and re-derived everything from it. `supports_operation` is
PennyLane's per-operation `stopping_condition`, so a single preprocessing pass
over a 100-gate circuit made 101 `Device.operations()` round trips and 1818
`Operation.name()` calls where 1 and 18 would do. A four-tape parameter-shift
gradient multiplied that by four.

Bind the conversion to the device instead. `ProgramConverter` reads the
advertised operation table and the wire mapping once, when the session opens,
and reuses them for every operation and every tape. Measured on a 100-gate
circuit over an 18-operation device: 101 and 1818 per pass become 1 and 18 once,
for the life of the device.

Two duplicate implementations go with it:

- `converter._preferred_format` repeated the QASM3-then-QASM2 rule that
  `QDMIDevice._select_program_format` already applies, down to a second wording
  of the same error. The device now hands the selected format to the converter.
- The two textually identical `stopping_condition` lambdas become one bound
  method. PennyLane discards the first whenever the tape has shots, which is
  always.

`convert_program` and `ConvertedProgram` leave the package's public surface.
`QDMIDevice` is the documented entry point and was already the only caller.

Also drop the unreachable finite-shots check in `_shot_copies`:
`_validate_finite_shots` runs first in the pipeline and every later transform
preserves `tape.shots`, so the second check reads as live validation and is not.

Applies verdicts 2 and 6 of `.agent/audits/pennylane-plugin.md`.

(cherry picked from commit 458aa6a)

* 📝 Document the private PennyLane conversion surface

Record the removal of `convert_program` and `ConvertedProgram` from
`mqt.core.plugins.pennylane` and name `QDMIDevice` as the replacement.

(cherry picked from commit 9d8c002)

* 📝 Reconcile the PennyLane plugin SpecAudit

Mark every verdict applied or narrowed, per the Reconciling section of the
method. Each verdict gains a paragraph saying what landed and what did not, the
summary table gains a status column, and the header records the reconciliation
commit and the scope numbers after the change.

Two facts the reconciliation records rather than hides. Verdict 4 is narrowed,
not applied: the probe reproduced, so no code change was earned, and
`rotations=False` remains protected by nothing. Verdict 6 removed round trips,
not lines -- the test tree grew by 57 lines because driving conversion through
the device costs QNode boilerplate.

The one open item, probing the exact QASM2 payload assertion against a bumped
PennyLane, stays open.

(cherry picked from commit c174631)

* 📝 Shorten the changelog entries to match the surrounding style

(cherry picked from commit a65dad2)

* 🧪 Cover the QDMI loci checks and unpreprocessed tapes

`_validate_qdmi_contract` reads advertised sites, site pairs, and the device
coupling map, and no test exercised any of it beyond one site-pair rejection.
The converter's fallbacks for a tape that skips preprocessing had no test
either.

Add both. Plugin coverage goes from 85% to 90%, and the converter from 81% to
93%.

(cherry picked from commit 9b86f86)

* ♻️ Drop the unreachable format default in ProgramConverter.supports

`_select_program_format` returns OpenQASM 3 or OpenQASM 2 and raises otherwise,
so the third branch could never run. Every added source line in this branch is
now covered.

(cherry picked from commit e214e4a)

* 📝 Record the coverage the audit did not look for

Codecov rejected the patch, which showed that the advertised-loci validation had
almost no test behind it. Say so in the reconciliation, with the numbers.

(cherry picked from commit 7d43bb0)

* 🐛 Address PennyLane SpecAudit review findings

Restore finite-shot validation for direct device execution and keep the session converter implementation private.

Assisted-by: OpenAI Codex
(cherry picked from commit 4b435d0)

* Apply suggestion from @burgholzer

Signed-off-by: Lukas Burgholzer <burgholzer@me.com>
(cherry picked from commit 248028b)

* 🎨 pre-commit fixes

(cherry picked from commit 2b22cf5)

* 🐛 Let the QDMI client trigger a calibration run (#2148)

* 🐛 Let the QDMI client trigger a calibration run

`Device::submitJob` rejected `CALIBRATION` and `BATCH_JOB` together, under
one predicate that read as "carries no program payload". The two are not
the same case, and the calibration half was wrong.

QDMI declares `QDMI_PROGRAM_FORMAT_CALIBRATION` as `void*` "A calibration
program" and says only that triggering a calibration run "does not require
a program to be set", so the payload is optional rather than absent. MQT
Core rejected the format outright, before any device call, so a calibration
run could not be started at all. The client meanwhile reports
`needs_calibration()`, telling a caller a device needs calibration while
giving no way to act on it.

Add `Device::submitCalibrationJob`, bound as `submit_calibration_job`. The
payload is optional and may be text or bytes, and no shot count is set,
because a calibration run executes no circuit.

A batch job is a different matter. Its program is a list of job handles
rather than a byte payload, so `submitJob` cannot express it whatever the
check says. State that MQT Core does not support batch jobs, and leave the
door open for real support once a device implements the feature.

* 📝 Reference the pull request in the changelog entries

* ♻️ Share regular and calibration job submission

Route both submission APIs through one helper that accepts optional program and shot parameters. Treat an empty calibration byte span as no payload so Python b"" does not reach QDMI as an invalid zero-sized parameter.

Assisted-by: GPT-5 via Codex

---------

Co-authored-by: Lukas Burgholzer <burgholzer@me.com>
(cherry picked from commit 1cf85c2)

* 🐛 Guard assignment type checking after expression errors (#2156)

* 🐛 Stop assignment type checking after expression errors

Co-authored-by: Damian Rovara <damianrovara@gmail.com>
Assisted-by: GPT-5 via Codex

* 📝 Add changelog entry for #2156

Assisted-by: GPT-5 via Codex

---------

Co-authored-by: Damian Rovara <damianrovara@gmail.com>
(cherry picked from commit 665072c)

* 🐛 Distinguish scalar OpenQASM qubits from registers

Co-authored-by: Damian Rovara <damianrovara@gmail.com>
Assisted-by: GPT-5 via Codex
(cherry picked from commit 6eaea2a)

* 📝 Add changelog entry for #2157

Assisted-by: GPT-5 via Codex
(cherry picked from commit 0009e39)

* 📝 Document the v3 PennyLane conversion migration

Direct callers of the removed public conversion helpers to execute through QDMIDevice.

Assisted-by: GPT-5 via Codex

* ⬆️🔒️ Lock file maintenance (#2130)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

(cherry picked from commit 5332592)

* 📝 Record backport validation

Update the living plan with the completed ports, exact local results, and environment-specific validation limits. Apply the repository Markdown formatting to the PennyLane audit ledger.

* 📝 Record rebased backport validation

Record the #2153 base update, repeated validation, successful Python 3.14 lint run, and local documentation limit.

Assisted-by: GPT-5 via Codex

* 📝 Record backport publication

Record draft PR #2159, its metadata, and the initial check state in the living ExecPlan.

Assisted-by: GPT-5 via Codex

* 📝 Address backport documentation review

Remove the unreleased PennyLane migration note, close the gap between the new fixed entries, and order the added PR links.

Assisted-by: GPT-5 via Codex

---------

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: Lukas Burgholzer <burgholzer@me.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Haag <121057143+denialhaag@users.noreply.github.com>
Co-authored-by: Marcel Walter <marcel.walter@tum.de>
Co-authored-by: Marcel Walter <marcel@mq.sc>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Damian Rovara <damianrovara@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Anything related to C++ code fix Fix for something that isn't working QDMI Anything related to QDMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants