quantrs2_tytan/sampler/hardware/
azure_quantum.rs1use scirs2_core::ndarray::{Array, Ix2};
7use scirs2_core::random::{thread_rng, Rng, RngExt};
8use std::collections::HashMap;
9
10use quantrs2_anneal::QuboModel;
11
12use super::super::{SampleResult, Sampler, SamplerError, SamplerResult};
13
14#[derive(Debug, Clone)]
16pub enum AzureSolver {
17 SimulatedAnnealing,
19 ParallelTempering,
21 TabuSearch,
23 PopulationAnnealing,
25 SubstrateMonteCarlo,
27 IonQ,
29 Quantinuum,
31 Rigetti,
33}
34
35#[derive(Debug, Clone)]
37pub struct AzureQuantumConfig {
38 pub subscription_id: String,
40 pub resource_group: String,
42 pub workspace_name: String,
44 pub solver: AzureSolver,
46 pub timeout: u64,
48 pub solver_params: HashMap<String, String>,
50}
51
52impl Default for AzureQuantumConfig {
53 fn default() -> Self {
54 Self {
55 subscription_id: String::new(),
56 resource_group: String::new(),
57 workspace_name: String::new(),
58 solver: AzureSolver::SimulatedAnnealing,
59 timeout: 300,
60 solver_params: HashMap::new(),
61 }
62 }
63}
64
65pub struct AzureQuantumSampler {
70 config: AzureQuantumConfig,
71}
72
73impl AzureQuantumSampler {
74 #[must_use]
80 pub const fn new(config: AzureQuantumConfig) -> Self {
81 Self { config }
82 }
83
84 #[must_use]
92 pub fn with_workspace(
93 subscription_id: &str,
94 resource_group: &str,
95 workspace_name: &str,
96 ) -> Self {
97 Self {
98 config: AzureQuantumConfig {
99 subscription_id: subscription_id.to_string(),
100 resource_group: resource_group.to_string(),
101 workspace_name: workspace_name.to_string(),
102 ..Default::default()
103 },
104 }
105 }
106
107 #[must_use]
109 pub const fn with_solver(mut self, solver: AzureSolver) -> Self {
110 self.config.solver = solver;
111 self
112 }
113
114 #[must_use]
116 pub const fn with_timeout(mut self, timeout: u64) -> Self {
117 self.config.timeout = timeout;
118 self
119 }
120
121 #[must_use]
123 pub fn with_param(mut self, key: String, value: String) -> Self {
124 self.config.solver_params.insert(key, value);
125 self
126 }
127}
128
129impl Sampler for AzureQuantumSampler {
130 fn run_qubo(
131 &self,
132 qubo: &(Array<f64, Ix2>, HashMap<String, usize>),
133 shots: usize,
134 ) -> SamplerResult<Vec<SampleResult>> {
135 let (matrix, var_map) = qubo;
137
138 let n_vars = var_map.len();
140
141 match self.config.solver {
143 AzureSolver::IonQ => {
144 if n_vars > 29 {
145 return Err(SamplerError::InvalidParameter(
146 "IonQ currently supports up to 29 qubits".to_string(),
147 ));
148 }
149 }
150 AzureSolver::Quantinuum => {
151 if n_vars > 20 {
152 return Err(SamplerError::InvalidParameter(
153 "Quantinuum currently supports up to 20 qubits for this application"
154 .to_string(),
155 ));
156 }
157 }
158 AzureSolver::Rigetti => {
159 if n_vars > 40 {
160 return Err(SamplerError::InvalidParameter(
161 "Rigetti currently supports up to 40 qubits".to_string(),
162 ));
163 }
164 }
165 _ => {
166 if n_vars > 10000 {
168 return Err(SamplerError::InvalidParameter(
169 "Problem size exceeds Azure QIO limits".to_string(),
170 ));
171 }
172 }
173 }
174
175 let idx_to_var: HashMap<usize, String> = var_map
177 .iter()
178 .map(|(var, &idx)| (idx, var.clone()))
179 .collect();
180
181 let mut qubo_model = QuboModel::new(n_vars);
183
184 for i in 0..n_vars {
186 if matrix[[i, i]] != 0.0 {
187 qubo_model.set_linear(i, matrix[[i, i]])?;
188 }
189
190 for j in (i + 1)..n_vars {
191 if matrix[[i, j]] != 0.0 {
192 qubo_model.set_quadratic(i, j, matrix[[i, j]])?;
193 }
194 }
195 }
196
197 #[cfg(feature = "azure_quantum")]
199 {
200 if self.config.subscription_id.is_empty()
202 || self.config.resource_group.is_empty()
203 || self.config.workspace_name.is_empty()
204 {
205 return Err(SamplerError::ApiError(
206 "Azure Quantum workspace not configured. Call with_workspace() to provide \
207 subscription_id, resource_group, and workspace_name."
208 .to_string(),
209 ));
210 }
211
212 let (provider_id, target_id) = match self.config.solver {
214 AzureSolver::SimulatedAnnealing => {
215 ("microsoft.qio", "microsoft.simulatedannealing.cpu")
216 }
217 AzureSolver::ParallelTempering => {
218 ("microsoft.qio", "microsoft.paralleltempering.cpu")
219 }
220 AzureSolver::TabuSearch => ("microsoft.qio", "microsoft.tabu.cpu"),
221 AzureSolver::PopulationAnnealing => {
222 ("microsoft.qio", "microsoft.populationannealing.cpu")
223 }
224 AzureSolver::SubstrateMonteCarlo => {
225 ("microsoft.qio", "microsoft.substochastic.cpu")
226 }
227 AzureSolver::IonQ => ("ionq", "ionq.qpu"),
228 AzureSolver::Quantinuum => ("quantinuum", "quantinuum.hqs-lt-s1"),
229 AzureSolver::Rigetti => ("rigetti", "rigetti.qpu.aspen-m-3"),
230 };
231
232 let terms: Vec<serde_json::Value> = {
234 let mut t = Vec::new();
235 for i in 0..n_vars {
236 if matrix[[i, i]] != 0.0 {
237 t.push(serde_json::json!({
238 "c": matrix[[i, i]],
239 "ids": [i]
240 }));
241 }
242 for j in (i + 1)..n_vars {
243 if matrix[[i, j]] != 0.0 {
244 t.push(serde_json::json!({
245 "c": matrix[[i, j]],
246 "ids": [i, j]
247 }));
248 }
249 }
250 }
251 t
252 };
253
254 let problem_payload = serde_json::json!({
255 "cost_function": {
256 "type": "ising",
257 "version": "1.1",
258 "terms": terms
259 }
260 });
261
262 let mut solver_params = serde_json::json!({
263 "timeout": self.config.timeout,
264 "seed": 42u64
265 });
266
267 for (k, v) in &self.config.solver_params {
269 if let Some(obj) = solver_params.as_object_mut() {
270 obj.insert(k.clone(), serde_json::Value::String(v.clone()));
271 }
272 }
273
274 let job_payload = serde_json::json!({
275 "id": uuid::Uuid::new_v4().to_string(),
276 "name": "qubo_job",
277 "providerId": provider_id,
278 "target": target_id,
279 "inputDataFormat": "microsoft.qio.v2",
280 "outputDataFormat": "microsoft.qio-results.v2",
281 "inputParams": solver_params,
282 "inputData": problem_payload
283 });
284
285 let base_url = format!(
287 "https://{region}.quantum.azure.com/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Quantum/Workspaces/{ws}",
288 region = "eastus",
289 sub = self.config.subscription_id,
290 rg = self.config.resource_group,
291 ws = self.config.workspace_name
292 );
293 let jobs_url = format!("{base_url}/jobs");
294
295 let client = reqwest::blocking::Client::builder()
296 .timeout(std::time::Duration::from_secs(30))
297 .build()
298 .map_err(|e| SamplerError::ApiError(format!("Failed to build HTTP client: {e}")))?;
299
300 let submit_resp = client
302 .post(&jobs_url)
303 .header("Content-Type", "application/json")
304 .json(&job_payload)
305 .send()
306 .map_err(|e| {
307 SamplerError::ApiError(format!(
308 "Failed to submit Azure Quantum job: {e}. \
309 Ensure workspace credentials are correct and network is accessible."
310 ))
311 })?;
312
313 if !submit_resp.status().is_success() {
314 let status = submit_resp.status();
315 let body = submit_resp
316 .text()
317 .unwrap_or_else(|_| "<unreadable>".to_string());
318 return Err(SamplerError::ApiError(format!(
319 "Azure Quantum job submission failed (HTTP {status}): {body}"
320 )));
321 }
322
323 let job_response: serde_json::Value = submit_resp.json().map_err(|e| {
324 SamplerError::ApiError(format!("Failed to parse Azure Quantum response: {e}"))
325 })?;
326
327 let job_id = job_response["id"]
328 .as_str()
329 .ok_or_else(|| {
330 SamplerError::ApiError("Missing job ID in Azure Quantum response".to_string())
331 })?
332 .to_string();
333
334 let poll_interval = 5u64;
336 let max_polls = self.config.timeout / poll_interval + 1;
337 let mut poll_count = 0u64;
338 loop {
339 if poll_count >= max_polls {
340 return Err(SamplerError::ApiError(format!(
341 "Azure Quantum job {job_id} timed out after {max_polls} polls ({}s)",
342 self.config.timeout
343 )));
344 }
345 poll_count += 1;
346 std::thread::sleep(std::time::Duration::from_secs(poll_interval));
347
348 let status_url = format!("{jobs_url}/{job_id}");
349 let status_resp = client.get(&status_url).send().map_err(|e| {
350 SamplerError::ApiError(format!("Failed to poll Azure job status: {e}"))
351 })?;
352
353 let status_json: serde_json::Value = status_resp.json().map_err(|e| {
354 SamplerError::ApiError(format!("Failed to parse Azure status: {e}"))
355 })?;
356
357 match status_json["status"].as_str() {
358 Some("Succeeded") => break,
359 Some("Failed") => {
360 let reason = status_json["errorData"]["message"]
361 .as_str()
362 .unwrap_or("unknown error");
363 return Err(SamplerError::ApiError(format!(
364 "Azure Quantum job failed: {reason}"
365 )));
366 }
367 Some("Cancelled") => {
368 return Err(SamplerError::ApiError(
369 "Azure Quantum job was cancelled".to_string(),
370 ));
371 }
372 _ => continue,
373 }
374 }
375
376 let output_url = format!("{jobs_url}/{job_id}/output");
378 let output_resp = client.get(&output_url).send().map_err(|e| {
379 SamplerError::ApiError(format!("Failed to retrieve Azure results: {e}"))
380 })?;
381
382 let output_json: serde_json::Value = output_resp.json().map_err(|e| {
383 SamplerError::ApiError(format!("Failed to parse Azure result: {e}"))
384 })?;
385
386 if let Some(solutions_arr) = output_json["solutions"].as_array() {
388 let mut parsed: Vec<SampleResult> = solutions_arr
389 .iter()
390 .map(|sol| {
391 let energy = sol["cost"].as_f64().unwrap_or(0.0);
392 let occurrences = sol["count"].as_u64().unwrap_or(1) as usize;
393 let assignments: HashMap<String, bool> =
394 if let Some(config_obj) = sol["configuration"].as_object() {
395 config_obj
396 .iter()
397 .filter_map(|(k, v)| {
398 k.parse::<usize>().ok().and_then(|idx| {
399 idx_to_var.get(&idx).map(|name| {
400 (name.clone(), v.as_i64().unwrap_or(0) > 0)
401 })
402 })
403 })
404 .collect()
405 } else {
406 HashMap::new()
407 };
408 SampleResult {
409 assignments,
410 energy,
411 occurrences,
412 }
413 })
414 .collect();
415
416 parsed.sort_by(|a, b| {
417 a.energy
418 .partial_cmp(&b.energy)
419 .unwrap_or(std::cmp::Ordering::Equal)
420 });
421
422 return Ok(parsed);
423 }
424 }
426
427 let mut results = Vec::new();
429 let mut rng = thread_rng();
430
431 let unique_solutions = match self.config.solver {
433 AzureSolver::SimulatedAnnealing => shots.min(50),
434 AzureSolver::ParallelTempering => shots.min(100),
435 AzureSolver::TabuSearch => shots.min(30),
436 AzureSolver::PopulationAnnealing => shots.min(200),
437 AzureSolver::SubstrateMonteCarlo => shots.min(150),
438 AzureSolver::IonQ | AzureSolver::Quantinuum | AzureSolver::Rigetti => {
439 shots.min(1000)
441 }
442 };
443
444 for _ in 0..unique_solutions {
445 let assignments: HashMap<String, bool> = idx_to_var
446 .values()
447 .map(|name| (name.clone(), rng.random::<bool>()))
448 .collect();
449
450 let mut energy = 0.0;
452 for (var_name, &val) in &assignments {
453 let i = var_map[var_name];
454 if val {
455 energy += matrix[[i, i]];
456 for (other_var, &other_val) in &assignments {
457 let j = var_map[other_var];
458 if i < j && other_val {
459 energy += matrix[[i, j]];
460 }
461 }
462 }
463 }
464
465 let occurrences = match self.config.solver {
467 AzureSolver::IonQ | AzureSolver::Quantinuum | AzureSolver::Rigetti => {
468 rng.random_range(1..=(shots / unique_solutions + 10))
470 }
471 _ => {
472 1
474 }
475 };
476
477 results.push(SampleResult {
478 assignments,
479 energy,
480 occurrences,
481 });
482 }
483
484 results.sort_by(|a, b| {
486 a.energy
487 .partial_cmp(&b.energy)
488 .unwrap_or(std::cmp::Ordering::Equal)
489 });
490
491 results.truncate(shots.min(100));
493
494 Ok(results)
495 }
496
497 fn run_hobo(
498 &self,
499 hobo: &(
500 Array<f64, scirs2_core::ndarray::IxDyn>,
501 HashMap<String, usize>,
502 ),
503 shots: usize,
504 ) -> SamplerResult<Vec<SampleResult>> {
505 use scirs2_core::ndarray::Ix2;
506
507 if hobo.0.ndim() <= 2 {
509 let qubo_matrix = hobo.0.clone().into_dimensionality::<Ix2>().map_err(|e| {
511 SamplerError::InvalidParameter(format!(
512 "Failed to convert HOBO to QUBO dimensionality: {e}"
513 ))
514 })?;
515 let qubo = (qubo_matrix, hobo.1.clone());
516 self.run_qubo(&qubo, shots)
517 } else {
518 Err(SamplerError::InvalidParameter(
520 "Azure Quantum doesn't support HOBO problems directly. Use a quadratization technique first.".to_string()
521 ))
522 }
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_azure_quantum_config() {
532 let config = AzureQuantumConfig::default();
533 assert_eq!(config.timeout, 300);
534 assert!(matches!(config.solver, AzureSolver::SimulatedAnnealing));
535 }
536
537 #[test]
538 fn test_azure_quantum_sampler_creation() {
539 let sampler =
540 AzureQuantumSampler::with_workspace("test-subscription", "test-rg", "test-workspace")
541 .with_solver(AzureSolver::ParallelTempering)
542 .with_timeout(600)
543 .with_param("temperature".to_string(), "0.5".to_string());
544
545 assert_eq!(sampler.config.subscription_id, "test-subscription");
546 assert_eq!(sampler.config.resource_group, "test-rg");
547 assert_eq!(sampler.config.workspace_name, "test-workspace");
548 assert_eq!(sampler.config.timeout, 600);
549 assert!(matches!(
550 sampler.config.solver,
551 AzureSolver::ParallelTempering
552 ));
553 }
554
555 #[test]
556 fn test_azure_solver_types() {
557 let solvers = [
558 AzureSolver::SimulatedAnnealing,
559 AzureSolver::ParallelTempering,
560 AzureSolver::TabuSearch,
561 AzureSolver::PopulationAnnealing,
562 AzureSolver::SubstrateMonteCarlo,
563 AzureSolver::IonQ,
564 AzureSolver::Quantinuum,
565 AzureSolver::Rigetti,
566 ];
567
568 assert_eq!(solvers.len(), 8);
569 }
570}