Skip to content

QFw: run Pulser programs on Pasqal devices through QRMI - #158

Merged
DougSO merged 3 commits into
openQSE:mainfrom
awennersteen:aw/pasqal-pulser
Oct 22, 2026
Merged

DougSO merged 3 commits into
openQSE:mainfrom
awennersteen:aw/pasqal-pulser

Conversation

@awennersteen

@awennersteen awennersteen commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Implements #157: Pulser programs on Pasqal devices through the shim QPM, via QRMI. The design is in docs/design/pulser-programs.md.

It is one PR so the whole path can be run end to end, split into three commits that build on each other and each pass ci-syntax and ci-mock on their own:

  1. QFw: let a QPM run Pulser sequences. The pulser-abstract-repr format in util.circuit_payload, its declaration by a Pasqal QPM, QPM_CAP_NEUTRAL_ATOM and the pasqal client selection, and the design note.
  2. QFw: run Pasqal Cloud devices through QRMI. One identity per resource: a reservation credential carrying a token runs as that user, with the operator's login cleared; otherwise the service's token ({backend}_QRMI_PASQAL_CLOUD_AUTH_TOKEN or QFW_API_KEY), a service account, or an explicitly set PASQAL_CONFIG_ROOT / PASQAL_USERNAME / PASQAL_PASSWORD ($HOME is never passed). The project-id device key and the user's project_id credential entry; Payload.PasqalCloud execution to qhw-result-v1; get_backend_info returning the Pulser devices, read as the reservation when the client names one. A Pasqal job has no default provider deadline: a device can set job-timeout-seconds, otherwise the job runs until it ends, the client cancels it, or its reservation closes.
  3. QFw: add a Pulser frontend and example. backends/qfw_pulser.QFwConnection, setup/pulser-requirements.txt, install, and examples/qfw_pulser.sh.

Try it

With a Pasqal Cloud account, from examples/ inside an allocation:

pip install -r ../setup/pulser-requirements.txt   # into the application venv
QFW_API_KEY=<pasqal token> QFW_PASQAL_PROJECT_ID=<project> ./qfw_pulser.sh

Testing

  • ci-syntax clean; tests/mock 760 passed, 3 skipped; tests (unit) 130 passed; tests/pulser against Pulser 1.9.1 passed. Each commit passes on its own.

  • End to end on openQSE's QFw-SLURM-Cluster (two-group heterogeneous allocation, job-owned directory and shim QPM, reservation through qfw_slurm_driver.sh) against Pasqal Cloud EMU_FREE, 4 atoms built on FRESNEL_CAN1's layout, 100 shots:

    Pulser program on 4 atoms, 100 shots per job, built on FRESNEL_CAN1
      QRMI  DONE      61.3s
              500 ns  excited 0.51  0011:11  1011:10
             1000 ns  excited 1.00  1111:100
    
  • get_backend_info live: EMU_FREE serves FRESNEL and FRESNEL_CAN1, FRESNEL serves FRESNEL.

Not in this PR

  • PasqalLocal (an on-premises QPU behind Warden), which needs the Slurm job's identity and a session acquired for it.

awennersteen added a commit to awennersteen/QFw-SLURM-Cluster that referenced this pull request Oct 19, 2026
A recipe and two scripts so QFw developers can run the Pulser example of
openQSE/QFw#158 on Pasqal Cloud from a QFw checkout in one command.
pasqal_token.py turns the developer's own Pasqal Cloud login into a token
in a private env file; run_pulser_example.sh passes it to docker exec
with --env-file, allocates the two-group job and runs qfw_pulser.sh.

This is a testing aid for reviewing #158, not meant to merge as is.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>
@awennersteen

awennersteen commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

An AI agent wrote this

Design question: how a Pasqal QPM authenticates

An open choice in this PR that we'd like your view on before it settles.

What the PR does now

QFw opens every QRMI resource with QuantumResource.from_config(alias, type, map), so settings never go through the process environment (the shim runs a reservation's circuits per thread). For Pasqal Cloud, _pasqal_cloud_settings follows the IBM resolver's rules and puts three keys in that map: a token, the project and the base URL. The token comes from:

  • a reservation credential (a user's api_key in the credential DB), which replaces everything, or
  • without one, {backend}_QRMI_PASQAL_CLOUD_AUTH_TOKEN or QFW_API_KEY in the QPM's environment.

An operator can instead set a Pasqal service account as {backend}_QRMI_PASQAL_CLOUD_CLIENT_ID / _CLIENT_SECRET.

With from_config, QRMI does not look at the environment or $HOME (cloud.rs#L80-L91 at 0.26.0). The usual Pasqal setup, ~/.pasqal/config or PASQAL_USERNAME / PASQAL_PASSWORD, which QRMI, pasqal-cloud and Pulser users already have, is therefore never read. It is only read when the map carries PASQAL_CONFIG_ROOT (or HOME).

What that costs

  • Tokens expire. A Pasqal Cloud token is a short-lived JWT, not a long-lived API key like IBM's. QRMI renews tokens itself (ensure_authenticated), but only when it holds a username/password or client id/secret. With a bare token:
    • a user's api_key in the credential DB stops working after expiry and has to be replaced by hand;
    • a resource opened without a reservation is cached for the life of the QPM under the ("default",) key, so a long-running QPM started with QFW_API_KEY fails once that token expires.
  • Extra step for developers. A developer running the example has to mint a token first. The QFw-SLURM-Cluster test kit ([DO NOT MERGE] Pulser requirements and a Pasqal test kit for QFw#158 QFw-SLURM-Cluster#60) has a helper script just for that.

Options

A. Keep it as is (token, or operator service account).

  • Same model as IBM.
  • No user credentials on QPM hosts.
  • Sites that want unattended operation use a service account plus each user's project_id in the credential DB.
  • Per-user access, though, means short-lived tokens that must be refreshed outside QFw.

B. Let QRMI read Pasqal's own config when no reservation credential is bound.

  • _pasqal_cloud_settings would add PASQAL_CONFIG_ROOT to the map, from {backend}_PASQAL_CONFIG_ROOT / PASQAL_CONFIG_ROOT, else the QPM process's HOME.
  • QRMI then logs in with ~/.pasqal/config or PASQAL_USERNAME / PASQAL_PASSWORD and renews tokens itself.
  • A bound reservation credential still replaces everything, so per-user isolation on site services is unchanged.
  • Explicit settings keep precedence: QRMI uses the map first, then the file.
  • About a dozen lines plus tests.
  • Risk: a site QPM started without a credential DB would silently use the account in the service user's ~/.pasqal/config.

C. Like B, but only forward an explicitly set PASQAL_CONFIG_ROOT, with no HOME fallback.

  • The operator opts in, so nothing is picked up by accident.
  • Developers set one variable.

We lean towards C:

  • It keeps QFw's credential model as the default.
  • It removes token expiry for job-owned QPMs and for operators who opt in.

Happy to add it to this PR, or as a follow-up, whichever you prefer.

awennersteen added a commit to awennersteen/QFw-SLURM-Cluster that referenced this pull request Oct 20, 2026
A recipe and two scripts so QFw developers can run the Pulser example of
openQSE/QFw#158 on Pasqal Cloud from a QFw checkout in one command.
pasqal_token.py turns the developer's own Pasqal Cloud login into a token
in a private env file; run_pulser_example.sh passes it to docker exec
with --env-file, allocates the two-group job and runs qfw_pulser.sh.

This is a testing aid for reviewing #158, not meant to merge as is.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>
awennersteen added a commit to awennersteen/QFw-SLURM-Cluster that referenced this pull request Oct 20, 2026
A recipe and two scripts so QFw developers can run the Pulser example of
openQSE/QFw#158 on Pasqal Cloud from a QFw checkout in one command.
pasqal_token.py turns the developer's own Pasqal Cloud login into a token
in a private env file; run_pulser_example.sh passes it to docker exec
with --env-file, allocates the two-group job and runs qfw_pulser.sh.

This is a testing aid for reviewing #158, not meant to merge as is.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>
awennersteen added a commit to awennersteen/QFw-SLURM-Cluster that referenced this pull request Oct 20, 2026
A recipe and two scripts so QFw developers can run the Pulser example of
openQSE/QFw#158 on Pasqal Cloud from a QFw checkout in one command.
pasqal_token.py turns the developer's own Pasqal Cloud login into a token
in a private env file; run_pulser_example.sh passes it to docker exec
with --env-file, allocates the two-group job and runs qfw_pulser.sh.

This is a testing aid for reviewing #158, not meant to merge as is.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>

@DougSO DougSO left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this, and for the end-to-end run on EMU_FREE. The design reads well and the code looks right to me. There is one change I'd still like to see, then answers to your questions.

1. After merging main, a reservation-bound get_backend_info fails on QDMI devices

Main has moved since this branch was cut. #163 gave the QDMI driver its own get_backend_info(), which takes no arguments (qdmi_driver.py L141 on main), and QDMI is the default library for that call. When a caller names a reservation, this PR passes a credential to whichever driver serves the call (frontend.py L146-L152). So on an IQM or Braket device, and on IBM once #156 lands, that call fails with TypeError: QdmiDriver.get_backend_info() got an unexpected keyword argument 'credential'. Nothing calls it that way yet, because only the Pulser client names a reservation, and only on Pasqal devices. But the telemetry API now offers reservation_id on every QPM, so the next caller would hit it.

When you merge main, could QdmiDriver.get_backend_info take credential=None and open the device with it, self._device(credential)? A test through the frontend for a QDMI-routed device would keep it working. The QRMI driver's IBM and IQM branches ignore the credential too (qrmi_driver.py L1073-L1092). Using it there as well would make a reservation-bound read mean the same thing on every device.

2. Smaller, optional

Main also added get_qpm_telemetry() in qfw_lookup_service.py (L84 on main). QFwConnection.fetch_available_devices builds the same telemetry binding by hand (L316-L317) and could use it instead.

Your questions, from #157 and your comment here

  • Reusing info["circuit"] and circuit_formats for a Pulser sequence is fine for now. A rename can wait until a second non-circuit format comes along, as your design note says.
  • Bit 3 of QPMCapability is free. Nothing in QFw, the DEFw v2 prototype branch, DEFw or the cluster repo uses it.
  • setup/pulser-requirements.txt fits. Once this merges, the cluster image will need to install it, which is the first commit of QFw-SLURM-Cluster#60.
  • Authentication: what the PR does now, option C, looks right to me. A config root is forwarded only when the operator sets one, and $HOME never is. Per-user tokens in a credential DB still expire, as your design note says. Calling the credential providers' refresh hook can be a follow-up.

@awennersteen

Copy link
Copy Markdown
Contributor Author

An AI agent wrote this

Thanks! Rebased on main 7e77742 and force-pushed; the changes are squashed into the existing commits.

  1. QdmiDriver.get_backend_info now takes credential=None and opens self._device(credential). The frontend always forwards the credential. The QRMI driver's IBM and IQM branches now read _target(credential=...) too. On IQM, _arch_raw was also reading the service's own target, so it now takes the credential as well. New tests: a reservation-bound read through the frontend on a real QdmiDriver (it fails with your TypeError without the fix), and the QRMI IBM and IQM branches.
  2. fetch_available_devices now uses get_qpm_telemetry().

Authentication: left as option C. Calling the providers' refresh hook can be a follow-up.

@awennersteen

Copy link
Copy Markdown
Contributor Author

An AI agent wrote this

One more force-push (fa6d7ce, squashed into the third commit): cancel_batch() read each job's completion before cancelling, so a failing poll, which is when _wait points users to it, stopped every cancel. The read is now best effort per job, with a regression test. I also updated the PR description to match the current authentication model and timeouts.

@DougSO

DougSO commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Thanks, both of those look right to me, and good catch on cancel_batch.

The only thing left is a rebase. #156 merged just after you rebased, so main is
now b93bc39 and there are two conflicts. Both are additive, so keep both sides.

services/svc_lib_qpm/descriptor.py — #156 shortened the object storage
comment. Your two entries go above it:

		# The Pasqal Cloud project a job is billed to when the user's
		# credential DB entry names none. See drivers/qrmi_driver.py.
		"project_id": device.get("project-id"),
		# The device's API URL, which a Pasqal resource opens against when
		# nothing more specific names one.
		"url": device.get("url"),
		# Device-owned object storage for QRMI's IBMQuantumSystem.
		# Storage credentials stay in the credential DB.

man/man5/qfw-device-access.yaml.5 — in the resource-type entry, #156
added a paragraph about IBM QDMI. Your Pasqal sentence follows it:

IBM QDMI supports only
.BR IBMQiskitRuntimeService .
A device using only QDMI can omit this field. For the other IBM resource
types, set
.B libraries
to
.BR [qrmi] .
A
.B pasqal
device sets
.BR PasqalCloud ,
the only Pasqal resource type the shim runs.

Nothing else in the two PRs overlaps. I resolved it this way locally and all
four CI stages pass.

A QPM runs gate circuits, sent in the circuit envelope as OpenQASM 2 or QPY.
A Pasqal neutral-atom device runs a Pulser sequence instead. This adds the
format, the way a client finds a QPM that runs it, and the design note for
the Pasqal work that follows.

util.circuit_payload gains `pulser-abstract-repr`: a Pulser sequence in
Pulser's JSON abstract representation, read with pulser_sequence() and
written with encode_pulser_sequence(). A QPM declares it in circuit_formats
only if it runs it, and a Pasqal shim QPM declares it alone. A circuit QPM
refuses a Pulser sequence by name, and a Pulser QPM refuses a circuit by
name, before anything reaches a provider.

A client looks a QPM up by capability bits, and the shim advertised the
superconducting bit for every device. QPMCapability gains
QPM_CAP_NEUTRAL_ATOM; the shim advertises it for a neutral-atom device and
the superconducting bit for the others, as before. The client selection
table gains `pasqal` (provider shim, hardware, neutral-atom), so a Pulser
client finds the Pasqal QPM and a Qiskit client looking for a
superconducting shim never does. The directory record carries
technology neutral-atom for a Pasqal device.

docs/design/pulser-programs.md describes the whole Pasqal path; the QPU
front-end contract points to it.

Verified: tests/mock (new: Pulser format reading and refusals, the
declaration, capability bits against both selections), ci-syntax clean.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>
QRMI serves Pasqal Cloud devices (QPUs and emulators) as its PasqalCloud
resource type, which the shim's QRMI driver listed but ran as IQM. This
runs them.

Authentication: QRMI logs in and renews tokens; the driver picks whose
identity the resource uses and never mixes two, since QRMI renews an
expired token with whatever login the map holds. A reservation whose
credential carries a token runs as that user, with the operator's service
account and Pasqal login cleared from the map. Otherwise (no credential, a
reservation id alone from the no-secret provider, an entitlement) it runs
as the service: the backend's token or QFW_API_KEY, the service account
{backend}_QRMI_PASQAL_CLOUD_CLIENT_ID/_SECRET, or, set explicitly,
PASQAL_CONFIG_ROOT and PASQAL_USERNAME/PASQAL_PASSWORD, which Pasqal's own
tools read; $HOME is never passed. The resource opens only through
QuantumResource.from_config, since the environment path cannot keep the
identities apart.

The project is the user's project_id entry in the credential DB (an
entitlement passes it on too), else the backend's, else the device's new
project-id key in device-access config (forwarded by select_qpu, kept by
the descriptor, documented in qfw-device-access.yaml(5)); a config file
under PASQAL_CONFIG_ROOT may name it instead. A job needs one; opening the
resource does not, so public emulator specs can be read without it. A
user's resource is built from the credential and the device alone (URL:
credential, QFW_QC_URL, device url), never from the service user's
credential DB entry; a service's falls back to the device's url when
device access cannot be resolved.

Execution: run_circuit submits the Pulser sequence as
Payload.PasqalCloud(sequence, job_runs) and turns the batch's counter into a
qhw-result-v1 record. A finished task is not deleted with task_stop, which
on Pasqal cancels the batch and is rejected once the batch has ended. A
result without counts is an error rather than an empty success.
PasqalLocal is refused: it needs the Slurm job's identity and a session
acquired for it.

The provider deadline for a Pasqal job is the device's job-timeout-seconds
or the client's timeout, and reaching it cancels the job. Without one the
job waits as long as Pasqal queues it; a reservation that closes cancels
it. Other providers wait for job-timeout-seconds too, else 300 s as
before. A timeout that is not a number fails before anything is
submitted.

Capabilities: a Pasqal resource serves run_circuit, the task calls and
get_backend_info, which returns the Pulser devices QRMI's target() lists
(the QPU itself, or for an emulator the real devices it accepts sequences
for), read for each call so they follow recalibrations, for a Pulser client
to build against. The telemetry get_backend_info takes a reservation_id;
the shim QPM checks the reservation as for execution (a closing one is
refused) and resolves its credential from its own bindings, since a QPU's
specs need a login. Whichever library serves the call opens the device
with that credential: the QRMI driver for IBM and IQM as for Pasqal, and
the QDMI driver. The qhw introspection calls are a gap for it.

Verified: tests/mock (new: whose identity each kind of reservation runs
as, payload and result, PasqalLocal refusal, the finished-batch rule, the
provider deadline, the project from the credential DB, a reservation's
credential reaching get_backend_info on QRMI and QDMI); against qrmi 0.26
and a local stand-in for Pasqal's API, an expired user token is no longer renewed with
the operator's service account, and a service logs in from the config
file under PASQAL_CONFIG_ROOT; live on Pasqal Cloud EMU_FREE and FRESNEL
device specs through get_backend_info; ci-syntax clean.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>
backends/qfw_pulser.QFwConnection is a Pulser RemoteConnection, so a Pulser
program runs on a QFw QPM the way it runs on Pasqal Cloud:

    backend = pulser.QPUBackend(sequence, connection=QFwConnection())
    results = backend.run([{"runs": 100}], wait=True)

It finds the QPM through the `pasqal` selection or the reservation the
launcher exported, and checks the QPM reads Pulser sequences. submit()
checks the sequence against the device's current specs and sends that
sequence (QPUBackend.run checks too but hands submit the one it was
given), each job as one circuit through async_run, built with the job's
variables; every job is built before any is sent. A batch is the jobs of
one submit. Job state comes from the
QPM's completion queue: rc 0 is done, a QPM-side or provider cancel is
cancelled, anything else an error with the QPM's message, a lost
reservation an error.

Once a job is accepted its batch stays reachable. A submit that fails part
way cancels each accepted job on its own and raises QFwBatchError naming
the batch, with the submission failure as its cause and any cancel that
failed or the QPM refused; an interrupt cancels them too and is
re-raised. A wait that times out (timeout bounds the client's wait only)
or cannot poll raises it too and leaves the jobs running for
RemoteResults(batch_id, connection) to follow and cancel_batch() to stop.
cancel_batch() asks for every job not known to be finished, including
one whose completion cannot be read.

fetch_available_devices() reads get_backend_info through the QPM's
telemetry binding as the reservation, so QPUBackend checks a sequence
against the device's current specs; a QPM that answers with none raises
NotImplementedError,
which Pulser treats as keeping the sequence's device, and any other failure
is raised as it is. lib and QFW_SHIM_LIB pick the shim
library as for the Qiskit backend. failure_message() and SHIM_LIB_ENV move
into qfw_qiskit.qfw_job, which no longer needs a Qiskit backend import to
reach them.

setup/pulser-requirements.txt holds pulser-core, needed only by
applications that use the frontend. CMake installs qfw_pulser and the
install-tree smoke checks it.

examples/qfw_pulser.sh starts the shim service for a Pasqal device, reserves
it and runs examples/tests/test_pulser.py through QPUBackend. The program
fetches the device through the QPM, places atoms on a calibrated layout
(raising the count to the device's minimum layout filling when it has one,
42 on FRESNEL), and drives them with a pi/2 and a pi pulse in two jobs. It
prints, per shim library asked for, the excited fraction and top bitstrings
of each job. Devices: Pasqal Cloud EMU_FREE and the FRESNEL QPU.

tests/pulser runs the client against real Pulser with the QPM stood in
for: the sequence sent is the one checked against the device's current
specs, and results read in register order. CI runs it as ci-pulser;
ci-mock leaves it out.

Verified: tests/mock (new: submission, per-job variables, the completion
states, partial submission and cancel failures, a wait that times out or
cannot poll, cancel_batch, device fetch, the example config through the
descriptor); tests/pulser against Pulser 1.9.1; end to end on openQSE's QFw-SLURM-Cluster against EMU_FREE,
4 atoms built on FRESNEL_CAN1, 100 shots: pi/2 pulse excited fraction 0.51,
pi pulse 1111 in 100 of 100 shots; ci-syntax clean.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Aleksander Wennersteen <aleksander.wennersteen@pasqal.com>
@awennersteen

Copy link
Copy Markdown
Contributor Author

An AI agent wrote this

@DougSO Rebased on main b93bc39 and force-pushed (c19f489). Both conflicts resolved the way you described: the Pasqal project_id and url entries above the shortened object storage comment in descriptor.py, and the Pasqal sentence after the IBM QDMI paragraph in qfw-device-access.yaml.5. ci-syntax passes; ci-mock 797 passed, 3 skipped, and 130 unit tests passed.

@DougSO
DougSO merged commit bc8d6a8 into openQSE:main Oct 22, 2026
8 checks passed
DougSO added a commit that referenced this pull request Oct 22, 2026
Fixes an AI agent caught in existing code while developing #158 (unreviewed)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants