cqlib_core/device/result.rs
1// This code is part of Cqlib.
2//
3// (C) Copyright China Telecom Quantum Group 2026
4//
5// This code is licensed under the Apache License, Version 2.0. You may
6// obtain a copy of this license in the LICENSE.txt file in the root directory
7// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8//
9// Any modifications or derivative works of this code must retain this
10// copyright notice, and modified files need to carry a notice indicating
11// that they have been altered from the originals.
12
13//! Quantum measurement results and execution status.
14//!
15//! This module provides types for representing quantum measurement outcomes,
16//! execution status tracking, and result aggregation with histogram data.
17//!
18//! # Core Types
19//!
20//! - [`Outcome`]: Compact bit vector for measurement outcomes
21//! - [`Status`]: Job execution state machine
22//! - [`ExecutionResult`]: Complete measurement results with metadata
23//!
24//! # Example
25//!
26//! ```rust,ignore
27//! use cqlib_core::device::{Outcome, ExecutionResult};
28//! use std::collections::HashMap;
29//!
30//! // Create a new execution result
31//! let mut result = ExecutionResult::new(
32//! "task-001".to_string(),
33//! vec![],
34//! 1000, // shots
35//! 2, // num_qubits
36//! Some("simulator".to_string()),
37//! None,
38//! );
39//!
40//! // Mark as running and complete with counts
41//! result.start(None);
42//! let mut counts = HashMap::new();
43//! counts.insert(Outcome::from_bitstring("00").unwrap(), 520);
44//! counts.insert(Outcome::from_bitstring("11").unwrap(), 480);
45//! result.finish(counts, None);
46//!
47//! // Calculate probabilities
48//! result.calc_probabilities();
49//! ```
50
51use crate::circuit::Qubit;
52use smallvec::SmallVec;
53use std::collections::HashMap;
54use std::fmt;
55use std::fmt::Write;
56use std::str::FromStr;
57use thiserror::Error;
58use time::OffsetDateTime;
59
60/// Number of bits per u64 chunk.
61const BITS_PER_CHUNK: usize = 64;
62
63/// Error type for outcome parsing failures.
64#[derive(Error, Debug, Clone, PartialEq, Eq)]
65pub enum OutcomeError {
66 /// Invalid character in binary string at given index.
67 #[error("Invalid character '{1}' in binary string at index {0}")]
68 InvalidCharacter(usize, char),
69}
70
71/// Measurement outcome as a compact bit vector.
72///
73/// # Bit Layout & Endianness (Little-Endian)
74///
75/// This struct uses **Little-Endian** storage:
76/// - **Low Index holds Low Bits**: `measured_qubits[0]` is stored in `chunks[0]`, bit 0.
77/// - **High Index holds High Bits**: `measured_qubits[64]` is stored in `chunks[1]`, bit 0.
78///
79/// ## Visual Mapping
80///
81/// ```text
82/// Memory Index: [ 0 ] [ 1 ] ...
83/// Data (u64): [ 63 ... 0 ] [ 127 ... 64 ]
84/// ^ ^ ^
85/// | | |
86/// Qubit Index: MSB LSB LSB of next chunk
87/// ```
88///
89/// Note: while stored as little-endian chunks, bitstrings are written in standard
90/// binary form (MSB left). The original input width is not stored: parsing `"001"`
91/// and `"1"` produces the same outcome. Callers must provide the desired output
92/// width to [`Outcome::to_bitstring`].
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94#[repr(transparent)]
95pub struct Outcome(pub SmallVec<[u64; 4]>);
96
97impl Outcome {
98 /// Creates an outcome from raw chunks.
99 #[inline(always)]
100 pub fn new(chunks: SmallVec<[u64; 4]>) -> Self {
101 Self(chunks)
102 }
103
104 /// Creates an outcome with the given bit indices set to one.
105 ///
106 /// `width` is the number of logical bits in the outcome. Indices greater
107 /// than or equal to `width` are ignored. Bit index 0 is stored as the least
108 /// significant bit, matching [`Outcome`]'s little-endian layout.
109 pub fn from_indices(width: usize, indices: impl IntoIterator<Item = usize>) -> Self {
110 let mut chunks = SmallVec::from_elem(0u64, width.div_ceil(BITS_PER_CHUNK));
111 for index in indices {
112 if index < width {
113 chunks[index / BITS_PER_CHUNK] |= 1u64 << (index % BITS_PER_CHUNK);
114 }
115 }
116 Self(chunks)
117 }
118
119 /// Creates an outcome from bits in little-endian order.
120 ///
121 /// `bits[0]` becomes bit index 0, the least-significant bit.
122 pub(crate) fn from_lsb_bits(bits: &[bool]) -> Self {
123 let mut chunks = SmallVec::from_elem(0u64, bits.len().div_ceil(BITS_PER_CHUNK));
124 for (index, value) in bits.iter().copied().enumerate() {
125 if value {
126 chunks[index / BITS_PER_CHUNK] |= 1u64 << (index % BITS_PER_CHUNK);
127 }
128 }
129 Self(chunks)
130 }
131
132 /// Create an Outcome from a binary string (e.g., "101").
133 ///
134 /// # Convention
135 /// - The string is interpreted as **Big-Endian visual order**.
136 /// - The **last character** (rightmost) corresponds to **Qubit 0** (LSB).
137 /// - The **first character** (leftmost) corresponds to **Qubit N-1** (MSB).
138 ///
139 /// Example: `from_bitstring("100")` -> Qubit 2=1, Qubit 1=0, Qubit 0=0.
140 pub fn from_bitstring(s: &str) -> Result<Self, OutcomeError> {
141 let len = s.len();
142 if len == 0 {
143 return Ok(Self(SmallVec::new()));
144 }
145
146 // Calculate required chunks (ceil(len / 64))
147 let num_chunks = len.div_ceil(BITS_PER_CHUNK);
148
149 // Pre-allocate and initialize to 0
150 let mut chunks = smallvec::smallvec![0u64; num_chunks];
151
152 // Iterate from rightmost character (qubit 0)
153 // enumerate() gives the qubit index directly (0, 1, 2...)
154 for (qubit_index, c) in s.chars().rev().enumerate() {
155 match c {
156 '1' => {
157 let chunk_idx = qubit_index / BITS_PER_CHUNK;
158 let bit_idx = qubit_index % BITS_PER_CHUNK;
159
160 // Safety: chunk size is calculated from len, always valid
161 chunks[chunk_idx] |= 1u64 << bit_idx;
162 }
163 '0' => {
164 // Default is 0, no operation needed
165 }
166 _ => {
167 // Calculate original string index for accurate error message
168 let original_index = len - 1 - qubit_index;
169 return Err(OutcomeError::InvalidCharacter(original_index, c));
170 }
171 }
172 }
173
174 Ok(Self(chunks))
175 }
176
177 /// Returns true if the bit at given index is 1.
178 #[inline]
179 pub fn is_one(&self, index: usize) -> bool {
180 let chunk_idx = index / BITS_PER_CHUNK;
181 let bit_idx = index % BITS_PER_CHUNK;
182
183 // Safety: SmallVec indexing is fast, usually bounds check is elided in loops
184 // if the compiler can prove bounds, but explicit check is safe.
185 if let Some(chunk) = self.0.get(chunk_idx) {
186 (chunk >> bit_idx) & 1 == 1
187 } else {
188 false
189 }
190 }
191
192 /// Formats the outcome as a binary string with the requested width.
193 ///
194 /// Output is big-endian (MSB left), padded with leading zeros. If the
195 /// requested width is smaller than the stored value, high bits are truncated.
196 pub fn to_bitstring(&self, num_qubits: usize) -> String {
197 if num_qubits == 0 {
198 return String::new();
199 }
200
201 let mut s = String::with_capacity(num_qubits);
202 let full_chunks = num_qubits / BITS_PER_CHUNK;
203 let remainder_bits = num_qubits % BITS_PER_CHUNK;
204 // Little-endian storage, but usually printed Big-endian (Q_n...Q_0)
205 if remainder_bits > 0 {
206 let chunk = self.0.get(full_chunks).copied().unwrap_or(0);
207 let mask = if remainder_bits == 64 {
208 u64::MAX
209 } else {
210 (1u64 << remainder_bits) - 1
211 };
212 let val = chunk & mask;
213 write!(&mut s, "{:0width$b}", val, width = remainder_bits).unwrap();
214 }
215
216 // Iterate from high index chunk to low index chunk
217 for i in (0..full_chunks).rev() {
218 let chunk = self.0.get(i).copied().unwrap_or(0);
219 // Print full 64 bits
220 // e.g., Chunk 0: bit 63 ... bit 0 prints as "0...01"
221 write!(&mut s, "{:064b}", chunk).unwrap();
222 }
223 s
224 }
225}
226
227impl FromStr for Outcome {
228 type Err = OutcomeError;
229
230 fn from_str(s: &str) -> Result<Self, Self::Err> {
231 Self::from_bitstring(s)
232 }
233}
234
235/// Execution status of a quantum job.
236#[derive(Debug, Clone, PartialEq, Eq, Hash)]
237pub enum Status {
238 /// Task has been submitted and is queued for execution.
239 Queued,
240 /// Task is currently running.
241 Running,
242 /// Task completed successfully.
243 Completed,
244 /// Task failed with an error.
245 Failed { error_msg: String, error_code: i32 },
246 /// Task was cancelled by the user.
247 Cancelled,
248}
249
250impl fmt::Display for Status {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 match self {
253 Status::Queued => write!(f, "Queued"),
254 Status::Running => write!(f, "Running"),
255 Status::Completed => write!(f, "Completed"),
256 Status::Failed {
257 error_msg,
258 error_code,
259 } => {
260 write!(f, "Failed (Code {}): {}", error_code, error_msg)
261 }
262 Status::Cancelled => write!(f, "Cancelled"),
263 }
264 }
265}
266
267impl Status {
268 /// Returns true if the status is terminal (completed, failed, or cancelled).
269 pub fn is_terminal(&self) -> bool {
270 matches!(
271 self,
272 Status::Completed | Status::Failed { .. } | Status::Cancelled
273 )
274 }
275
276 /// Returns true if the job completed successfully.
277 pub fn is_success(&self) -> bool {
278 matches!(self, Status::Completed)
279 }
280}
281
282/// Quantum measurement results with histogram data and metadata.
283///
284/// # Measurement Mapping & Bit Ordering
285///
286/// There is a strictly **1-to-1 correspondence** between the indices of the
287/// [`qubits`](ExecutionResult::qubits) vector and the bits in the [`Outcome`] keys.
288///
289/// - **Rule**: The qubit at `qubits[i]` corresponds to the **$i$-th bit** (value $2^i$) in the outcome.
290/// - **String Representation**: Since binary strings are printed in Big-Endian (MSB left),
291/// the string order is the **reverse** of the `qubits` vector order.
292///
293/// ## Example Mapping
294///
295/// Suppose we measure two qubits: `qubits = vec![Qubit(2), Qubit(5)]`.
296///
297/// | Index | Qubit ID | Outcome Bit (Storage) | String Position ("01") |
298/// | :---: | :---: | :---: | :---: |
299/// | **0** | `Qubit(2)` | Bit 0 (LSB, $2^0$) | **Last** (Rightmost) |
300/// | **1** | `Qubit(5)` | Bit 1 (MSB, $2^1$) | **First** (Leftmost) |
301///
302/// If the result is `Qubit(2) = 1` and `Qubit(5) = 0`:
303/// - **Storage**: `...001` (Bit 0 is 1)
304/// - **String**: `"01"` (Qubit 5 is '0', Qubit 2 is '1')
305#[derive(Debug, Clone)]
306pub struct ExecutionResult {
307 /// Unique task identifier.
308 task_id: String,
309 /// Number of shots executed.
310 shots: usize,
311 /// Number of qubits measured.
312 num_qubits: usize,
313 /// Measured qubits.
314 qubits: Vec<Qubit>,
315 /// Current execution status.
316 status: Status,
317 /// Task creation timestamp.
318 created_at: OffsetDateTime,
319 /// Execution start timestamp (None if not started).
320 started_at: Option<OffsetDateTime>,
321 /// Execution finish timestamp (None if not finished).
322 finished_at: Option<OffsetDateTime>,
323 /// Backend name where executed.
324 backend: Option<String>,
325 /// Measurement counts per outcome.
326 counts: HashMap<Outcome, usize>,
327 /// Calculated probabilities (computed on demand).
328 probabilities: Option<HashMap<Outcome, f64>>,
329}
330
331impl ExecutionResult {
332 /// Creates a new execution result in Queued status.
333 pub fn new(
334 task_id: String,
335 qubits: Vec<Qubit>,
336 shots: usize,
337 num_qubits: usize,
338 backend: Option<String>,
339 created_at: Option<OffsetDateTime>,
340 ) -> Self {
341 Self {
342 task_id,
343 status: Status::Queued,
344 shots,
345 qubits,
346 num_qubits,
347 created_at: created_at.unwrap_or(OffsetDateTime::now_utc()),
348 started_at: None,
349 finished_at: None,
350 backend,
351 counts: HashMap::new(),
352 probabilities: None,
353 }
354 }
355
356 /// Creates a completed execution result from measurement counts.
357 ///
358 /// This applies the same lifecycle as `new`, `start`, `finish`, and
359 /// `calc_probabilities`, but keeps simulator call sites focused on
360 /// producing counts.
361 pub fn from_counts(
362 task_id: String,
363 qubits: Vec<Qubit>,
364 shots: usize,
365 num_qubits: usize,
366 backend: Option<String>,
367 counts: HashMap<Outcome, usize>,
368 ) -> Self {
369 let mut result = Self::new(task_id, qubits, shots, num_qubits, backend, None);
370 result.start(None).finish(counts, None).calc_probabilities();
371 result
372 }
373
374 /// Marks the job as running.
375 pub fn start(&mut self, t: Option<OffsetDateTime>) -> &mut Self {
376 self.started_at = t.or(Some(OffsetDateTime::now_utc()));
377 self.status = Status::Running;
378 self
379 }
380
381 /// Marks the job as completed with measurement counts.
382 pub fn finish(
383 &mut self,
384 counts: HashMap<Outcome, usize>,
385 t: Option<OffsetDateTime>,
386 ) -> &mut Self {
387 self.counts = counts;
388 self.probabilities = None;
389 self.status = Status::Completed;
390 self.finished_at = t.or(Some(OffsetDateTime::now_utc()));
391 self
392 }
393
394 /// Marks the job as failed with error code and message.
395 pub fn fail(&mut self, msg: String, code: i32) {
396 self.status = Status::Failed {
397 error_msg: msg,
398 error_code: code,
399 };
400 }
401
402 /// Marks the job as cancelled.
403 pub fn cancel(&mut self) {
404 self.status = Status::Cancelled;
405 }
406
407 /// Calculates probabilities from counts.
408 pub fn calc_probabilities(&mut self) -> &mut Self {
409 let total_observed: usize = self.counts.values().sum();
410 if total_observed == 0 {
411 self.probabilities = None;
412 } else {
413 let mut probs = HashMap::with_capacity(self.counts.len());
414 let inv_total = 1.0 / (total_observed as f64);
415 for (outcome, &count) in &self.counts {
416 probs.insert(outcome.clone(), count as f64 * inv_total);
417 }
418 self.probabilities = Some(probs);
419 }
420 self
421 }
422
423 /// Returns the task ID.
424 pub fn task_id(&self) -> &str {
425 self.task_id.as_str()
426 }
427
428 /// Returns the number of shots.
429 pub fn shots(&self) -> usize {
430 self.shots
431 }
432
433 /// Returns the number of qubits.
434 pub fn num_qubits(&self) -> usize {
435 self.num_qubits
436 }
437
438 /// Returns the measured qubits.
439 pub fn qubits(&self) -> &Vec<Qubit> {
440 &self.qubits
441 }
442
443 /// Returns the execution status.
444 pub fn status(&self) -> &Status {
445 &self.status
446 }
447
448 /// Returns the creation timestamp.
449 pub fn created_at(&self) -> &OffsetDateTime {
450 &self.created_at
451 }
452
453 /// Returns the start timestamp (None if not started).
454 pub fn started_at(&self) -> &Option<OffsetDateTime> {
455 &self.started_at
456 }
457
458 /// Returns the finish timestamp (None if not finished).
459 pub fn finished_at(&self) -> &Option<OffsetDateTime> {
460 &self.finished_at
461 }
462
463 /// Returns the backend name.
464 pub fn backend(&self) -> Option<&String> {
465 self.backend.as_ref()
466 }
467
468 /// Returns the measurement counts.
469 pub fn counts(&self) -> &HashMap<Outcome, usize> {
470 &self.counts
471 }
472
473 /// Returns the calculated probabilities (None if not computed).
474 pub fn probabilities(&self) -> &Option<HashMap<Outcome, f64>> {
475 &self.probabilities
476 }
477}
478
479#[cfg(test)]
480mod api_tests {
481 use super::{ExecutionResult, Outcome, Status};
482 use crate::circuit::Qubit;
483 use std::collections::HashMap;
484
485 #[test]
486 fn outcome_from_indices_uses_little_endian_bit_indices() {
487 let outcome = Outcome::from_indices(66, [0, 2, 65, 99]);
488
489 assert!(outcome.is_one(0));
490 assert!(!outcome.is_one(1));
491 assert!(outcome.is_one(2));
492 assert!(outcome.is_one(65));
493 assert!(!outcome.is_one(99));
494 }
495
496 #[test]
497 fn execution_result_from_counts_returns_completed_result_with_probabilities() {
498 let zero = Outcome::from_indices(1, []);
499 let one = Outcome::from_indices(1, [0]);
500 let mut counts = HashMap::new();
501 counts.insert(zero.clone(), 3);
502 counts.insert(one.clone(), 1);
503
504 let result = ExecutionResult::from_counts(
505 "task".to_string(),
506 vec![Qubit::new(0)],
507 4,
508 1,
509 Some("backend".to_string()),
510 counts,
511 );
512
513 assert_eq!(result.status(), &Status::Completed);
514 assert_eq!(result.counts().get(&zero), Some(&3));
515 assert_eq!(result.counts().get(&one), Some(&1));
516 let probabilities = result.probabilities().as_ref().unwrap();
517 assert_eq!(probabilities.get(&zero), Some(&0.75));
518 assert_eq!(probabilities.get(&one), Some(&0.25));
519 }
520}
521
522#[cfg(test)]
523#[path = "./result_test.rs"]
524mod result_test;