Skip to main content

clawft_kernel/
quantum_pasqal.rs

1//! Pasqal Cloud backend — live implementation (EXPERIMENTAL, 0.6.x).
2//!
3//! Wires up Auth0 service-account authentication, batch submission, polling,
4//! and result retrieval against `https://apis.pasqal.cloud`. Intended to work
5//! against the free `EMU_FREE` device with zero QPU-hour cost.
6//!
7//! **Known caveat**: the exact JSON schema produced by Pulser's
8//! `Sequence.to_abstract_repr()` is not fully documented in Rust. The builder
9//! in `build_sequence_json` implements the stable subset (AnalogDevice,
10//! global Rydberg channel, constant pulse) that matches Pulser v0.15+ output
11//! for simple cases. For complex sequences, generate the JSON from the Pulser
12//! Python SDK and submit via `submit_raw_sequence`.
13//!
14//! See `.planning/development_notes/pasqal-integration.md` §6 and §13.
15//!
16//! Feature: `quantum-pasqal` (off by default).
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22use tokio::sync::Mutex;
23
24use crate::quantum_backend::{
25    BackendStatus, EvolutionParams, JobHandle, JobStatus, QuantumBackend, QuantumError,
26    QuantumResults,
27};
28use crate::quantum_state::QuantumCognitiveState;
29
30const DEFAULT_API_URL: &str = "https://apis.pasqal.cloud";
31const DEFAULT_AUTH_URL: &str = "https://pasqal.eu.auth0.com/oauth/token";
32const AUTH_AUDIENCE: &str = "https://apis.pasqal.cloud/account/api/v1";
33// Refresh the token this many seconds before actual expiry to avoid races.
34const TOKEN_REFRESH_SKEW_SECS: u64 = 300;
35
36/// Pasqal Cloud device selector.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PasqalDevice {
39    EmuFree,
40    EmuTn,
41    Fresnel,
42}
43
44impl PasqalDevice {
45    pub fn as_api_str(self) -> &'static str {
46        match self {
47            PasqalDevice::EmuFree => "EMU_FREE",
48            PasqalDevice::EmuTn => "EMU_TN",
49            PasqalDevice::Fresnel => "FRESNEL",
50        }
51    }
52
53    pub fn max_qubits(self) -> usize {
54        match self {
55            PasqalDevice::EmuFree => 25,
56            PasqalDevice::EmuTn => 100,
57            PasqalDevice::Fresnel => 100,
58        }
59    }
60
61    /// Pulser abstract-repr `device.name` matching this Pasqal device.
62    pub fn pulser_device_name(self) -> &'static str {
63        match self {
64            // EMU_FREE and FRESNEL both accept AnalogDevice sequences.
65            PasqalDevice::EmuFree | PasqalDevice::EmuTn | PasqalDevice::Fresnel => "AnalogDevice",
66        }
67    }
68}
69
70#[derive(Debug, Clone)]
71pub struct PasqalConfig {
72    pub api_url: String,
73    pub auth_url: String,
74    pub project_id: String,
75    pub device: PasqalDevice,
76    pub client_id: String,
77    pub client_secret: Option<String>,
78    pub http_timeout: Duration,
79}
80
81impl Default for PasqalConfig {
82    fn default() -> Self {
83        Self {
84            api_url: DEFAULT_API_URL.into(),
85            auth_url: DEFAULT_AUTH_URL.into(),
86            project_id: String::new(),
87            device: PasqalDevice::EmuFree,
88            client_id: String::new(),
89            client_secret: None,
90            http_timeout: Duration::from_secs(30),
91        }
92    }
93}
94
95#[derive(Debug, Clone, Deserialize)]
96struct Auth0Response {
97    access_token: String,
98    expires_in: u64,
99    #[serde(default)]
100    #[allow(dead_code)]
101    token_type: String,
102}
103
104#[derive(Debug)]
105struct CachedToken {
106    access_token: String,
107    expires_at: Instant,
108}
109
110impl CachedToken {
111    fn is_valid(&self) -> bool {
112        Instant::now() + Duration::from_secs(TOKEN_REFRESH_SKEW_SECS) < self.expires_at
113    }
114}
115
116/// Live Pasqal backend. Holds a reqwest client and a cached Auth0 token.
117pub struct PasqalBackend {
118    cfg: PasqalConfig,
119    http: reqwest::Client,
120    token: Arc<Mutex<Option<CachedToken>>>,
121}
122
123impl PasqalBackend {
124    pub fn new(cfg: PasqalConfig) -> Result<Self, QuantumError> {
125        let http = reqwest::Client::builder()
126            .timeout(cfg.http_timeout)
127            .build()
128            .map_err(|e| QuantumError::Transport(e.to_string()))?;
129        Ok(Self {
130            cfg,
131            http,
132            token: Arc::new(Mutex::new(None)),
133        })
134    }
135
136    pub fn config(&self) -> &PasqalConfig {
137        &self.cfg
138    }
139
140    async fn get_token(&self) -> Result<String, QuantumError> {
141        let mut slot = self.token.lock().await;
142        if let Some(t) = slot.as_ref() {
143            if t.is_valid() {
144                return Ok(t.access_token.clone());
145            }
146        }
147
148        let secret = self
149            .cfg
150            .client_secret
151            .as_ref()
152            .ok_or_else(|| QuantumError::Auth("missing client_secret".into()))?;
153        if self.cfg.client_id.is_empty() {
154            return Err(QuantumError::Auth("missing client_id".into()));
155        }
156
157        let body = serde_json::json!({
158            "grant_type": "client_credentials",
159            "client_id": self.cfg.client_id,
160            "client_secret": secret,
161            "audience": AUTH_AUDIENCE,
162        });
163
164        let resp = self
165            .http
166            .post(&self.cfg.auth_url)
167            .json(&body)
168            .send()
169            .await
170            .map_err(|e| QuantumError::Transport(e.to_string()))?;
171
172        if !resp.status().is_success() {
173            let status = resp.status();
174            let text = resp.text().await.unwrap_or_default();
175            return Err(QuantumError::Auth(format!("auth0 {}: {}", status, text)));
176        }
177
178        let parsed: Auth0Response = resp
179            .json()
180            .await
181            .map_err(|e| QuantumError::Serde(e.to_string()))?;
182
183        let token = CachedToken {
184            access_token: parsed.access_token.clone(),
185            expires_at: Instant::now() + Duration::from_secs(parsed.expires_in),
186        };
187        *slot = Some(token);
188        Ok(parsed.access_token)
189    }
190
191    /// Submit a pre-built Pulser-format JSON sequence directly. Useful when
192    /// the caller already has a golden JSON from the Python Pulser SDK.
193    pub async fn submit_raw_sequence(
194        &self,
195        sequence_json: serde_json::Value,
196        runs: u32,
197    ) -> Result<JobHandle, QuantumError> {
198        let token = self.get_token().await?;
199        let body = serde_json::json!({
200            "sequence_builder": sequence_json,
201            "jobs": [{ "runs": runs, "variables": {} }],
202            "device_type": self.cfg.device.as_api_str(),
203            "project_id": self.cfg.project_id,
204        });
205
206        let url = format!("{}/api/v1/batches", self.cfg.api_url);
207        let resp = self
208            .http
209            .post(&url)
210            .bearer_auth(&token)
211            .json(&body)
212            .send()
213            .await
214            .map_err(|e| QuantumError::Transport(e.to_string()))?;
215
216        let status = resp.status();
217        let text = resp
218            .text()
219            .await
220            .map_err(|e| QuantumError::Transport(e.to_string()))?;
221        if !status.is_success() {
222            return Err(QuantumError::Rejected(format!("{}: {}", status, text)));
223        }
224
225        let parsed: BatchCreateResponse = serde_json::from_str(&text)
226            .map_err(|e| QuantumError::Serde(format!("{}: {}", e, text)))?;
227        let job_id = parsed
228            .data
229            .jobs
230            .first()
231            .map(|j| j.id.clone())
232            .unwrap_or_else(|| parsed.data.id.clone());
233        Ok(JobHandle {
234            backend: "pasqal",
235            job_id,
236            batch_id: Some(parsed.data.id),
237        })
238    }
239}
240
241/// Build a Pulser abstract-repr-compatible JSON for a constant-pulse evolution
242/// on a named 2D register. Best-effort for `AnalogDevice` with a single global
243/// Rydberg channel. Matches the stable subset of Pulser v0.15+ output.
244pub fn build_sequence_json(
245    register: &[(String, [f64; 2])],
246    device: PasqalDevice,
247    params: EvolutionParams,
248) -> serde_json::Value {
249    let atoms: Vec<serde_json::Value> = register
250        .iter()
251        .map(|(name, p)| serde_json::json!({ "name": name, "x": p[0], "y": p[1] }))
252        .collect();
253
254    serde_json::json!({
255        "version": "1",
256        "name": "weft-evolution",
257        "device": { "name": device.pulser_device_name() },
258        "register": atoms,
259        "channels": { "rydberg_global": "rydberg_global" },
260        "variables": {},
261        "operations": [
262            {
263                "op": "pulse",
264                "channel": "rydberg_global",
265                "protocol": "min-delay",
266                "amplitude": {
267                    "kind": "constant",
268                    "duration": params.duration_ns,
269                    "value": params.omega_rad_per_us,
270                },
271                "detuning": {
272                    "kind": "constant",
273                    "duration": params.duration_ns,
274                    "value": params.detuning_rad_per_us,
275                },
276                "phase": params.phase_rad,
277                "post_phase_shift": 0.0,
278            },
279            { "op": "measure", "basis": "ground-rydberg" }
280        ],
281        "measurement": "ground-rydberg"
282    })
283}
284
285/// Parse Pasqal measurement bitstrings into a `QuantumResults` with per-atom
286/// Rydberg-excitation probabilities.
287pub fn parse_results(bitstrings: Vec<Vec<u8>>, n_atoms: usize) -> QuantumResults {
288    let shots = bitstrings.len() as u32;
289    let mut counts = vec![0u64; n_atoms];
290    for bs in &bitstrings {
291        for (i, &b) in bs.iter().take(n_atoms).enumerate() {
292            if b != 0 {
293                counts[i] += 1;
294            }
295        }
296    }
297    let denom = shots.max(1) as f64;
298    let rydberg_probs: Vec<f64> = counts.iter().map(|&c| c as f64 / denom).collect();
299    QuantumResults {
300        bitstrings,
301        rydberg_probs,
302        shots,
303    }
304}
305
306// ---------------------------------------------------------------------------
307// REST response types
308// ---------------------------------------------------------------------------
309
310#[derive(Debug, Deserialize)]
311struct BatchCreateResponse {
312    data: BatchData,
313}
314
315#[derive(Debug, Deserialize)]
316struct BatchData {
317    id: String,
318    #[serde(default)]
319    jobs: Vec<JobSummary>,
320}
321
322#[derive(Debug, Deserialize)]
323struct JobSummary {
324    id: String,
325}
326
327#[derive(Debug, Deserialize)]
328struct JobStatusResponse {
329    data: JobStatusData,
330}
331
332#[derive(Debug, Deserialize)]
333struct JobStatusData {
334    #[allow(dead_code)]
335    id: String,
336    status: String,
337}
338
339#[derive(Debug, Deserialize)]
340struct BatchResultsResponse {
341    #[serde(default)]
342    data: Vec<JobResultData>,
343}
344
345#[derive(Debug, Deserialize)]
346struct JobResultData {
347    #[allow(dead_code)]
348    #[serde(default)]
349    id: Option<String>,
350    /// Measurement counts: bitstring (as "01001") -> count.
351    /// Pasqal returns counts keyed by bitstring; we expand to per-shot array.
352    #[serde(default)]
353    counts: std::collections::HashMap<String, u64>,
354}
355
356fn status_from_str(s: &str) -> JobStatus {
357    match s.to_ascii_uppercase().as_str() {
358        "PENDING" => JobStatus::Pending,
359        "RUNNING" => JobStatus::Running,
360        "DONE" => JobStatus::Done,
361        "CANCELED" | "CANCELLED" => JobStatus::Canceled,
362        _ => JobStatus::Error,
363    }
364}
365
366fn counts_to_bitstrings(counts: &std::collections::HashMap<String, u64>) -> Vec<Vec<u8>> {
367    let mut out = Vec::new();
368    for (bs, &n) in counts {
369        let row: Vec<u8> = bs.chars().map(|c| if c == '1' { 1 } else { 0 }).collect();
370        for _ in 0..n {
371            out.push(row.clone());
372        }
373    }
374    out
375}
376
377// ---------------------------------------------------------------------------
378// QuantumBackend impl
379// ---------------------------------------------------------------------------
380
381#[async_trait]
382impl QuantumBackend for PasqalBackend {
383    fn name(&self) -> &'static str {
384        "pasqal"
385    }
386
387    fn max_qubits(&self) -> usize {
388        self.cfg.device.max_qubits()
389    }
390
391    async fn health_check(&self) -> Result<BackendStatus, QuantumError> {
392        // Minimal reachability probe: HEAD / on the API base.
393        let url = format!("{}/api/v1/devices", self.cfg.api_url);
394        let reachable = match self.get_token().await {
395            Ok(token) => self
396                .http
397                .get(&url)
398                .bearer_auth(&token)
399                .send()
400                .await
401                .map(|r| r.status().is_success())
402                .unwrap_or(false),
403            Err(_) => false,
404        };
405        Ok(BackendStatus {
406            name: "pasqal",
407            reachable,
408            queue_depth: None,
409            estimated_wait: None,
410            max_qubits: self.max_qubits(),
411        })
412    }
413
414    async fn submit_evolution(
415        &self,
416        register: &[(String, [f64; 2])],
417        _state: &QuantumCognitiveState,
418        params: EvolutionParams,
419    ) -> Result<JobHandle, QuantumError> {
420        if register.len() > self.max_qubits() {
421            return Err(QuantumError::GraphTooLarge {
422                nodes: register.len(),
423                max: self.max_qubits(),
424            });
425        }
426        let seq = build_sequence_json(register, self.cfg.device, params);
427        self.submit_raw_sequence(seq, params.shots).await
428    }
429
430    async fn poll(&self, handle: &JobHandle) -> Result<JobStatus, QuantumError> {
431        let token = self.get_token().await?;
432        let url = format!("{}/api/v2/jobs/{}", self.cfg.api_url, handle.job_id);
433        let resp = self
434            .http
435            .get(&url)
436            .bearer_auth(&token)
437            .send()
438            .await
439            .map_err(|e| QuantumError::Transport(e.to_string()))?;
440        let status_code = resp.status();
441        let text = resp
442            .text()
443            .await
444            .map_err(|e| QuantumError::Transport(e.to_string()))?;
445        if !status_code.is_success() {
446            return Err(QuantumError::Rejected(format!("{}: {}", status_code, text)));
447        }
448        let parsed: JobStatusResponse = serde_json::from_str(&text)
449            .map_err(|e| QuantumError::Serde(format!("{}: {}", e, text)))?;
450        Ok(status_from_str(&parsed.data.status))
451    }
452
453    async fn get_results(
454        &self,
455        handle: &JobHandle,
456    ) -> Result<Option<QuantumResults>, QuantumError> {
457        let batch_id = handle
458            .batch_id
459            .as_ref()
460            .ok_or_else(|| QuantumError::Rejected("job handle missing batch_id".into()))?;
461        let token = self.get_token().await?;
462        let url = format!("{}/api/v1/batches/{}/results", self.cfg.api_url, batch_id);
463        let resp = self
464            .http
465            .get(&url)
466            .bearer_auth(&token)
467            .send()
468            .await
469            .map_err(|e| QuantumError::Transport(e.to_string()))?;
470        let status_code = resp.status();
471        if status_code.as_u16() == 404 || status_code.as_u16() == 204 {
472            return Ok(None);
473        }
474        let text = resp
475            .text()
476            .await
477            .map_err(|e| QuantumError::Transport(e.to_string()))?;
478        if !status_code.is_success() {
479            return Err(QuantumError::Rejected(format!("{}: {}", status_code, text)));
480        }
481        let parsed: BatchResultsResponse = serde_json::from_str(&text)
482            .map_err(|e| QuantumError::Serde(format!("{}: {}", e, text)))?;
483        let Some(first) = parsed.data.into_iter().next() else {
484            return Ok(None);
485        };
486        if first.counts.is_empty() {
487            return Ok(None);
488        }
489        let bitstrings = counts_to_bitstrings(&first.counts);
490        let n_atoms = bitstrings.first().map(|b| b.len()).unwrap_or(0);
491        Ok(Some(parse_results(bitstrings, n_atoms)))
492    }
493
494    async fn cancel(&self, handle: &JobHandle) -> Result<(), QuantumError> {
495        let token = self.get_token().await?;
496        let url = format!("{}/api/v2/jobs/{}/cancel", self.cfg.api_url, handle.job_id);
497        let resp = self
498            .http
499            .patch(&url)
500            .bearer_auth(&token)
501            .send()
502            .await
503            .map_err(|e| QuantumError::Transport(e.to_string()))?;
504        if !resp.status().is_success() {
505            let status = resp.status();
506            let text = resp.text().await.unwrap_or_default();
507            return Err(QuantumError::Rejected(format!("{}: {}", status, text)));
508        }
509        Ok(())
510    }
511}
512
513// ---------------------------------------------------------------------------
514// Tests
515// ---------------------------------------------------------------------------
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn device_api_strings() {
523        assert_eq!(PasqalDevice::EmuFree.as_api_str(), "EMU_FREE");
524        assert_eq!(PasqalDevice::EmuTn.as_api_str(), "EMU_TN");
525        assert_eq!(PasqalDevice::Fresnel.as_api_str(), "FRESNEL");
526    }
527
528    #[test]
529    fn build_sequence_json_shape() {
530        let reg = vec![("q0".into(), [0.0, 0.0]), ("q1".into(), [5.0, 0.0])];
531        let j = build_sequence_json(&reg, PasqalDevice::EmuFree, EvolutionParams::default());
532        assert_eq!(j["version"], "1");
533        assert_eq!(j["device"]["name"], "AnalogDevice");
534        assert_eq!(j["register"].as_array().unwrap().len(), 2);
535        assert_eq!(j["register"][0]["name"], "q0");
536        assert_eq!(j["register"][1]["x"], 5.0);
537        assert_eq!(j["operations"][0]["op"], "pulse");
538        assert_eq!(j["operations"][0]["channel"], "rydberg_global");
539        assert_eq!(j["operations"][1]["op"], "measure");
540    }
541
542    #[test]
543    fn parse_results_computes_rydberg_probs() {
544        // 4 shots, 3 atoms. Atom 0 excited 3/4, atom 1 excited 1/4, atom 2 excited 2/4.
545        let bs = vec![vec![1, 0, 1], vec![1, 0, 0], vec![1, 1, 1], vec![0, 0, 0]];
546        let r = parse_results(bs, 3);
547        assert_eq!(r.shots, 4);
548        assert!((r.rydberg_probs[0] - 0.75).abs() < 1e-9);
549        assert!((r.rydberg_probs[1] - 0.25).abs() < 1e-9);
550        assert!((r.rydberg_probs[2] - 0.5).abs() < 1e-9);
551    }
552
553    #[test]
554    fn counts_to_bitstrings_expands_correctly() {
555        let mut counts = std::collections::HashMap::new();
556        counts.insert("10".to_string(), 2u64);
557        counts.insert("01".to_string(), 1u64);
558        let bs = counts_to_bitstrings(&counts);
559        assert_eq!(bs.len(), 3);
560        let ones_atom_0: u32 = bs.iter().map(|b| b[0] as u32).sum();
561        let ones_atom_1: u32 = bs.iter().map(|b| b[1] as u32).sum();
562        assert_eq!(ones_atom_0, 2);
563        assert_eq!(ones_atom_1, 1);
564    }
565
566    #[test]
567    fn status_from_str_maps_known_values() {
568        assert_eq!(status_from_str("PENDING"), JobStatus::Pending);
569        assert_eq!(status_from_str("running"), JobStatus::Running);
570        assert_eq!(status_from_str("DONE"), JobStatus::Done);
571        assert_eq!(status_from_str("CANCELED"), JobStatus::Canceled);
572        assert_eq!(status_from_str("CANCELLED"), JobStatus::Canceled);
573        assert_eq!(status_from_str("nonsense"), JobStatus::Error);
574    }
575
576    #[test]
577    fn missing_client_secret_is_auth_error() {
578        let cfg = PasqalConfig {
579            client_id: "abc".into(),
580            client_secret: None,
581            ..Default::default()
582        };
583        let backend = PasqalBackend::new(cfg).unwrap();
584        let rt = tokio::runtime::Builder::new_current_thread()
585            .enable_all()
586            .build()
587            .unwrap();
588        let err = rt.block_on(backend.get_token()).unwrap_err();
589        assert!(matches!(err, QuantumError::Auth(_)));
590    }
591
592    #[test]
593    fn max_qubits_reflects_device() {
594        let b = PasqalBackend::new(PasqalConfig {
595            device: PasqalDevice::Fresnel,
596            ..Default::default()
597        })
598        .unwrap();
599        assert_eq!(b.max_qubits(), 100);
600    }
601}
602
603#[cfg(test)]
604mod live_tests {
605    //! End-to-end tests using a local wiremock server. These exercise the
606    //! full HTTP + JSON path without hitting Pasqal Cloud.
607    use super::*;
608    use wiremock::matchers::{bearer_token, method, path};
609    use wiremock::{Mock, MockServer, ResponseTemplate};
610
611    fn test_config(mock: &MockServer, auth_path: &str) -> PasqalConfig {
612        PasqalConfig {
613            api_url: mock.uri(),
614            auth_url: format!("{}{}", mock.uri(), auth_path),
615            project_id: "proj-1".into(),
616            device: PasqalDevice::EmuFree,
617            client_id: "client-1".into(),
618            client_secret: Some("secret-1".into()),
619            http_timeout: Duration::from_secs(5),
620        }
621    }
622
623    #[tokio::test]
624    async fn token_is_fetched_and_cached() {
625        let mock = MockServer::start().await;
626        Mock::given(method("POST"))
627            .and(path("/oauth/token"))
628            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
629                "access_token": "tok-abc",
630                "expires_in": 3600,
631                "token_type": "Bearer"
632            })))
633            .expect(1)
634            .mount(&mock)
635            .await;
636
637        let cfg = test_config(&mock, "/oauth/token");
638        let backend = PasqalBackend::new(cfg).unwrap();
639
640        let t1 = backend.get_token().await.unwrap();
641        let t2 = backend.get_token().await.unwrap();
642        assert_eq!(t1, "tok-abc");
643        assert_eq!(t2, "tok-abc");
644        // Auth endpoint should have been hit exactly once (cache hit on 2nd call).
645    }
646
647    #[tokio::test]
648    async fn submit_evolution_posts_batch_and_returns_handle() {
649        let mock = MockServer::start().await;
650        mount_auth_async(&mock, "tok-xyz", 3600).await;
651
652        Mock::given(method("POST"))
653            .and(path("/api/v1/batches"))
654            .and(bearer_token("tok-xyz"))
655            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
656                "data": {
657                    "id": "batch-1",
658                    "jobs": [{ "id": "job-1" }]
659                }
660            })))
661            .expect(1)
662            .mount(&mock)
663            .await;
664
665        let cfg = test_config(&mock, "/oauth/token");
666        let backend = PasqalBackend::new(cfg).unwrap();
667        let reg = vec![("q0".into(), [0.0, 0.0]), ("q1".into(), [5.0, 0.0])];
668        let state = QuantumCognitiveState::uniform(2, &[0, 1]);
669        let handle = backend
670            .submit_evolution(&reg, &state, EvolutionParams::default())
671            .await
672            .unwrap();
673        assert_eq!(handle.backend, "pasqal");
674        assert_eq!(handle.job_id, "job-1");
675        assert_eq!(handle.batch_id.as_deref(), Some("batch-1"));
676    }
677
678    #[tokio::test]
679    async fn poll_parses_job_status() {
680        let mock = MockServer::start().await;
681        mount_auth_async(&mock, "tok-1", 3600).await;
682
683        Mock::given(method("GET"))
684            .and(path("/api/v2/jobs/job-1"))
685            .and(bearer_token("tok-1"))
686            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
687                "data": { "id": "job-1", "status": "DONE" }
688            })))
689            .mount(&mock)
690            .await;
691
692        let cfg = test_config(&mock, "/oauth/token");
693        let backend = PasqalBackend::new(cfg).unwrap();
694        let handle = JobHandle {
695            backend: "pasqal",
696            job_id: "job-1".into(),
697            batch_id: Some("batch-1".into()),
698        };
699        assert_eq!(backend.poll(&handle).await.unwrap(), JobStatus::Done);
700    }
701
702    #[tokio::test]
703    async fn get_results_parses_counts_to_probs() {
704        let mock = MockServer::start().await;
705        mount_auth_async(&mock, "tok-2", 3600).await;
706
707        Mock::given(method("GET"))
708            .and(path("/api/v1/batches/batch-1/results"))
709            .and(bearer_token("tok-2"))
710            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
711                "data": [{
712                    "id": "job-1",
713                    "counts": { "10": 3, "01": 1 }
714                }]
715            })))
716            .mount(&mock)
717            .await;
718
719        let cfg = test_config(&mock, "/oauth/token");
720        let backend = PasqalBackend::new(cfg).unwrap();
721        let handle = JobHandle {
722            backend: "pasqal",
723            job_id: "job-1".into(),
724            batch_id: Some("batch-1".into()),
725        };
726        let results = backend.get_results(&handle).await.unwrap().unwrap();
727        assert_eq!(results.shots, 4);
728        // Atom 0 excited in "10" -> 3/4 = 0.75
729        assert!((results.rydberg_probs[0] - 0.75).abs() < 1e-9);
730        // Atom 1 excited in "01" -> 1/4 = 0.25
731        assert!((results.rydberg_probs[1] - 0.25).abs() < 1e-9);
732    }
733
734    #[tokio::test]
735    async fn get_results_returns_none_on_404() {
736        let mock = MockServer::start().await;
737        mount_auth_async(&mock, "tok-3", 3600).await;
738
739        Mock::given(method("GET"))
740            .and(path("/api/v1/batches/batch-x/results"))
741            .respond_with(ResponseTemplate::new(404))
742            .mount(&mock)
743            .await;
744
745        let cfg = test_config(&mock, "/oauth/token");
746        let backend = PasqalBackend::new(cfg).unwrap();
747        let handle = JobHandle {
748            backend: "pasqal",
749            job_id: "job-x".into(),
750            batch_id: Some("batch-x".into()),
751        };
752        assert!(backend.get_results(&handle).await.unwrap().is_none());
753    }
754
755    #[tokio::test]
756    async fn cancel_sends_patch_request() {
757        let mock = MockServer::start().await;
758        mount_auth_async(&mock, "tok-4", 3600).await;
759
760        Mock::given(method("PATCH"))
761            .and(path("/api/v2/jobs/job-7/cancel"))
762            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
763            .expect(1)
764            .mount(&mock)
765            .await;
766
767        let cfg = test_config(&mock, "/oauth/token");
768        let backend = PasqalBackend::new(cfg).unwrap();
769        let handle = JobHandle {
770            backend: "pasqal",
771            job_id: "job-7".into(),
772            batch_id: Some("batch-7".into()),
773        };
774        backend.cancel(&handle).await.unwrap();
775    }
776
777    #[tokio::test]
778    async fn too_large_register_rejected_before_submit() {
779        let mock = MockServer::start().await;
780        let cfg = test_config(&mock, "/oauth/token");
781        let backend = PasqalBackend::new(cfg).unwrap();
782        // EMU_FREE max_qubits = 25; submit 26.
783        let reg: Vec<(String, [f64; 2])> = (0..26)
784            .map(|i| (format!("q{}", i), [i as f64 * 5.0, 0.0]))
785            .collect();
786        let state = QuantumCognitiveState::uniform(26, &(0..26).collect::<Vec<_>>());
787        let err = backend
788            .submit_evolution(&reg, &state, EvolutionParams::default())
789            .await
790            .unwrap_err();
791        assert!(matches!(
792            err,
793            QuantumError::GraphTooLarge { nodes: 26, max: 25 }
794        ));
795    }
796
797    async fn mount_auth_async(mock: &MockServer, token: &str, expires_in: u64) {
798        Mock::given(method("POST"))
799            .and(path("/oauth/token"))
800            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
801                "access_token": token,
802                "expires_in": expires_in,
803                "token_type": "Bearer"
804            })))
805            .mount(mock)
806            .await;
807    }
808}