1use 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";
33const TOKEN_REFRESH_SKEW_SECS: u64 = 300;
35
36#[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 pub fn pulser_device_name(self) -> &'static str {
63 match self {
64 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
116pub 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 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
241pub 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
285pub 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#[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 #[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#[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 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#[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(®, 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 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 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 }
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(®, &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 assert!((results.rydberg_probs[0] - 0.75).abs() < 1e-9);
730 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 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(®, &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}