Repository: rigetti/qcs-sdk-rust (language: python)

Issue:
execution results from QVM and QPU should be exposed through a common struct
The changes made [here](https://github.com/rigetti/qcs-sdk-rust/pull/171/files#diff-2281d28e7ecc3103d60e15be625ee0ed848d19230272184746aa986d36b4ea43L10) broke `ExecutionData` into two structs: `Qvm` and `Qpu`. This is because the structure of data returned on a `QVM`s `registers` and a `QPU`s `readout_data` are different. However, from a users perspective, it should be easy to switch between running their programs on a `QVM` and a `QPU`, and having to account for two different return types adds a lot of friction to that. 

We should re-introduce something akin to the `ExecutionData` struct that is returned as the public facing type for both `QVM` and `QPU` execution results. `Qpu.readout_data` really is just an extension of `Qvm.registers`, so we should be able to rename the current `Qpu` struct, and do some data wrangling to make the `QVM` register data fit.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 27067dc22..63a113838 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,13 @@
 # Cargo / rustc artifacts
 /target
 
+# Python artifacts
+__pycache__/
+
 # Local config
 .env
 venv/
+.venv
 
 # JetBrains
 .idea
diff --git a/Cargo.lock b/Cargo.lock
index 68f6b9043..9a7481f0c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,17 @@
 # It is not intended for manual editing.
 version = 3
 
+[[package]]
+name = "ahash"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
+dependencies = [
+ "getrandom",
+ "once_cell",
+ "version_check",
+]
+
 [[package]]
 name = "aho-corasick"
 version = "0.7.20"
@@ -835,6 +846,15 @@ version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40"
 
+[[package]]
+name = "matrixmultiply"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "add85d4dd35074e6fedc608f8c8f513a3548619a9024b751949ef0e8e45a4d84"
+dependencies = [
+ "rawpointer",
+]
+
 [[package]]
 name = "memchr"
 version = "2.5.0"
@@ -901,6 +921,20 @@ version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
 
+[[package]]
+name = "ndarray"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "rawpointer",
+ "serde",
+]
+
 [[package]]
 name = "nom"
 version = "7.1.3"
@@ -1011,6 +1045,21 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "numpy"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a462c1af5ba1fddec1488c4646993a23ae7931f9e170ccba23e9c7c834277797"
+dependencies = [
+ "ahash",
+ "libc",
+ "ndarray",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "pyo3",
+]
+
 [[package]]
 name = "once_cell"
 version = "1.17.0"
@@ -1153,6 +1202,30 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.50"
@@ -1303,9 +1376,11 @@ dependencies = [
  "futures",
  "hex",
  "indexmap",
+ "itertools",
  "lazy_static",
  "log",
  "maplit",
+ "ndarray",
  "num",
  "qcs-api",
  "qcs-api-client-common",
@@ -1320,6 +1395,7 @@ dependencies = [
  "serde_json",
  "simple_logger",
  "tempfile",
+ "test-case",
  "thiserror",
  "tokio",
  "toml 0.5.11",
@@ -1394,6 +1470,7 @@ dependencies = [
 name = "qcs-sdk-python"
 version = "0.5.0-rc.6"
 dependencies = [
+ "numpy",
  "pyo3",
  "pyo3-asyncio",
  "pyo3-build-config",
@@ -1464,6 +1541,12 @@ dependencies = [
  "getrandom",
 ]
 
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
 [[package]]
 name = "redox_syscall"
 version = "0.2.16"
@@ -1552,9 +1635,9 @@ dependencies = [
 
 [[package]]
 name = "rigetti-pyo3"
-version = "0.1.0-rc.4"
+version = "0.1.0-rc.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "747db1eb6c058767a5528363b21e2a32eef3fa504238c7b643cac68d98b260c8"
+checksum = "8a218a8bb8c01486e4c2e4f01db65fc01174898618512027b54b7c32c4d327bd"
 dependencies = [
  "num-complex",
  "num-traits",
@@ -1899,6 +1982,28 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "test-case"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21d6cf5a7dffb3f9dceec8e6b8ca528d9bd71d36c9f074defb548ce161f598c0"
+dependencies = [
+ "test-case-macros",
+]
+
+[[package]]
+name = "test-case-macros"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e45b7bf6e19353ddd832745c8fcf77a17a93171df7151187f26623f2b75b5b26"
+dependencies = [
+ "cfg-if",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.38"
diff --git a/Cargo.toml b/Cargo.toml
index c9beaea7a..996f7854c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,23 @@
 [workspace]
 members = ["crates/*"]
 
+[workspace.dependencies]
+qcs-api = "0.2.1"
+qcs-api-client-common = "0.4.2"
+qcs-api-client-grpc = "0.4.2"
+qcs-api-client-openapi = "0.5.2"
+quil-rs = "0.15"
+serde_json = "1.0.86"
+tokio = "1.24.2"
+
+# ndarray is used by the `qcs` crate, but it is also used in the `python` crate via a 
+# re-export through the numpy crate. They should be updated as a pair to keep both 
+# crates version of ndarray in sync.
+# Similarly, pyo3 packages (`numpy`, `rigetti-pyo3`, `pyo3*`) track versions together 
+# and need to be updated together.
+ndarray = { version = "0.15.6", features = ["serde"] }
+numpy = "0.17"
+pyo3 = { version = "0.17", features = ["extension-module"] }
+pyo3-asyncio = { version = "0.17", features = ["tokio-runtime"] }
+pyo3-build-config = { version = "0.17" }
+rigetti-pyo3 = { version = "0.1.0-rc.4", features = ["extension-module", "complex"] }
diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml
index 76aaaaa11..66f7b489a 100644
--- a/crates/lib/Cargo.toml
+++ b/crates/lib/Cargo.toml
@@ -19,32 +19,35 @@ futures = "0.3.24"
 indexmap = "1.9.1"
 lazy_static = "1.4.0"
 log = "0.4.17"
+ndarray.workspace = true
 num = { version = "0.4.0", features = ["serde"] }
-qcs-api = "0.2.1"
-qcs-api-client-common = "0.4.2"
-qcs-api-client-openapi = "0.5.2"
-qcs-api-client-grpc = "0.4.2"
-quil-rs = "0.15"
+qcs-api.workspace = true
+qcs-api-client-common.workspace = true
+qcs-api-client-openapi.workspace = true
+qcs-api-client-grpc.workspace = true
+quil-rs.workspace = true
 reqwest = { version = "0.11.12", default-features = false, features = ["rustls-tls", "json"] }
 rmp-serde = "1.1.1"
 serde = { version = "1.0.145", features = ["derive"] }
 serde_bytes = "0.11.7"
-serde_json = "1.0.86"
+serde_json.workspace = true
 thiserror = "1.0.37"
-tokio = { version = "1.24.2", features = ["fs"] }
+tokio = { workspace = true, features = ["fs"] }
 toml = "0.5.9"
 uuid = { version = "1.2.1", features = ["v4"] }
 tonic = { version = "0.8.2", features = ["tls", "tls-roots"] }
 zmq = { version = "0.9.2", features = ["vendored"] }
+itertools = "0.10.5"
 
 [dev-dependencies]
 erased-serde = "0.3.23"
 float-cmp = "0.9.0"
 hex = "0.4.3"
 maplit = "1.0.2"
-qcs-api-client-grpc = { version = "0.4.2", features = ["server"] }
+qcs-api-client-grpc = { workspace = true, features = ["server"] }
 simple_logger = { version = "2.3.0", default-features = false }
 tempfile = "3.3.0"
 tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
 warp = { version = "0.3.3", default-features = false }
 regex = "1.7.0"
+test-case = "2.2.2"
diff --git a/crates/lib/examples/execute.rs b/crates/lib/examples/execute.rs
index fe628d97b..4c7d5b97d 100644
--- a/crates/lib/examples/execute.rs
+++ b/crates/lib/examples/execute.rs
@@ -17,7 +17,7 @@ async fn main() {
 
     let result = exe
         .with_parameter("theta", 0, PI)
-        .execute_on_qpu("Aspen-11")
+        .execute_on_qpu("Aspen-M-3")
         .await
         .expect("Program should execute successfully");
 
diff --git a/crates/lib/examples/local.rs b/crates/lib/examples/local.rs
index 903f286b4..4b40e93ee 100644
--- a/crates/lib/examples/local.rs
+++ b/crates/lib/examples/local.rs
@@ -18,9 +18,9 @@ async fn main() {
 
     let result = exe
         .with_parameter("theta", 0, PI)
-        .execute_on_qpu("Aspen-11")
+        .execute_on_qpu("Aspen-M-3")
         .await
         .expect("Program should execute successfully");
 
-    println!("{:?}", result);
+    println!("{result:?}");
 }
diff --git a/crates/lib/examples/parametric_compilation.rs b/crates/lib/examples/parametric_compilation.rs
index f699cc8f6..eb62b4178 100644
--- a/crates/lib/examples/parametric_compilation.rs
+++ b/crates/lib/examples/parametric_compilation.rs
@@ -5,7 +5,6 @@ use std::f64::consts::PI;
 use std::time::Duration;
 
 use qcs::Executable;
-use qcs_api_client_grpc::models::controller::{readout_values::Values, IntegerReadoutValues};
 
 const PROGRAM: &str = r#"
 DECLARE ro BIT
@@ -30,32 +29,30 @@ async fn main() {
         let theta = step * f64::from(i);
         let data = exe
             .with_parameter("theta", 0, theta)
-            .execute_on_qpu("Aspen-11")
+            .execute_on_qpu("Aspen-M-3")
             .await
             .expect("Executed program on QPU");
         total_execution_time += data
             .duration
-            .expect("Aspen-11 should always report duration");
+            .expect("Aspen-M-3 should always report duration");
 
-        let ro_readout_data = data
-            .readout_data
-            .get_readout_values_for_field("ro")
+        let first_ro_values = data
+            .result_data
+            .to_register_map()
+            .expect("should be able to create a RegisterMap")
+            .get_register_matrix("ro")
             .expect("readout values should contain 'ro'")
-            .expect("'ro' should contain readout values");
-        let first_ro_data = ro_readout_data
-            .first()
-            .expect("'ro' should contain at least one readout value")
-            .clone();
-        let first_ro_values = first_ro_data
-            .expect("first readout value should ")
-            .values
-            .expect("'ro' should have readout values");
-        if let Values::IntegerValues(IntegerReadoutValues { mut values }) = first_ro_values {
-            parametric_measurements.append(&mut values)
+            .as_integer()
+            .expect("'ro' should be a register of integer values")
+            .row(0)
+            .to_owned();
+
+        for value in &first_ro_values {
+            parametric_measurements.push(*value)
         }
     }
 
-    println!("Total execution time: {:?}", total_execution_time);
+    println!("Total execution time: {total_execution_time:?}");
 
     for measurement in parametric_measurements {
         if measurement == 1 {
diff --git a/crates/lib/examples/quil_t.rs b/crates/lib/examples/quil_t.rs
index 3779bf4d6..a4bc53345 100644
--- a/crates/lib/examples/quil_t.rs
+++ b/crates/lib/examples/quil_t.rs
@@ -3,7 +3,6 @@
 //! [pyquil]: https://pyquil-docs.rigetti.com/en/stable/quilt_getting_started.html#Another-example:-a-simple-T1-experiment
 
 use qcs::Executable;
-use qcs_api_client_grpc::models::controller::{readout_values::Values, IntegerReadoutValues};
 
 /// This program doesn't do much, the main point is that it will fail if quilc is invoked.
 const PROGRAM: &str = r#"
@@ -20,17 +19,17 @@ async fn main() {
 
     let result = exe
         .compile_with_quilc(false)
-        .execute_on_qpu("Aspen-11")
+        .execute_on_qpu("Aspen-M-3")
         .await
         .expect("Program should execute successfully")
-        .readout_data
-        .get_readout_values("ro".to_string(), 0)
-        .expect("Readout data should include 'ro'")
-        .values
-        .expect("Readout data should include values");
+        .result_data
+        .to_register_map()
+        .expect("should be able to convert execution data to RegisterMap")
+        .get_register_matrix("ro")
+        .expect("Register data should include 'ro'")
+        .as_integer()
+        .expect("ro should be a register of integer values")
+        .to_owned();
 
-    match result {
-        Values::IntegerValues(IntegerReadoutValues { values }) => assert!(!values.is_empty()),
-        _ => panic!("expected IntegerReadoutValues, got {:?}", result),
-    }
+    println!("{result:?}");
 }
diff --git a/crates/lib/src/api.rs b/crates/lib/src/api.rs
index dd0b2acf7..553a43667 100644
--- a/crates/lib/src/api.rs
+++ b/crates/lib/src/api.rs
@@ -63,7 +63,7 @@ pub enum RewriteArithmeticError {
     Rewrite(#[from] rewrite_arithmetic::Error),
 }
 
-/// The result of a call to [`rewrite_arithmetic`] which provides the
+/// The result of a call to [`rewrite_arithmetic()`] which provides the
 /// information necessary to later patch-in memory values to a compiled program.
 #[derive(Clone, Debug, Serialize)]
 pub struct RewriteArithmeticResult {
@@ -122,7 +122,7 @@ pub struct TranslationResult {
 ///
 /// # Errors
 ///
-/// Returns a [`translation::Error`] if translation fails.
+/// Returns a [`TranslationError`] if translation fails.
 pub async fn translate(
     native_quil: &str,
     shots: u16,
@@ -255,7 +255,10 @@ impl From<readout_values::Values> for ExecutionResult {
                     c.values
                         .iter()
                         .map(|c| {
-                            Complex::<f32>::new(c.real.unwrap_or(0.0), c.imaginary.unwrap_or(0.0))
+                            Complex::<f32>::new(
+                                c.real.unwrap_or_default(),
+                                c.imaginary.unwrap_or_default(),
+                            )
                         })
                         .collect(),
                 ),
@@ -300,7 +303,7 @@ impl From<ControllerJobExecutionResult> for ExecutionResults {
 ///
 /// # Errors
 ///
-/// May error if a [`gRPC`] client cannot be constructed, or a [`gRPC`]
+/// May error if a [`Qcs`] client cannot be constructed, or if the `gRPC`
 /// call fails.
 pub async fn retrieve_results(
     job_id: &str,
diff --git a/crates/lib/src/executable.rs b/crates/lib/src/executable.rs
index 78ea5c35a..8f272ef1c 100644
--- a/crates/lib/src/executable.rs
+++ b/crates/lib/src/executable.rs
@@ -9,7 +9,7 @@ use std::time::Duration;
 use qcs_api_client_common::configuration::LoadError;
 use qcs_api_client_common::ClientConfiguration;
 
-use crate::execution_data;
+use crate::execution_data::{self, ResultData};
 use crate::qpu::client::Qcs;
 use crate::qpu::quilc::CompilerOpts;
 use crate::qpu::rewrite_arithmetic;
@@ -25,7 +25,7 @@ use quil_rs::Program;
 ///
 /// ```rust
 /// use qcs_api_client_common::ClientConfiguration;
-/// use qcs::{Executable, RegisterData};
+/// use qcs::Executable;
 ///
 ///
 /// const PROGRAM: &str = r##"
@@ -41,12 +41,27 @@ use quil_rs::Program;
 /// #[tokio::main]
 /// async fn main() {
 ///     let mut result = Executable::from_quil(PROGRAM).with_config(ClientConfiguration::default()).with_shots(4).execute_on_qvm().await.unwrap();
-///     // We know it's i8 because we declared the memory as `BIT` in Quil.
 ///     // "ro" is the only source read from by default if you don't specify a .read_from()
-///     let data = result.registers.remove("ro").expect("Did not receive ro data").into_i8().unwrap();
-///     // In this case, we ran the program for 4 shots, so we know the length is 4.
-///     assert_eq!(data.len(), 4);
-///     for shot in data {
+///
+///     // We first convert the readout data to a [`RegisterMap`] to get a mapping of registers
+///     // (ie. "ro") to a [`RegisterMatrix`], `M`, where M[`shot`][`index`] is the value for
+///     // the memory offset `index` during shot `shot`.
+///     // There are some programs where QPU readout data does not fit into a [`RegisterMap`], in
+///     // which case you should build the matrix you need from [`QpuResultData`] directly. See
+///     // the [`RegisterMap`] documentation for more information on when this transformation
+///     // might fail.
+///     let data = result.result_data
+///                         .to_register_map()
+///                         .expect("should convert to readout map")
+///                         .get_register_matrix("ro")
+///                         .expect("should have data in ro")
+///                         .as_integer()
+///                         .expect("should be integer matrix")
+///                         .to_owned();
+///
+///     // In this case, we ran the program for 4 shots, so we know the number of rows is 4.
+///     assert_eq!(data.nrows(), 4);
+///     for shot in data.rows() {
 ///         // Each shot will contain all the memory, in order, for the vector (or "register") we
 ///         // requested the results of. In this case, "ro" (the default).
 ///         assert_eq!(shot.len(), 2);
@@ -144,20 +159,30 @@ impl<'executable> Executable<'executable, '_> {
     ///         .execute_on_qvm()
     ///         .await
     ///         .unwrap();
-    ///     let first = result
-    ///         .registers
-    ///         .remove("first")
-    ///         .expect("Did not receive first buffer")
-    ///         .into_f64()
-    ///         .expect("Received incorrect data type for first");
-    ///     let second = result
-    ///         .registers
-    ///         .remove("second")
-    ///         .expect("Did not receive second buffer")
-    ///         .into_f64()
-    ///         .expect("Received incorrect data type for second");
-    ///     assert_eq!(first[0][0], 3.141);
-    ///     assert_eq!(second[0][0], 1.234);
+    ///     let first_value = result
+    ///         .result_data
+    ///         .to_register_map()
+    ///         .expect("qvm memory should fit readout map")
+    ///         .get_register_matrix("first")
+    ///         .expect("readout map should have 'first'")
+    ///         .as_real()
+    ///         .expect("should be real numbered register")
+    ///         .get((0, 0))
+    ///         .expect("should have value in first position of first register")
+    ///         .clone();
+    ///     let second_value = result
+    ///         .result_data
+    ///         .to_register_map()
+    ///         .expect("qvm memory should fit readout map")
+    ///         .get_register_matrix("second")
+    ///         .expect("readout map should have 'second'")
+    ///         .as_real()
+    ///         .expect("should be real numbered register")
+    ///         .get((0, 0))
+    ///         .expect("should have value in first position of first register")
+    ///         .clone();
+    ///     assert_eq!(first_value, 3.141);
+    ///     assert_eq!(second_value, 1.234);
     /// }
     /// ```
     #[must_use]
@@ -202,9 +227,27 @@ impl<'executable> Executable<'executable, '_> {
     ///             .with_parameter("theta", 0, theta)
     ///             .with_parameter("theta", 1, theta * 2.0)
     ///             .execute_on_qvm().await.unwrap();
-    ///         let data = result.registers.remove("theta").expect("Could not read theta").into_f64().unwrap();
-    ///         assert_eq!(data[0][0], theta);
-    ///         assert_eq!(data[0][1], theta * 2.0);
+    ///         let theta_register = result
+    ///             .result_data
+    ///             .to_register_map()
+    ///             .expect("should fit readout map")
+    ///             .get_register_matrix("theta")
+    ///             .expect("should have theta")
+    ///             .as_real()
+    ///             .expect("should be real valued register")
+    ///             .to_owned();
+    ///
+    ///         let first = theta_register
+    ///             .get((0, 0))
+    ///             .expect("first index, first shot of theta should have value")
+    ///             .to_owned();
+    ///         let second = theta_register
+    ///             .get((0, 1))
+    ///             .expect("first shot, second_index of theta should have value")
+    ///             .to_owned();
+    ///         
+    ///         assert_eq!(first, theta);
+    ///         assert_eq!(second, theta * 2.0);
     ///     }
     /// }
     /// ```
@@ -240,10 +283,8 @@ impl<'executable> Executable<'executable, '_> {
     }
 }
 
-/// The [`Result`] from executing on the QVM.
-pub type ExecuteResultQVM = Result<execution_data::Qvm, Error>;
-/// The [`Result`] from executing on a QPU.
-pub type ExecuteResultQPU = Result<execution_data::Qpu, Error>;
+/// The [`Result`] from executing on a QPU or QVM.
+pub type ExecutionResult = Result<execution_data::ExecutionData, Error>;
 
 impl Executable<'_, '_> {
     /// Specify a number of times to run the program for each execution. Defaults to 1 run or "shot".
@@ -284,12 +325,12 @@ impl Executable<'_, '_> {
     ///
     /// # Returns
     ///
-    /// A `HashMap<String, ExecutionResult>` where the key is the name of the register that was read from (e.g. "ro").
+    /// An [`ExecutionResult`].
     ///
     /// # Errors
     ///
     /// See [`Error`].
-    pub async fn execute_on_qvm(&mut self) -> ExecuteResultQVM {
+    pub async fn execute_on_qvm(&mut self) -> ExecutionResult {
         let config = self.get_config().await?;
 
         let mut qvm = if let Some(qvm) = self.qvm.take() {
@@ -303,8 +344,8 @@ impl Executable<'_, '_> {
         self.qvm = Some(qvm);
         result
             .map_err(Error::from)
-            .map(|registers| execution_data::Qvm {
-                registers,
+            .map(|registers| execution_data::ExecutionData {
+                result_data: ResultData::Qvm(registers),
                 duration: None,
             })
     }
@@ -370,10 +411,10 @@ impl<'execution> Executable<'_, 'execution> {
     ///
     /// # Returns
     ///
-    /// A `HashMap<String, ExecutionResult>` where the key is the name of the register that was read from (e.g. "ro").
+    /// An [`ExecutionResult`].
     ///
     /// # Errors
-    /// All errors are human readable by way of [`mod@eyre`]. Some common errors are:
+    /// All errors are human readable by way of [`mod@thiserror`]. Some common errors are:
     ///
     /// 1. You are not authenticated for QCS
     /// 1. Your credentials don't have an active reservation for the QPU you requested
@@ -382,7 +423,7 @@ impl<'execution> Executable<'_, 'execution> {
     /// 1. Missing parameters that should be filled with [`Executable::with_parameter`]
     ///
     /// [quilc]: https://github.com/quil-lang/quilc
-    pub async fn execute_on_qpu<S>(&mut self, quantum_processor_id: S) -> ExecuteResultQPU
+    pub async fn execute_on_qpu<S>(&mut self, quantum_processor_id: S) -> ExecutionResult
     where
         S: Into<Cow<'execution, str>>,
     {
@@ -429,10 +470,7 @@ impl<'execution> Executable<'_, 'execution> {
     /// # Errors
     ///
     /// See [`Executable::execute_on_qpu`].
-    pub async fn retrieve_results(
-        &mut self,
-        job_handle: JobHandle<'execution>,
-    ) -> ExecuteResultQPU {
+    pub async fn retrieve_results(&mut self, job_handle: JobHandle<'execution>) -> ExecutionResult {
         let qpu = self.qpu_for_id(job_handle.quantum_processor_id).await?;
         qpu.retrieve_results(job_handle.job_id, job_handle.readout_map)
             .await
@@ -501,7 +539,7 @@ pub enum Error {
     /// [`Executable::retrieve_results`] can invalidate the handle.
     #[error("The job handle was not valid")]
     InvalidJobHandle,
-    /// Occurs when failing to construct a [`QcsClient`].
+    /// Occurs when failing to construct a [`Qcs`] client.
     #[error("The QCS client configuration failed to load")]
     QcsConfigLoadFailure(#[from] LoadError),
 }
diff --git a/crates/lib/src/execution_data.rs b/crates/lib/src/execution_data.rs
index e5f602a37..6d3973841 100644
--- a/crates/lib/src/execution_data.rs
+++ b/crates/lib/src/execution_data.rs
@@ -1,21 +1,66 @@
-use std::collections::HashMap;
+use enum_as_inner::EnumAsInner;
+use num::complex::Complex64;
+use quil_rs::program::SyntaxError;
+use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, HashMap};
 use std::convert::TryFrom;
-use std::num::{ParseIntError, TryFromIntError};
+use std::num::TryFromIntError;
 use std::str::FromStr;
 use std::time::Duration;
 
-use qcs_api_client_grpc::models::controller::ReadoutValues;
-use quil_rs::instruction::MemoryReference;
+use itertools::Itertools;
+use ndarray::prelude::*;
 
-use crate::RegisterData;
+use crate::{
+    qpu::{QpuResultData, ReadoutValues},
+    qvm::QvmResultData,
+    RegisterData,
+};
 
-/// The result of executing an [`Executable`](crate::Executable) via
-/// [`Executable::execute_on_qvm`](crate::Executable::execute_on_qvm).
+/// Represents the two possible types of data returned from either the QVM or a real QPU.
+/// Each variant contains the original data returned from its respective executor.
+///
+/// # Usage
+///
+/// Your usage of [`ResultData`] will depend on the types of programs you are running and where.
+/// The `to_register_map()` method will attempt to build a [`RegisterMap`] out of the data, where each
+/// register name is mapped to a 2-dimensional rectangular [`RegisterMatrix`] where each row
+/// represents the final values in each register index for a particular shot. This is often the
+/// desired form of the data and it is _probably_ what you want. This transformation isn't always
+/// possible, in which case `to_register_map()` will return an error.
+///
+/// To understand why this transformation can fail, we need to understand a bit about how readout data is
+/// returned from the QVM and from a real QPU:
+///
+/// The QVM treats each `DECLARE` statement as initialzing some amount of memory. This memory works
+/// as one might expect it to. It is zero-initialized on each shot, and subsequent writes to the same region
+/// overwrite the previous value. The QVM returns memory at the end of every shot. This means
+/// we get the last value in every memory reference for each shot, which is exactly the
+/// representation we want for a [`RegisterMatrix`]. For this reason, `to_register_map()` should
+/// always succeed for [`ResultData::Qvm`].
+///
+/// The QPU on the other hand doesn't use the same memory model as the QVM. Each memory reference
+/// (ie. "ro\[0\]") is more like a stream than a value in memory. Every `MEASURE` to a memory
+/// reference emits a new value to said stream. This means that the number of values per memory
+/// reference can vary per shot. For this reason, it's not always clear what the final value in
+/// each shot was for a particular reference. When this is the case, `to_register_map()` will return
+/// an error as it's impossible to build a correct [`RegisterMatrix`] from the data without
+/// knowing the intent of the program that was run. Instead, it's recommended to build the
+/// [`RegisterMatrix`] you need from the inner [`QpuResultData`] data using the knowledge of your
+/// program to choose the correct readout values for each shot.
+#[derive(Debug, Clone, PartialEq, EnumAsInner)]
+pub enum ResultData {
+    /// Data returned from the QVM, stored as [`QvmResultData`]
+    Qvm(QvmResultData),
+    /// Readout data returned from the QPU, stored as [`QpuResultData`]
+    Qpu(QpuResultData),
+}
+
+/// The result of executing an [`Executable`](crate::Executable)
 #[derive(Debug, Clone, PartialEq)]
-pub struct Qvm {
-    /// The readout data that was read from the [`Executable`](crate::Executable).
-    /// Key is the name of the register, value is the data of the register after execution.
-    pub registers: HashMap<String, RegisterData>,
+pub struct ExecutionData {
+    /// The [`ResultData`] that was read from the [`Executable`](crate::Executable).
+    pub result_data: ResultData,
     /// The time it took to execute the program on the QPU, not including any network or queueing
     /// time. If paying for on-demand execution, this is the amount you will be billed for.
     ///
@@ -23,138 +68,344 @@ pub struct Qvm {
     pub duration: Option<Duration>,
 }
 
-/// A mapping of readout fields to their [`ReadoutValues`].
-#[derive(Debug, Clone, PartialEq)]
+/// An enum representing every possible register type as a 2 dimensional matrix.
+#[derive(Clone, Debug, EnumAsInner, PartialEq, Serialize, Deserialize)]
+pub enum RegisterMatrix {
+    /// Integer register
+    Integer(Array2<i64>),
+    /// Real numbered register
+    Real(Array2<f64>),
+    /// Complex numbered register
+    Complex(Array2<Complex64>),
+}
+
+/// A mapping of a register name (ie. "ro") to a [`RegisterMatrix`] containing the values for the
+/// register.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 #[repr(transparent)]
-pub struct ReadoutMap(HashMap<MemoryReference, ReadoutValues>);
+pub struct RegisterMap(HashMap<String, RegisterMatrix>);
+
+/// Errors that may occur when trying to build a [`RegisterMatrix`] from execution data
+#[allow(missing_docs)]
+#[derive(Debug, thiserror::Error)]
+pub enum RegisterMatrixConversionError {
+    /// The data could not be fit into a rectangular matrix
+    #[error("The data for register {register} does fit into a rectangular matrix")]
+    InvalidShape { register: String },
+
+    /// The memory reference had no associated readout values
+    #[error("The mapping of {memory_reference} to {alias} had no readout values")]
+    UnmappedAlias {
+        memory_reference: String,
+        alias: String,
+    },
+
+    /// A row of readout values for a register were missing
+    #[error("Missing readout values for {register}[{index}]")]
+    MissingRow { register: String, index: usize },
+
+    /// The memory reference could not be parsed
+    #[error("{0}")]
+    MemoryReferenceParseError(MemoryReferenceParseError),
+}
+
+impl ResultData {
+    /// Convert [`ResultData`] from its inner representation as [`QvmResultData`] or
+    /// [`QpuResultData`] into a [`RegisterMap`]. The [`RegisterMatrix`] for each register will be
+    /// constructed such that each row contains all the final values in the register for a single shot.
+    ///
+    /// # Errors
+    ///
+    /// Returns a [`RegisterMatrixConversionError`] if the inner execution data for any of the
+    /// registers would result in a jagged matrix. [`QpuResultData`] data is captured per measure,
+    /// meaning a value is returned for every measure to a memory reference, not just once per shot.
+    /// This is often the case in programs that use mid-circuit measurement or dynamic control flow,
+    /// where measurements to the same memory reference might occur multiple times in a shot, or be
+    /// skipped conditionally. In these cases, building a rectangular [`RegisterMatrix`] would
+    /// necessitate making assumptions about the data that could skew the data in undesirable ways.
+    /// Instead, it's recommended to manually build a matrix from [`QpuResultData`] that accurately
+    /// selects the last value per-shot based on the program that was run.
+    pub fn to_register_map(&self) -> Result<RegisterMap, RegisterMatrixConversionError> {
+        match self {
+            ResultData::Qvm(data) => RegisterMap::from_qvm_result_data(data),
+            ResultData::Qpu(data) => RegisterMap::from_qpu_result_data(data),
+        }
+    }
+}
+
+impl RegisterMap {
+    /// Returns the [`RegisterMatrix`] for the given register, if it exists.
+    #[must_use]
+    pub fn get_register_matrix(&self, register_name: &str) -> Option<&RegisterMatrix> {
+        self.0.get(register_name)
+    }
 
-impl ReadoutMap {
-    /// Given a known readout field name and index, return the result's [`ReadoutValues`], if any.
+    /// Returns a [`RegisterMap`] with the underlying [`RegisterMatrix`] data
     #[must_use]
-    pub fn get_readout_values(&self, field: String, index: u64) -> Option<ReadoutValues> {
-        let readout_values = self.0.get(&MemoryReference { name: field, index })?;
+    pub fn from_hashmap(map: HashMap<String, RegisterMatrix>) -> Self {
+        Self(map)
+    }
 
-        Some(readout_values.clone())
+    /// Returns a [`RegisterMap`] built from [`QvmResultData`]
+    fn from_qvm_result_data(
+        result_data: &QvmResultData,
+    ) -> Result<Self, RegisterMatrixConversionError> {
+        Ok(Self(
+            result_data
+                .memory
+                .iter()
+                .map(|(name, register)| {
+                    let register_matrix = match register {
+                        RegisterData::I8(data) => Array::from_shape_vec(
+                            (data.len(), data.first().map_or(0, Vec::len)),
+                            data.iter().flatten().copied().map(i64::from).collect(),
+                        )
+                        .map(RegisterMatrix::Integer),
+                        RegisterData::I16(data) => Array::from_shape_vec(
+                            (data.len(), data.first().map_or(0, Vec::len)),
+                            data.iter().flatten().copied().map(i64::from).collect(),
+                        )
+                        .map(RegisterMatrix::Integer),
+                        RegisterData::F64(data) => Array::from_shape_vec(
+                            (data.len(), data.first().map_or(0, Vec::len)),
+                            data.iter().flatten().copied().collect(),
+                        )
+                        .map(RegisterMatrix::Real),
+                        RegisterData::Complex32(data) => Array::from_shape_vec(
+                            (data.len(), data.first().map_or(0, Vec::len)),
+                            data.iter()
+                                .flatten()
+                                .copied()
+                                .map(|c| Complex64::new(c.re.into(), c.im.into()))
+                                .collect(),
+                        )
+                        .map(RegisterMatrix::Complex),
+                    }
+                    .map_err(|_| {
+                        RegisterMatrixConversionError::InvalidShape {
+                            register: name.to_string(),
+                        }
+                    })?;
+                    Ok((name.clone(), register_matrix))
+                })
+                .collect::<Result<HashMap<String, RegisterMatrix>, RegisterMatrixConversionError>>(
+                )?,
+        ))
     }
 
-    /// Given a known readout field name, return the result's [`ReadoutValues`] for all indices, if any.
-    pub fn get_readout_values_for_field(
-        &self,
-        field: &str,
-    ) -> Result<Option<Vec<Option<ReadoutValues>>>, TryFromIntError> {
-        let mut readout_values = Vec::new();
-        for (memref, values) in &self.0 {
-            let MemoryReference { name, index } = memref;
-            let index = usize::try_from(*index)?;
-            if name == field {
-                if readout_values.len() <= index {
-                    readout_values.resize(index + 1, None);
+    /// Attempts to build a [`RegisterMap`] from [`QpuResultData`].
+    ///
+    /// # Errors
+    ///
+    /// This fails if the underlying [`QpuResultData`] data is jagged. See [`RegisterMap`] for more
+    /// detailed explanations of why and when this occurs.
+    fn from_qpu_result_data(
+        qpu_result_data: &QpuResultData,
+    ) -> Result<Self, RegisterMatrixConversionError> {
+        let register_map = qpu_result_data
+                    .mappings
+                    .iter()
+                    // Pair all the memory references with their readout values
+                    .map(|(memory_reference, alias)| {
+                        Ok((
+                            parse_readout_register(memory_reference).map_err(|e| {
+                                RegisterMatrixConversionError::MemoryReferenceParseError(e)
+                            })?,
+                            qpu_result_data.readout_values.get(alias).ok_or_else(||
+                                RegisterMatrixConversionError::UnmappedAlias {
+                                    memory_reference: memory_reference.to_string(),
+                                    alias: alias.to_string(),
+                                },
+                            )?,
+                        ))
+                    })
+                    // Collect into a type that will sort them by memory reference, this allows us
+                    // to make sure indices are sequential.
+                    .collect::<Result<
+                        BTreeMap<MemoryReference, &ReadoutValues>,
+                        RegisterMatrixConversionError,
+                    >>()?;
+
+        // Return an error if any group of memory references don't form a continuous sequence, indicating
+        // that a row is missing
+        let mut reference_windows = register_map.keys().tuple_windows().peekable();
+        // Ensure the first window starts with a zero index
+        if let Some((reference_a, _)) = reference_windows.peek() {
+            if reference_a.index != 0 {
+                return Err(RegisterMatrixConversionError::MissingRow {
+                    register: reference_a.name.clone(),
+                    index: 0,
+                });
+            }
+        }
+        for (reference_a, reference_b) in register_map.keys().tuple_windows() {
+            if reference_a.name == reference_b.name {
+                if reference_a.index + 1 != reference_b.index {
+                    return Err(RegisterMatrixConversionError::MissingRow {
+                        register: reference_a.name.clone(),
+                        index: reference_a.index + 1,
+                    });
                 }
-                readout_values[index] = Some(values.clone());
+            } else if reference_b.index != 0 {
+                return Err(RegisterMatrixConversionError::MissingRow {
+                    register: reference_b.name.clone(),
+                    index: 0,
+                });
             }
         }
 
-        Ok((!readout_values.is_empty()).then_some(readout_values))
-    }
+        Ok(Self(
+            // Iterate over them in reverse so we can initialize each RegisterMatrix with the
+            // correct number of rows
+            register_map.into_iter().try_rfold(
+                HashMap::with_capacity(qpu_result_data.readout_values.len()),
+                |mut register_map, (reference, values)| {
+                    let matrix =
+                        register_map
+                            .entry(reference.name.clone())
+                            .or_insert(match values {
+                                ReadoutValues::Integer(v) => RegisterMatrix::Integer(
+                                    Array2::zeros((v.len(), reference.index + 1)),
+                                ),
+                                ReadoutValues::Complex(v) => RegisterMatrix::Complex(
+                                    Array2::zeros((v.len(), reference.index + 1)),
+                                ),
+                                ReadoutValues::Real(v) => RegisterMatrix::Real(Array2::zeros((
+                                    v.len(),
+                                    reference.index + 1,
+                                ))),
+                            });
 
-    /// `readout_values` maps program-defined readout to result-defined readout, e.g.:
-    ///     { "ro[0]": "q0", "ro[1]": "q1" }
-    /// where `ro[0]` is defined in the original program, and `q0` is what comes back in execution results.
-    /// Here we map the result-defined readout values back to their original result-defined names.
-    pub(crate) fn from_mappings_and_values(
-        readout_mappings: &HashMap<String, String>,
-        readout_values: &HashMap<String, ReadoutValues>,
-    ) -> Self {
-        let result = readout_values
-            .iter()
-            .flat_map(|(readout_name, values)| {
-                readout_mappings
-                    .iter()
-                    .filter(|(_, program_alias)| *program_alias == readout_name)
-                    .map(|(program_name, _)| program_name.as_ref())
-                    .map(parse_readout_register)
-                    .map(|reference| Ok((reference?, values.clone())))
-                    .collect::<Result<Vec<_>, MemoryReferenceParseError>>()
-            })
-            .flatten()
-            .collect::<HashMap<_, _>>()
-            .into();
-
-        result
+                    // Insert the readout values as a column iff it fits within the
+                    // dimensions of the matrix. Otherwise, the readout data must be
+                    // jagged and we return an error.
+                    match (matrix, values) {
+                        (RegisterMatrix::Integer(m), ReadoutValues::Integer(v))
+                            if m.nrows() == v.len() =>
+                        {
+                            m.column_mut(reference.index)
+                                .assign(&Array::from_vec(v.clone()));
+                        }
+                        (RegisterMatrix::Real(m), ReadoutValues::Real(v))
+                            if m.nrows() == v.len() =>
+                        {
+                            m.column_mut(reference.index)
+                                .assign(&Array::from_vec(v.clone()));
+                        }
+                        (RegisterMatrix::Complex(m), ReadoutValues::Complex(v))
+                            if m.nrows() == v.len() =>
+                        {
+                            m.column_mut(reference.index)
+                                .assign(&Array::from_vec(v.clone()));
+                        }
+                        _ => {
+                            return Err(RegisterMatrixConversionError::InvalidShape {
+                                register: reference.name,
+                            })
+                        }
+                    }
+                    Ok(register_map)
+                },
+            )?,
+        ))
     }
 }
 
-impl From<HashMap<MemoryReference, ReadoutValues>> for ReadoutMap {
-    fn from(map: HashMap<MemoryReference, ReadoutValues>) -> Self {
-        Self(map)
-    }
-}
-
-/// The result of executing an [`Executable`](crate::Executable) via
-/// [`Executable::execute_on_qpu`](crate::Executable::execute_on_qpu).
-#[derive(Debug, Clone, PartialEq)]
-pub struct Qpu {
-    /// The data of all readout data that were read from
-    /// (via [`Executable::read_from`](crate::Executable::read_from)). Key is the name of the
-    /// register, value is the data of the register after execution.
-    pub readout_data: ReadoutMap,
-    /// The time it took to execute the program on the QPU, not including any network or queueing
-    /// time. If paying for on-demand execution, this is the amount you will be billed for.
-    ///
-    /// This will always be `None` for QVM execution.
-    pub duration: Option<Duration>,
+// This is a copy of [`quil_rs::instruction::MemoryReference`] that uses `usize` for the index
+// instead of `u64` for compatibility with the containers we use for [`RegisterMap`].
+// It's possible `quil_rs` will use `usize` for its `MemoryReference` in the future. If so, we
+// should use it to replace this.
+// See https://github.com/rigetti/qcs-sdk-rust/issues/224
+#[derive(Debug, PartialEq, PartialOrd, Eq, Ord)]
+struct MemoryReference {
+    name: String,
+    index: usize,
 }
 
 #[derive(Debug, thiserror::Error)]
-pub(crate) enum MemoryReferenceParseError {
-    #[error("Could not parse memory reference: {reason}")]
-    InvalidFormat { reason: String },
+pub enum MemoryReferenceParseError {
+    #[error("{0}")]
+    InvalidFormat(#[from] SyntaxError<quil_rs::instruction::MemoryReference>),
 
-    #[error("Could not parse index from reference name: {0}")]
-    InvalidIndex(#[from] ParseIntError),
+    #[error("Could not convert index from u64 to a usize: {0}")]
+    OversizedIndex(#[from] TryFromIntError),
 }
 
-// Note: MemoryReference may have a from_string in the fututre
 fn parse_readout_register(
     register_name: &str,
 ) -> Result<MemoryReference, MemoryReferenceParseError> {
-    let open_brace =
-        register_name
-            .find('[')
-            .ok_or_else(|| MemoryReferenceParseError::InvalidFormat {
-                reason: "Opening brace not found".into(),
-            })?;
-    let close_brace =
-        register_name
-            .find(']')
-            .ok_or_else(|| MemoryReferenceParseError::InvalidFormat {
-                reason: "Closing brace not found".into(),
-            })?;
-
+    let reference = quil_rs::instruction::MemoryReference::from_str(register_name)?;
     Ok(MemoryReference {
-        name: String::from(&register_name[..open_brace]),
-        index: u64::from_str(&register_name[open_brace + 1..close_brace])?,
+        name: reference.name,
+        index: usize::try_from(reference.index)?,
     })
 }
 
 #[cfg(test)]
-mod describe_readout_map {
+mod describe_register_map {
     use maplit::hashmap;
+    use ndarray::prelude::*;
 
-    use super::{ReadoutMap, ReadoutValues};
+    use crate::qpu::QpuResultData;
+    use crate::qvm::QvmResultData;
+
+    use super::{RegisterData, RegisterMap};
     use qcs_api_client_grpc::models::controller::readout_values::Values;
-    use qcs_api_client_grpc::models::controller::IntegerReadoutValues;
+    use qcs_api_client_grpc::models::controller::{IntegerReadoutValues, ReadoutValues};
 
-    fn dummy_readout_values(v: i32) -> ReadoutValues {
+    fn dummy_readout_values(v: Vec<i32>) -> ReadoutValues {
         ReadoutValues {
-            values: Some(Values::IntegerValues(IntegerReadoutValues {
-                values: vec![v],
-            })),
+            values: Some(Values::IntegerValues(IntegerReadoutValues { values: v })),
         }
     }
 
     #[test]
-    fn it_converts_from_translation_readout_mappings() {
+    fn it_converts_rectangular_qpu_result_data_to_register_map() {
+        let readout_mappings = hashmap! {
+            String::from("ro[1]") => String::from("qB"),
+            String::from("ro[2]") => String::from("qC"),
+            String::from("ro[0]") => String::from("qA"),
+            String::from("bar[0]") => String::from("qE"),
+            String::from("bar[1]") => String::from("qD")
+        };
+
+        let readout_values = hashmap! {
+            String::from("qA") => dummy_readout_values(vec![1, 2]),
+            String::from("qB") => dummy_readout_values(vec![3, 4]),
+            String::from("qC") => dummy_readout_values(vec![5, 6]),
+            String::from("qD") => dummy_readout_values(vec![0, 1]),
+            String::from("qE") => dummy_readout_values(vec![2, 3]),
+        };
+
+        let qpu_result_data =
+            QpuResultData::from_controller_mappings_and_values(&readout_mappings, &readout_values);
+
+        let register_map = RegisterMap::from_qpu_result_data(&qpu_result_data)
+            .expect("Should be able to create RegisterMap from rectangular QPU readout");
+
+        let ro = register_map
+            .get_register_matrix("ro")
+            .expect("RegisterMap should have ro")
+            .as_integer()
+            .expect("Should be a register of integer values");
+
+        let expected_ro = arr2(&[[1, 3, 5], [2, 4, 6]]);
+
+        assert_eq!(ro, expected_ro);
+
+        let bar = register_map
+            .get_register_matrix("bar")
+            .expect("RegisterMap should have bar")
+            .as_integer()
+            .expect("Shout be a register of integer values");
+
+        let expected_bar = arr2(&[[2, 0], [3, 1]]);
+
+        assert_eq!(bar, expected_bar);
+    }
+
+    #[test]
+    fn it_fails_to_convert_missing_readout_indices_to_register_map() {
         let readout_mappings = hashmap! {
             String::from("ro[1]") => String::from("qA"),
             String::from("ro[2]") => String::from("qB"),
@@ -164,42 +415,56 @@ mod describe_readout_map {
         };
 
         let readout_values = hashmap! {
-            String::from("qA") => dummy_readout_values(11),
-            String::from("qB") => dummy_readout_values(22),
-            String::from("qD") => dummy_readout_values(33),
-            String::from("qE") => dummy_readout_values(44),
+            String::from("qA") => dummy_readout_values(vec![11]),
+            String::from("qB") => dummy_readout_values(vec![22]),
+            String::from("qD") => dummy_readout_values(vec![33]),
+            String::from("qE") => dummy_readout_values(vec![44]),
+        };
+
+        let qpu_result_data =
+            QpuResultData::from_controller_mappings_and_values(&readout_mappings, &readout_values);
+
+        RegisterMap::from_qpu_result_data(&qpu_result_data)
+            .expect_err("Should not be able to create RegisterMap from QPU readout with missing indices for a register");
+    }
+
+    #[test]
+    fn it_fails_to_convert_jagged_qpu_result_data_to_register_map() {
+        let readout_mappings = hashmap! {
+            String::from("ro[1]") => String::from("qB"),
+            String::from("ro[2]") => String::from("qC"),
+            String::from("ro[0]") => String::from("qA"),
+        };
+
+        let readout_values = hashmap! {
+            String::from("qA") => dummy_readout_values(vec![1, 2]),
+            String::from("qB") => dummy_readout_values(vec![2]),
+            String::from("qC") => dummy_readout_values(vec![3]),
         };
 
-        let readout_map = ReadoutMap::from_mappings_and_values(&readout_mappings, &readout_values);
-        let ro = readout_map
-            .get_readout_values_for_field("ro")
-            .unwrap()
-            .expect("ReadoutMap should have field `ro`");
-
-        assert_eq!(
-            ro,
-            vec![
-                None,
-                Some(dummy_readout_values(11)),
-                Some(dummy_readout_values(22)),
-            ],
-        );
-
-        let bar = readout_map
-            .get_readout_values_for_field("bar")
-            .unwrap()
-            .expect("ReadoutMap should have field `bar`");
-
-        assert_eq!(
-            bar,
-            vec![
-                None,
-                None,
-                None,
-                Some(dummy_readout_values(33)),
-                None,
-                Some(dummy_readout_values(44))
-            ],
-        );
+        let qpu_result_data =
+            QpuResultData::from_controller_mappings_and_values(&readout_mappings, &readout_values);
+
+        RegisterMap::from_qpu_result_data(&qpu_result_data)
+            .expect_err("Should not be able to create RegisterMap from QPU readout with jagged data for a register");
+    }
+
+    #[test]
+    fn it_converts_from_qvm_result_data() {
+        let qvm_result_data = QvmResultData::from_memory_map(hashmap! {
+            String::from("ro") => RegisterData::I8(vec![vec![1, 0, 1]]),
+        });
+
+        let register_map = RegisterMap::from_qvm_result_data(&qvm_result_data)
+            .expect("Should be able to create RegisterMap from QvmResultData");
+
+        let ro = register_map
+            .get_register_matrix("ro")
+            .expect("RegisterMap should have ro")
+            .as_integer()
+            .expect("Should be a register of integers");
+
+        let expected = arr2(&[[1, 0, 1]]);
+        assert_eq!(ro, expected);
     }
 }
diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs
index 0182bbbca..8337852f3 100644
--- a/crates/lib/src/lib.rs
+++ b/crates/lib/src/lib.rs
@@ -50,13 +50,15 @@
 //! crate allows you to run Quil programs against real QPUs or a QVM
 //! using [`Executable`].
 
-pub use executable::{Error, Executable, ExecuteResultQPU, ExecuteResultQVM, JobHandle, Service};
-pub use execution_data::{Qpu, Qvm, ReadoutMap};
+pub use executable::{Error, Executable, ExecutionResult, JobHandle, Service};
+pub use execution_data::{
+    ExecutionData, RegisterMap, RegisterMatrix, RegisterMatrixConversionError, ResultData,
+};
 pub use register_data::RegisterData;
 
 pub mod api;
 mod executable;
 mod execution_data;
 pub mod qpu;
-mod qvm;
+pub mod qvm;
 mod register_data;
diff --git a/crates/lib/src/qpu/client.rs b/crates/lib/src/qpu/client.rs
index a881253dd..fda3ca87c 100644
--- a/crates/lib/src/qpu/client.rs
+++ b/crates/lib/src/qpu/client.rs
@@ -1,5 +1,5 @@
 //! This module provides methods for getting clients for the
-//! desired API (e.g. ``gRPC`` or ``OpenAPI``) and will properly
+//! desired API (e.g. `gRPC` or `OpenAPI`) and will properly
 //! initialize those clients (e.g. with authentication metadata).
 
 use qcs_api_client_common::ClientConfiguration;
@@ -156,7 +156,7 @@ impl Qcs {
     }
 }
 
-/// Errors that may occur while trying to resolve a ``gRPC`` endpoint
+/// Errors that may occur while trying to resolve a `gRPC` endpoint
 #[derive(Debug, thiserror::Error)]
 pub enum GrpcEndpointError {
     /// Error due to a malformed URI
@@ -176,7 +176,7 @@ pub enum GrpcEndpointError {
     NoEndpoint(String),
 }
 
-/// Errors that may occur while trying to use a ``gRPC`` client
+/// Errors that may occur while trying to use a `gRPC` client
 #[derive(Debug, thiserror::Error)]
 pub enum GrpcClientError {
     /// Error due to failure to resolve the endpoint
@@ -191,12 +191,12 @@ pub enum GrpcClientError {
     #[error("Response body had missing data: {0}")]
     ResponseEmpty(String),
 
-    /// Error due to ``gRPC`` error
+    /// Error due to `gRPC` error
     #[error("gRPC error: {0}")]
     GrpcError(#[from] GrpcError),
 }
 
-/// Errors that may occur while trying to use a [`OpenAPI`] client
+/// Errors that may occur while trying to use an `OpenAPI` client
 #[derive(Debug, thiserror::Error)]
 pub enum OpenApiClientError<T> {
     /// Error due to request failure
diff --git a/crates/lib/src/qpu/execution.rs b/crates/lib/src/qpu/execution.rs
index 00606abfc..84ae138b6 100644
--- a/crates/lib/src/qpu/execution.rs
+++ b/crates/lib/src/qpu/execution.rs
@@ -12,15 +12,16 @@ use quil_rs::Program;
 use tokio::task::{spawn_blocking, JoinError};
 
 use crate::executable::Parameters;
-use crate::execution_data::{MemoryReferenceParseError, Qpu, ReadoutMap};
+use crate::execution_data::{MemoryReferenceParseError, ResultData};
 use crate::qpu::{rewrite_arithmetic, runner::JobId, translation::translate};
-use crate::JobHandle;
+use crate::{ExecutionData, JobHandle};
 
 use super::client::{GrpcClientError, Qcs};
 use super::quilc::{self, CompilerOpts, TargetDevice};
 use super::rewrite_arithmetic::RewrittenProgram;
 use super::runner::{retrieve_results, submit};
 use super::translation::EncryptedTranslationResult;
+use super::QpuResultData;
 use super::{get_isa, IsaError};
 
 /// Contains all the info needed for a single run of an [`crate::Executable`] against a QPU. Can be
@@ -181,7 +182,7 @@ impl<'a> Execution<'a> {
         &self,
         job_id: JobId,
         readout_mappings: HashMap<String, String>,
-    ) -> Result<Qpu, Error> {
+    ) -> Result<ExecutionData, Error> {
         let response = retrieve_results(
             job_id,
             self.quantum_processor_id.as_ref(),
@@ -189,11 +190,11 @@ impl<'a> Execution<'a> {
         )
         .await?;
 
-        Ok(Qpu {
-            readout_data: ReadoutMap::from_mappings_and_values(
+        Ok(ExecutionData {
+            result_data: ResultData::Qpu(QpuResultData::from_controller_mappings_and_values(
                 &readout_mappings,
                 &response.readout_values,
-            ),
+            )),
             duration: response
                 .execution_duration_microseconds
                 .map(Duration::from_micros),
diff --git a/crates/lib/src/qpu/mod.rs b/crates/lib/src/qpu/mod.rs
index e4953ab5e..593438b81 100644
--- a/crates/lib/src/qpu/mod.rs
+++ b/crates/lib/src/qpu/mod.rs
@@ -12,6 +12,7 @@ use qcs_api_client_openapi::{
 pub mod client;
 mod execution;
 pub mod quilc;
+mod result_data;
 pub(crate) mod rewrite_arithmetic;
 pub(crate) mod rpcq;
 pub(crate) mod runner;
@@ -19,6 +20,8 @@ pub(crate) mod translation;
 
 pub use client::Qcs;
 pub(crate) use execution::{Error as ExecutionError, Execution};
+#[allow(clippy::module_name_repetitions)]
+pub use result_data::{QpuResultData, ReadoutValues};
 
 /// Query QCS for the ISA of the provided `quantum_processor_id`.
 ///
diff --git a/crates/lib/src/qpu/quilc/mod.rs b/crates/lib/src/qpu/quilc/mod.rs
index c391aef6a..32290846d 100644
--- a/crates/lib/src/qpu/quilc/mod.rs
+++ b/crates/lib/src/qpu/quilc/mod.rs
@@ -190,7 +190,7 @@ impl NativeQuilRequest {
     }
 }
 
-/// Description of a device to compile for, part of [`NativeQuilRequest`]
+/// Description of a device to compile for.
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
 #[serde(tag = "_type")]
 pub struct TargetDevice {
@@ -268,6 +268,7 @@ MEASURE 1 ro[1]
             .await
             .expect("Could not run program on QVM");
         for shot in results
+            .memory
             .remove("ro")
             .expect("Did not receive ro buffer")
             .into_i8()
diff --git a/crates/lib/src/qpu/result_data.rs b/crates/lib/src/qpu/result_data.rs
new file mode 100644
index 000000000..1b12b0497
--- /dev/null
+++ b/crates/lib/src/qpu/result_data.rs
@@ -0,0 +1,108 @@
+//! This modules provides types and functions for initializing and working with
+//! data returned from the QPU
+use enum_as_inner::EnumAsInner;
+use num::complex::Complex64;
+use quil_rs::instruction::MemoryReference;
+use std::collections::HashMap;
+
+use qcs_api_client_grpc::models::controller::{
+    readout_values as controller_readout_values, ReadoutValues as ControllerReadoutValues,
+};
+
+/// A row of readout values from the QPU. Each row contains all the values emitted to a
+/// memory reference across all shots.
+#[derive(Debug, Clone, EnumAsInner, PartialEq)]
+pub enum ReadoutValues {
+    /// Integer readout values
+    Integer(Vec<i64>),
+    /// Real numbered readout values
+    Real(Vec<f64>),
+    /// Complex readout values
+    Complex(Vec<Complex64>),
+}
+
+/// This struct encapsulates data returned from the QPU after executing a job.
+#[allow(clippy::module_name_repetitions)]
+#[derive(Debug, Clone, PartialEq)]
+pub struct QpuResultData {
+    pub(crate) mappings: HashMap<String, String>,
+    pub(crate) readout_values: HashMap<String, ReadoutValues>,
+}
+
+impl QpuResultData {
+    /// Builds a new [`QpuResultData`] from mappings of memory references to readout identifiers
+    /// and readout identifiers to [`ReadoutValues`]
+    #[must_use]
+    pub fn from_mappings_and_values(
+        mappings: HashMap<String, String>,
+        readout_values: HashMap<String, ReadoutValues>,
+    ) -> Self {
+        Self {
+            mappings,
+            readout_values,
+        }
+    }
+
+    /// Creates a new [`QpuResultData`] using data returned from controller service.
+    pub(crate) fn from_controller_mappings_and_values(
+        mappings: &HashMap<String, String>,
+        values: &HashMap<String, ControllerReadoutValues>,
+    ) -> Self {
+        Self {
+            mappings: mappings.clone(),
+            readout_values: values
+                .iter()
+                .map(|(key, readout_values)| {
+                    (
+                        key.clone(),
+                        match &readout_values.values {
+                            Some(controller_readout_values::Values::IntegerValues(v)) => {
+                                ReadoutValues::Integer(
+                                    v.values.iter().copied().map(i64::from).collect(),
+                                )
+                            }
+                            Some(controller_readout_values::Values::ComplexValues(v)) => {
+                                ReadoutValues::Complex(
+                                    v.values
+                                        .iter()
+                                        .map(|c| {
+                                            Complex64::new(
+                                                c.real.unwrap_or(0.0).into(),
+                                                c.imaginary.unwrap_or(0.0).into(),
+                                            )
+                                        })
+                                        .collect(),
+                                )
+                            }
+                            None => ReadoutValues::Integer(Vec::new()),
+                        },
+                    )
+                })
+                .collect(),
+        }
+    }
+
+    /// Returns the [`ReadoutValues`] for a [`MemoryReference`], or `None` if a mapping to the
+    /// provided memory reference doesn't exist.
+    #[must_use]
+    pub fn get_values_for_memory_reference(
+        &self,
+        reference: &MemoryReference,
+    ) -> Option<&ReadoutValues> {
+        self.mappings
+            .get(&reference.to_string())
+            .and_then(|key| self.readout_values.get(key))
+    }
+
+    /// Get mappings of a memory region (ie. "ro\[0\]") to it's key name in `readout_values` (ie. "q0")
+    #[must_use]
+    pub fn mappings(&self) -> &HashMap<String, String> {
+        &self.mappings
+    }
+
+    /// Get mapping of a readout values identifier (ie. "q0") to a set of [`ReadoutValues`]
+    #[must_use]
+    pub fn readout_values(&self) -> &HashMap<String, ReadoutValues> {
+        &self.readout_values
+    }
+}
diff --git a/crates/lib/src/qvm/execution.rs b/crates/lib/src/qvm/execution.rs
index a2e4182c7..ace1de629 100644
--- a/crates/lib/src/qvm/execution.rs
+++ b/crates/lib/src/qvm/execution.rs
@@ -1,5 +1,5 @@
+use std::borrow::Cow;
 use std::str::FromStr;
-use std::{borrow::Cow, collections::HashMap};
 
 use qcs_api_client_common::ClientConfiguration;
 use quil_rs::{
@@ -9,9 +9,8 @@ use quil_rs::{
 };
 
 use crate::executable::Parameters;
-use crate::RegisterData;
 
-use super::{Request, Response};
+use super::{QvmResultData, Request, Response};
 
 /// Contains all the info needed to execute on a QVM a single time, with the ability to be reused for
 /// faster subsequent runs.
@@ -61,7 +60,7 @@ impl Execution {
         readouts: &[Cow<'_, str>],
         params: &Parameters,
         config: &ClientConfiguration,
-    ) -> Result<HashMap<String, RegisterData>, Error> {
+    ) -> Result<QvmResultData, Error> {
         if shots == 0 {
             return Err(Error::ShotsMustBePositive);
         }
@@ -110,7 +109,7 @@ impl Execution {
         shots: u16,
         readouts: &[Cow<'_, str>],
         config: &ClientConfiguration,
-    ) -> Result<HashMap<String, RegisterData>, Error> {
+    ) -> Result<QvmResultData, Error> {
         let request = Request::new(&self.program.to_string(true), shots, readouts);
 
         let client = reqwest::Client::new();
@@ -129,7 +128,9 @@ impl Execution {
                 qvm_url: config.qvm_url().into(),
                 source,
             }),
-            Ok(Response::Success(response)) => Ok(response.registers),
+            Ok(Response::Success(response)) => {
+                Ok(QvmResultData::from_memory_map(response.registers))
+            }
             Ok(Response::Failure(response)) => Err(Error::Qvm {
                 message: response.status,
             }),
diff --git a/crates/lib/src/qvm/mod.rs b/crates/lib/src/qvm/mod.rs
index 33dd3f8c9..ca0e45479 100644
--- a/crates/lib/src/qvm/mod.rs
+++ b/crates/lib/src/qvm/mod.rs
@@ -11,6 +11,27 @@ use crate::RegisterData;
 
 mod execution;
 
+/// Encapsulates data returned after running a program on the QVM
+#[allow(clippy::module_name_repetitions)]
+#[derive(Debug, Deserialize, Clone, PartialEq)]
+pub struct QvmResultData {
+    pub(crate) memory: HashMap<String, RegisterData>,
+}
+
+impl QvmResultData {
+    #[must_use]
+    /// Build a [`QvmResultData`] from a mapping of register names to a [`RegisterData`]
+    pub fn from_memory_map(memory: HashMap<String, RegisterData>) -> Self {
+        Self { memory }
+    }
+
+    /// Get a map of register names (ie. "ro") to a [`RegisterData`] containing their values.
+    #[must_use]
+    pub fn memory(&self) -> &HashMap<String, RegisterData> {
+        &self.memory
+    }
+}
+
 #[derive(Debug, Deserialize, Clone, PartialEq)]
 #[serde(untagged)]
 pub(super) enum Response {
diff --git a/crates/lib/src/register_data.rs b/crates/lib/src/register_data.rs
index e29f62942..b5915858a 100644
--- a/crates/lib/src/register_data.rs
+++ b/crates/lib/src/register_data.rs
@@ -2,21 +2,26 @@ use enum_as_inner::EnumAsInner;
 use num::complex::Complex32;
 use serde::{Deserialize, Serialize};
 
-/// Data resulting from [`Executable::execute_on_qvm`](`crate::Executable::execute_on_qvm`) or
-/// [`Executable::execute_on_qpu`](`crate::Executable::execute_on_qpu`).
+/// Data resulting from [`Executable::execute_on_qvm`](`crate::Executable::execute_on_qvm`)
 ///
 /// This represents a single vector (or "register") of typed memory across some number of shots.
 /// The register corresponds to the usage of a `DECLARE` instruction in Quil, and the name of that
 /// register should be provided with [`Executable::read_from`](`crate::Executable::read_from`).
 ///
-/// There is a variant of this enum for each type of data that a register could hold.
-/// Any variant of an instance of `ExecutionResult` will contain a `Vec` with one entry for each shot,
-/// where each entry represents the entire register.
+/// There is a variant of this enum for each type of data that a register could hold. The register
+/// is represented as a 2-dimensional array `M` where the value `M[shot_number][memory_index]`
+/// represents the value at `memory_index` for `shot_number`.
 ///
 /// # Usage
 ///
-/// Typically you will already know what type of data the `ExecutionResult` _should_ have, so you can
-/// use the [`mod@enum_as_inner`] methods (e.g. [`ExecutionResult::into_i8`]) in order to
+/// Typically, you will be interacting with this data through the [`crate::ResultData`] of an
+/// [`crate::ExecutionData`] returned after running a program. In those cases, you'll probably
+/// want to convert it to a readout map using [`crate::ResultData.to_register_map()`]. This
+/// will give you each register in the form of a [`crate::RegisterMatrix`] which is similar
+/// but backed by an [`ndarray::Array2`] and more convenient for working with matrices.
+///
+/// If you are interacting with [`RegisterData`] directly, then you should already know what type of data it _should_
+/// have, so you can  use the [`mod@enum_as_inner`] methods (e.g. [`RegisterData::into_i8`]) in order to
 /// convert any variant type to its inner data.
 #[derive(Clone, Debug, Deserialize, EnumAsInner, PartialEq, Serialize)]
 #[serde(untagged)]
diff --git a/crates/python/.flake8 b/crates/python/.flake8
new file mode 100644
index 000000000..98e11a2c0
--- /dev/null
+++ b/crates/python/.flake8
@@ -0,0 +1,7 @@
+[flake8]
+max-line-length = 120
+# E203, E302 ignored for black compatibility
+extend-ignore = E203, E302
+
+per-file-ignores = 
+    qcs_sdk/__init__.pyi:F401,F403 # Disable "unused" warning for top-level exports
diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml
index 048a2e5ca..b5dd6bfe2 100644
--- a/crates/python/Cargo.toml
+++ b/crates/python/Cargo.toml
@@ -17,16 +17,17 @@ crate-type = ["cdylib"]
 
 [dependencies]
 qcs = { path = "../lib" }
-qcs-api-client-common = "0.4.2"
-qcs-api-client-openapi = "0.5.2"
-qcs-api-client-grpc = "0.4.2"
-pyo3 = { version = "0.17", features = ["extension-module"] }
-pyo3-asyncio = { version = "0.17", features = ["tokio-runtime"] }
-quil-rs = "0.15"
-tokio = "1.21"
-qcs-api = "0.2.1"
-rigetti-pyo3 = { version = "0.1.0-rc.4", features = ["extension-module", "complex"] }
-serde_json = "1.0.86"
+qcs-api.workspace = true
+qcs-api-client-common.workspace = true
+qcs-api-client-grpc.workspace = true
+qcs-api-client-openapi.workspace = true
+pyo3.workspace = true
+pyo3-asyncio.workspace = true
+quil-rs.workspace = true
+serde_json.workspace = true
+tokio.workspace = true
+numpy.workspace = true
+rigetti-pyo3.workspace = true
 
 [build-dependencies]
-pyo3-build-config = { version = "0.17" }
+pyo3-build-config.workspace = true
diff --git a/crates/python/poetry.lock b/crates/python/poetry.lock
index e92cf03ec..851218764 100644
--- a/crates/python/poetry.lock
+++ b/crates/python/poetry.lock
@@ -1,3 +1,5 @@
+# This file is automatically @generated by Poetry and should not be changed by hand.
+
 [[package]]
 name = "attrs"
 version = "22.2.0"
@@ -5,14 +7,17 @@ description = "Classes Without Boilerplate"
 category = "dev"
 optional = false
 python-versions = ">=3.6"
+files = [
+    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
+    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
+]
 
 [package.extras]
-cov = ["attrs", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
-dev = ["attrs"]
-docs = ["furo", "sphinx", "myst-parser", "zope.interface", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"]
-tests = ["attrs", "zope.interface"]
-tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"]
-tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"]
+cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
+dev = ["attrs[docs,tests]"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
+tests = ["attrs[tests-no-zope]", "zope.interface"]
+tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
 
 [[package]]
 name = "black"
@@ -21,6 +26,20 @@ description = "The uncompromising code formatter."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"},
+    {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"},
+    {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"},
+    {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"},
+    {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"},
+    {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"},
+    {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"},
+    {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"},
+    {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"},
+    {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"},
+    {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"},
+    {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"},
+]
 
 [package.dependencies]
 click = ">=8.0.0"
@@ -43,6 +62,10 @@ description = "Composable command line interface toolkit"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+]
 
 [package.dependencies]
 colorama = {version = "*", markers = "platform_system == \"Windows\""}
@@ -54,6 +77,10 @@ description = "Cross-platform colored terminal text."
 category = "dev"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
 
 [[package]]
 name = "exceptiongroup"
@@ -62,6 +89,10 @@ description = "Backport of PEP 654 (exception groups)"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
+    {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
+]
 
 [package.extras]
 test = ["pytest (>=6)"]
@@ -73,6 +104,10 @@ description = "brain-dead simple config-ini parsing"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
 
 [[package]]
 name = "maturin"
@@ -81,13 +116,27 @@ description = "Build and publish crates with pyo3, rust-cpython and cffi binding
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "maturin-0.13.7-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:bc58b0266a4c124f9afd69a987ac324ff35c0d963b41073ed64a32f94c226d5a"},
+    {file = "maturin-0.13.7-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:424d53adc9684167cf89829275fe5072331a197de0ac08e7a893f9283c7df213"},
+    {file = "maturin-0.13.7-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:3137876c338eb7e551ba44a609b5f02d02454d1b3a8677ad6bf2121c5a92b2b7"},
+    {file = "maturin-0.13.7-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:a96f1b3ede71c0f76b8c7cfac18a9eec90174bdf434fa9aeff491be9a7ca5179"},
+    {file = "maturin-0.13.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0b6ac1219a809155057fd1f358f7ece03c3abd2e2991832ce5146825a9fa4160"},
+    {file = "maturin-0.13.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3c36f429adc3a8af8de9838399750742a86053f0031a953b48ee92932120dc0c"},
+    {file = "maturin-0.13.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:bb3a2830d64ae6a324571f694475b91111e827bc0ccc60a0c47f4fb596a46bd8"},
+    {file = "maturin-0.13.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:794f58d3103449f8cd8ab5f36fd05c31b8d8de3643cd0e3720fd5dc3c328dd5c"},
+    {file = "maturin-0.13.7-py3-none-win32.whl", hash = "sha256:63586eb286866264ec62d29df6ab955360de6226128f67d14623ffe1a12d4963"},
+    {file = "maturin-0.13.7-py3-none-win_amd64.whl", hash = "sha256:f50d62aca567fdbbb929771794f3c5c78048ef0efa4af7d83ed472a8b8d26454"},
+    {file = "maturin-0.13.7-py3-none-win_arm64.whl", hash = "sha256:8c6225e7eba2885a0cd82a6cf898e74bb720796a5744e0450f3b1340d1ca97af"},
+    {file = "maturin-0.13.7.tar.gz", hash = "sha256:c0a77aa0c57f945649ca711c806203a1b6888ad49c2b8b85196ffdcf0421db77"},
+]
 
 [package.dependencies]
 tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
-zig = ["ziglang (>=0.9.0,<0.10.0)"]
 patchelf = ["patchelf"]
+zig = ["ziglang (>=0.9.0,<0.10.0)"]
 
 [[package]]
 name = "mypy-extensions"
@@ -96,6 +145,48 @@ description = "Experimental type system extensions for programs checked with the
 category = "dev"
 optional = false
 python-versions = "*"
+files = [
+    {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
+    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
+]
+
+[[package]]
+name = "numpy"
+version = "1.24.1"
+description = "Fundamental package for array computing in Python"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "numpy-1.24.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:179a7ef0889ab769cc03573b6217f54c8bd8e16cef80aad369e1e8185f994cd7"},
+    {file = "numpy-1.24.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b09804ff570b907da323b3d762e74432fb07955701b17b08ff1b5ebaa8cfe6a9"},
+    {file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b739841821968798947d3afcefd386fa56da0caf97722a5de53e07c4ccedc7"},
+    {file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3463e6ac25313462e04aea3fb8a0a30fb906d5d300f58b3bc2c23da6a15398"},
+    {file = "numpy-1.24.1-cp310-cp310-win32.whl", hash = "sha256:b31da69ed0c18be8b77bfce48d234e55d040793cebb25398e2a7d84199fbc7e2"},
+    {file = "numpy-1.24.1-cp310-cp310-win_amd64.whl", hash = "sha256:b07b40f5fb4fa034120a5796288f24c1fe0e0580bbfff99897ba6267af42def2"},
+    {file = "numpy-1.24.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7094891dcf79ccc6bc2a1f30428fa5edb1e6fb955411ffff3401fb4ea93780a8"},
+    {file = "numpy-1.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e418681372520c992805bb723e29d69d6b7aa411065f48216d8329d02ba032"},
+    {file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e274f0f6c7efd0d577744f52032fdd24344f11c5ae668fe8d01aac0422611df1"},
+    {file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0044f7d944ee882400890f9ae955220d29b33d809a038923d88e4e01d652acd9"},
+    {file = "numpy-1.24.1-cp311-cp311-win32.whl", hash = "sha256:442feb5e5bada8408e8fcd43f3360b78683ff12a4444670a7d9e9824c1817d36"},
+    {file = "numpy-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:de92efa737875329b052982e37bd4371d52cabf469f83e7b8be9bb7752d67e51"},
+    {file = "numpy-1.24.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b162ac10ca38850510caf8ea33f89edcb7b0bb0dfa5592d59909419986b72407"},
+    {file = "numpy-1.24.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:26089487086f2648944f17adaa1a97ca6aee57f513ba5f1c0b7ebdabbe2b9954"},
+    {file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caf65a396c0d1f9809596be2e444e3bd4190d86d5c1ce21f5fc4be60a3bc5b36"},
+    {file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0677a52f5d896e84414761531947c7a330d1adc07c3a4372262f25d84af7bf7"},
+    {file = "numpy-1.24.1-cp38-cp38-win32.whl", hash = "sha256:dae46bed2cb79a58d6496ff6d8da1e3b95ba09afeca2e277628171ca99b99db1"},
+    {file = "numpy-1.24.1-cp38-cp38-win_amd64.whl", hash = "sha256:6ec0c021cd9fe732e5bab6401adea5a409214ca5592cd92a114f7067febcba0c"},
+    {file = "numpy-1.24.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28bc9750ae1f75264ee0f10561709b1462d450a4808cd97c013046073ae64ab6"},
+    {file = "numpy-1.24.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84e789a085aabef2f36c0515f45e459f02f570c4b4c4c108ac1179c34d475ed7"},
+    {file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e669fbdcdd1e945691079c2cae335f3e3a56554e06bbd45d7609a6cf568c700"},
+    {file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef85cf1f693c88c1fd229ccd1055570cb41cdf4875873b7728b6301f12cd05bf"},
+    {file = "numpy-1.24.1-cp39-cp39-win32.whl", hash = "sha256:87a118968fba001b248aac90e502c0b13606721b1343cdaddbc6e552e8dfb56f"},
+    {file = "numpy-1.24.1-cp39-cp39-win_amd64.whl", hash = "sha256:ddc7ab52b322eb1e40521eb422c4e0a20716c271a306860979d450decbb51b8e"},
+    {file = "numpy-1.24.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed5fb71d79e771ec930566fae9c02626b939e37271ec285e9efaf1b5d4370e7d"},
+    {file = "numpy-1.24.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad2925567f43643f51255220424c23d204024ed428afc5aad0f86f3ffc080086"},
+    {file = "numpy-1.24.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cfa1161c6ac8f92dea03d625c2d0c05e084668f4a06568b77a25a89111621566"},
+    {file = "numpy-1.24.1.tar.gz", hash = "sha256:2386da9a471cc00a1f47845e27d916d5ec5346ae9696e01a8a34760858fe9dd2"},
+]
 
 [[package]]
 name = "packaging"
@@ -104,6 +195,10 @@ description = "Core utilities for Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
+    {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
+]
 
 [[package]]
 name = "pathspec"
@@ -112,6 +207,10 @@ description = "Utility library for gitignore style pattern matching of file path
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"},
+    {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
+]
 
 [[package]]
 name = "platformdirs"
@@ -120,10 +219,14 @@ description = "A small Python package for determining appropriate platform-speci
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},
+    {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"},
+]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.5)", "sphinx (>=5.3)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"]
+docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 
 [[package]]
 name = "pluggy"
@@ -132,6 +235,10 @@ description = "plugin and hook calling mechanisms for python"
 category = "dev"
 optional = false
 python-versions = ">=3.6"
+files = [
+    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
 
 [package.extras]
 dev = ["pre-commit", "tox"]
@@ -144,6 +251,10 @@ description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"},
+    {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"},
+]
 
 [package.dependencies]
 attrs = ">=19.2.0"
@@ -164,12 +275,16 @@ description = "Pytest support for asyncio"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"},
+    {file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"},
+]
 
 [package.dependencies]
 pytest = ">=6.1.0"
 
 [package.extras]
-testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
+testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
 
 [[package]]
 name = "tomli"
@@ -178,6 +293,10 @@ description = "A lil' TOML parser"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
 
 [[package]]
 name = "typing-extensions"
@@ -186,38 +305,12 @@ description = "Backported and Experimental Type Hints for Python 3.7+"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
+    {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
+]
 
 [metadata]
-lock-version = "1.1"
+lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "67c0952caa1ebf2881aec378850a3188a6650b6b9cc9cda838dec5d23ed15e72"
-
-[metadata.files]
-attrs = []
-black = []
-click = [
-    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
-    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
-]
-colorama = []
-exceptiongroup = []
-iniconfig = []
-maturin = []
-mypy-extensions = [
-    {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
-    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
-]
-packaging = []
-pathspec = []
-platformdirs = []
-pluggy = [
-    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
-    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
-]
-pytest = []
-pytest-asyncio = []
-tomli = [
-    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
-    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
-]
-typing-extensions = []
+content-hash = "226706998c75145ffb0124fc12c73c08935e14c1872e1b0bd37f08c1c3e69cb5"
diff --git a/crates/python/pyproject.toml b/crates/python/pyproject.toml
index 46f900f4e..14b8e2d3a 100644
--- a/crates/python/pyproject.toml
+++ b/crates/python/pyproject.toml
@@ -43,11 +43,17 @@ sdist-include = ["README.md"]
 python = "^3.8"
 
 [tool.poetry.dev-dependencies]
+numpy = "^1.24.1"
 maturin = "^0.13.2"
 pytest = "^7.1.3"
 pytest-asyncio = "^0.19.0"
 black = "^22.8.0"
 
+[tool.black]
+line-length = 120
+target-version = ['py37', 'py38', 'py39', 'py310']
+include = '\.pyi?$'
+
 [build-system]
 requires = ["maturin>=0.13,<0.14"]
 build-backend = "maturin"
diff --git a/crates/python/qcs_sdk/__init__.pyi b/crates/python/qcs_sdk/__init__.pyi
index cbd1666c4..8369f07dd 100644
--- a/crates/python/qcs_sdk/__init__.pyi
+++ b/crates/python/qcs_sdk/__init__.pyi
@@ -1,17 +1,16 @@
 from .api import *
 
-from .qpu.client import (
-    QcsClient as QcsClient
-)
-
+from .qpu.client import QcsClient as QcsClient
 from .qpu.isa import (
-    get_instruction_set_architecture as get_instruction_set_architecture
+    get_instruction_set_architecture as get_instruction_set_architecture,
 )
 
 from ._execution_data import (
-    QPU as QPU,
-    QVM as QVM,
-    ReadoutMap as ReadoutMap,
+    ResultData as ResultData,
+    ExecutionData as ExecutionData,
+    RegisterMatrix as RegisterMatrix,
+    RegisterMap as RegisterMap,
+    RegisterMatrixConversionError as RegisterMatrixConversionError,
 )
 
 from ._executable import (
diff --git a/crates/python/qcs_sdk/_executable.pyi b/crates/python/qcs_sdk/_executable.pyi
index 2ec80827d..696f9f392 100644
--- a/crates/python/qcs_sdk/_executable.pyi
+++ b/crates/python/qcs_sdk/_executable.pyi
@@ -6,12 +6,12 @@ from enum import Enum
 
 from typing import Dict, List, Optional
 from .qpu.quilc import CompilerOpts
-from ._execution_data import QVM, QPU
+from ._execution_data import ExecutionData
 
 class QcsExecutionError(RuntimeError):
     """Error encounteted when executing programs."""
-    ...
 
+    ...
 
 class Executable:
     def __new__(
@@ -20,10 +20,9 @@ class Executable:
         parameters: Optional[List[ExeParameter]] = None,
         shots: Optional[int] = None,
         compile_with_quilc: Optional[bool] = None,
-        compiler_options: Optional[CompilerOpts] = None
+        compiler_options: Optional[CompilerOpts] = None,
     ) -> "Executable": ...
-
-    async def execute_on_qvm(self) -> QVM:
+    async def execute_on_qvm(self) -> ExecutionData:
         """
         Execute on a QVM which must be available at the configured URL (default http://localhost:5000).
 
@@ -31,8 +30,7 @@ class Executable:
             - ``QcsExecutionError``: If the job fails to execute.
         """
         ...
-
-    async def execute_on_qpu(self, quantum_processor_id: str) -> QPU:
+    async def execute_on_qpu(self, quantum_processor_id: str) -> ExecutionData:
         """
         Compile the program and execute it on a QPU, waiting for results.
 
@@ -40,8 +38,7 @@ class Executable:
             - ``QcsExecutionError``: If the job fails to execute.
         """
         ...
-    
-    async def retrieve_results(job_handle: JobHandle) -> QPU:
+    async def retrieve_results(self, job_handle: JobHandle) -> ExecutionData:
         """
         Wait for the results of a job to complete.
 
@@ -50,11 +47,10 @@ class Executable:
         """
         ...
 
-
 class JobHandle:
     """
     The result of submitting a job to a QPU.
-    
+
     Used to retrieve the results of a job.
     """
 
@@ -64,7 +60,6 @@ class JobHandle:
         Unique ID associated with a single job execution.
         """
         ...
-
     @property
     def readout_map(self) -> Dict[str, str]:
         """
@@ -72,38 +67,34 @@ class JobHandle:
         """
         ...
 
-
 class ExeParameter:
     """
     Program execution parameters.
 
     Note: The validity of parameters is not checked until execution.
     """
+
     def __new__(
         cls: type["ExeParameter"],
         name: str,
         index: int,
         value: float,
     ) -> "ExeParameter": ...
-
     @property
     def name(self) -> str: ...
     @name.setter
     def name(self, value: str): ...
-
     @property
     def index(self) -> int: ...
     @index.setter
     def index(self, value: int): ...
-
     @property
     def value(self) -> float: ...
     @value.setter
     def value(self, value: float): ...
 
-
 class Service(Enum):
-    Quilc = "Quilc",
-    Qvm = "Qvm",
-    Qcs = "Qcs",
-    Qpu = "Qpu",
+    Quilc = "Quilc"
+    Qvm = "Qvm"
+    Qcs = "Qcs"
+    Qpu = "Qpu"
diff --git a/crates/python/qcs_sdk/_execution_data.pyi b/crates/python/qcs_sdk/_execution_data.pyi
index d64ae413f..cd339168c 100644
--- a/crates/python/qcs_sdk/_execution_data.pyi
+++ b/crates/python/qcs_sdk/_execution_data.pyi
@@ -4,69 +4,144 @@ It is only here to represent the structure of the rust source code 1:1
 """
 
 import datetime
-from typing import List, Optional
-
-class _IntegerReadoutValues:
-    @property
-    def values(self) -> List[int]: ...
-    @values.setter
-    def values(self, value: List[int]): ...
-
-
-class _ComplexReadoutValues:
-    @property
-    def values(self) -> List[complex]: ...
-    @values.setter
-    def values(self, value: List[complex]): ...
-
-
-class _ReadoutValuesValues:
-    @property
-    def integer_values(self) -> _IntegerReadoutValues: ...
-    @integer_values.setter
-    def integer_values(self, value: _IntegerReadoutValues): ...
-
-    @property
-    def complex_values(self) -> _ComplexReadoutValues: ...
-    @complex_values.setter
-    def complex_values(self, value: _ComplexReadoutValues): ...
-
-
-class _ReadoutValues:
+from typing import Optional
+
+import numpy as np
+from numpy.typing import NDArray
+
+from .qpu import QPUResultData
+from .qvm import QVMResultData
+
+class RegisterMatrixConversionError(ValueError):
+    """Error that may occur when building a ``RegisterMatrix`` from execution data."""
+
+    ...
+
+class RegisterMatrix:
+    """
+    Values in a 2-dimensional ``ndarray`` representing the final shot value in each memory reference across all shots.
+    Each variant corresponds to the possible data types a register can contain.
+
+    Variants:
+        ``integer``: Corresponds to the Quil `BIT`, `OCTET`, or `INTEGER` types.
+        ``real``: Corresponds to the Quil `REAL` type.
+        ``complex``: Registers containing complex numbers.
+
+    Methods (each per variant):
+        - ``is_*``: if the underlying values are that type.
+        - ``as_*``: if the underlying values are that type, then those values, otherwise ``None``.
+        - ``to_*``: the underlying values as that type, raises ``ValueError`` if they are not.
+        - ``from_*``: wrap underlying values as this enum type.
+
+    """
+
+    def is_integer(self) -> bool: ...
+    def is_real(self) -> bool: ...
+    def is_complex(self) -> bool: ...
+    def as_integer(self) -> Optional[NDArray[np.int64]]: ...
+    def as_real(self) -> Optional[NDArray[np.float64]]: ...
+    # In numpy `complex128` is a complex number made up of two `f64`s.
+    def as_complex(self) -> Optional[NDArray[np.complex128]]: ...
+    def to_integer(self) -> NDArray[np.int64]: ...
+    def to_real(self) -> NDArray[np.float64]: ...
+    def to_complex(self) -> NDArray[np.complex128]: ...
+    @staticmethod
+    def from_integer(inner: NDArray[np.int64]) -> "RegisterMatrix": ...
+    @staticmethod
+    def from_real(inner: NDArray[np.float64]) -> "RegisterMatrix": ...
+    @staticmethod
+    def from_complex(inner: NDArray[np.complex128]) -> "RegisterMatrix": ...
+
+class RegisterMap:
+    """A map of register names (ie. "ro") to a ``RegisterMatrix`` containing the values of the register."""
+
+    def get_register_matrix(self, register_name: str) -> Optional[RegisterMatrix]: ...
+    """Get the ``RegisterMatrix`` for the given register. Returns `None` if the register doesn't exist."""
+
+class ResultData:
+    """
+    Represents the two possible types of data returned from either the QVM or a real QPU.
+    Each variant contains the original data returned from its respective executor.
+
+    Usage
+    -----
+
+    Your usage of ``ResultData`` will depend on the types of programs you are running and where.
+    The `to_register_map()` method will attempt to build ``RegisterMap`` out of the data, where each
+    register name is mapped to a 2-dimensional rectangular ``RegisterMatrix`` where each row
+    represents the final values in each register index for a particular shot. This is often the
+    desired form of the data and it is _probably_ what you want. This transformation isn't always
+    possible, in which case `to_register_map()` will return an error.
+
+    To understand why this transformation can fail, we need to understand a bit about how readout data is
+    returned from the QVM and from a real QPU:
+
+    The QVM treats each `DECLARE` statement as initialzing some amount of memory. This memory works
+    as one might expect it to. It is zero-initalized, and subsequent writes to the same region
+    overwrite the previous value. The QVM returns memory at the end of every shot. This means
+    we get the last value in every memory reference for each shot, which is exactly the
+    representation we want for a ``RegisterMatrix``. For this reason, `to_register_map()` should
+    always succeed for ``ResultData::Qvm``.
+
+    The QPU on the other hand doesn't use the same memory model as the QVM. Each memory reference
+    (ie. "ro[0]") is more like a stream than a value in memory. Every `MEASURE` to a memory
+    reference emits a new value to said stream. This means that the number of values per memory
+    reference can vary per shot. For this reason, it's not always clear what the final value in
+    each shot was for a particular reference. When this is the case, `to_register_map()` will return
+    an error as it's impossible to build a correct ``RegisterMatrix``  from the data without
+    knowing the intent of the program that was run. Instead, it's recommended to build the
+    ``RegisterMatrix`` you need from the inner ``QPUResultData`` data using the knowledge of your
+    program to choose the correct readout values for each shot.
+
+    Variants:
+        - ``qvm``: Data returned from the QVM, stored as ``QVMResultData``
+        - ``qpu``: Data returned from the QPU, stored as ``QPUResultData``
+
+    Methods (each per variant):
+        - ``is_*``: if the underlying values are that type.
+        - ``as_*``: if the underlying values are that type, then those values, otherwise ``None``.
+        - ``to_*``: the underlying values as that type, raises ``ValueError`` if they are not.
+        - ``from_*``: wrap underlying values as this enum type.
+
+    """
+
+    def to_register_map(self) -> RegisterMap: ...
+    """
+    Convert ``ResultData`` from its inner representation as ``QVMResultData`` or
+    ``QPUResultData`` into a ``RegisterMap``. The ``RegisterMatrix`` for each register will be
+    constructed such that each row contains all the final values in the register for a single shot.
+
+    Errors
+    ------
+
+    Raises a ``RegisterMatrixConversionError`` if the inner execution data for any of the
+    registers would result in a jagged matrix. ``QPUResultData`` data is captured per measure,
+    meaning a value is returned for every measure to a memory reference, not just once per shot.
+    This is often the case in programs that use mid-circuit measurement or dynamic control flow,
+    where measurements to the same memory reference might occur multiple times in a shot, or be
+    skipped conditionally. In these cases, building a rectangular ``RegisterMatrix`` would
+    necessitate making assumptions about the data that could skew the data in undesirable ways.
+    Instead, it's recommended to manually build a matrix from ``QPUResultData`` that accurately
+    selects the last value per-shot based on the program that was run.
+    """
+
+    def is_qvm(self) -> bool: ...
+    def is_qpu(self) -> bool: ...
+    def as_qvm(self) -> Optional[QVMResultData]: ...
+    def as_qpu(self) -> Optional[QPUResultData]: ...
+    def to_qvm(self) -> QVMResultData: ...
+    def to_qpu(self) -> QPUResultData: ...
+    @staticmethod
+    def from_qvm(inner: QVMResultData) -> "ResultData": ...
+    @staticmethod
+    def from_qpu(inner: QPUResultData) -> "ResultData": ...
+
+class ExecutionData:
     @property
-    def values(self) -> Optional[_ReadoutValuesValues]: ...
-    @values.setter
-    def values(self, value: Optional[_ReadoutValuesValues]): ...
-
-
-class ReadoutMap:
-    def get_readout_values(self, field: str, index: int) -> Optional[_ReadoutValues]:
-        """Given a known readout field name and index, return the result's ``ReadoutValues``, if any."""
-        ...
-    
-    def get_readout_values_for_field(self, field: str) -> Optional[List[Optional[_ReadoutValues]]]:
-        """Given a known readout field name, return the result's ``ReadoutValues`` for all indices, if any."""
-        ...
-
-class QVM:
-    @property
-    def registers(self) -> dict: ...
-    @registers.setter
-    def registers(self, value: dict): ...
-
-    @property
-    def duration(self) -> Optional[datetime.timedelta]: ...
-    @duration.setter
-    def duration(self, value: Optional[datetime.timedelta]): ...
-
-
-class QPU:
-    @property
-    def readout_data(self) -> ReadoutMap: ...
-    @readout_data.setter
-    def readout_data(self, value: ReadoutMap): ...
-
+    def result_data(self) -> ResultData: ...
+    @result_data.setter
+    def result_data(self, result_data: ResultData): ...
     @property
     def duration(self) -> Optional[datetime.timedelta]: ...
     @duration.setter
-    def duration(self, value: Optional[datetime.timedelta]): ...
+    def duration(self, duration: Optional[datetime.timedelta]): ...
diff --git a/crates/python/qcs_sdk/api.pyi b/crates/python/qcs_sdk/api.pyi
index 208f66678..f956603fb 100644
--- a/crates/python/qcs_sdk/api.pyi
+++ b/crates/python/qcs_sdk/api.pyi
@@ -1,9 +1,10 @@
 """
 The qcs_sdk module provides an interface to Rigetti Quantum Cloud Services. Allowing users to compile and run Quil programs on Rigetti quantum processors.
 """
-from typing import  Dict, List, Optional
 from numbers import Number
+from typing import Dict, List, Optional
 
+from .qpu.isa import InstructionSetArchitecture
 from .qpu.client import QcsClient
 
 RecalculationTable = List[str]
@@ -12,33 +13,33 @@ PatchValues = Dict[str, List[float]]
 
 class ExecutionError(RuntimeError):
     """Error encountered during program execution submission or when retrieving results."""
-    ...
 
+    ...
 
 class TranslationError(RuntimeError):
     """Error encountered during program translation."""
-    ...
 
+    ...
 
 class CompilationError(RuntimeError):
     """Error encountered during program compilation."""
-    ...
 
+    ...
 
 class RewriteArithmeticError(RuntimeError):
     """Error encountered rewriting arithmetic for program."""
-    ...
 
+    ...
 
 class DeviceIsaError(ValueError):
     """Error while building Instruction Set Architecture."""
-    ...
 
+    ...
 
 class QcsGetQuiltCalibrationsError(RuntimeError):
     """Error while fetching Quil-T calibrations."""
-    ...
 
+    ...
 
 class RewriteArithmeticResults:
     """
@@ -53,17 +54,15 @@ class RewriteArithmeticResults:
         ...
     @program.setter
     def program(self, value: str): ...
-
     @property
     def recalculation_table(self) -> List[str]:
-        """ 
+        """
         The recalculation table stores an ordered list of arithmetic expressions, which are to be used when updating the program memory before execution.
         """
         ...
     @recalculation_table.setter
     def recalculation_table(self, value: List[str]): ...
 
-
 class TranslationResult:
     """
     The result of a call to [`translate`] which provides information about the translated program.
@@ -77,7 +76,6 @@ class TranslationResult:
         ...
     @program.setter
     def program(self, value: str): ...
-
     @property
     def ro_sources(self) -> Optional[dict]:
         """
@@ -87,7 +85,6 @@ class TranslationResult:
     @ro_sources.setter
     def ro_sources(self, value: Optional[dict]): ...
 
-
 class ExecutionResult:
     """Execution readout data from a particular memory location."""
 
@@ -97,14 +94,12 @@ class ExecutionResult:
         ...
     @shape.setter
     def shape(self, value: List[int]): ...
-    
     @property
     def data(self) -> List[Number | List[float]]:
         """The result data. Complex numbers are represented as [real, imaginary]."""
         ...
     @data.setter
     def data(self, value: List[Number | List[float]]): ...
-
     @property
     def dtype(self) -> str:
         """The type of the result data (as a `numpy` `dtype`)."""
@@ -112,7 +107,6 @@ class ExecutionResult:
     @dtype.setter
     def dtype(self, value: str): ...
 
-
 class ExecutionResults:
     """Execution readout data for all memory locations."""
 
@@ -126,7 +120,6 @@ class ExecutionResults:
         ...
     @buffers.setter
     def buffers(self, value: Dict[str, ExecutionResult]): ...
-    
     @property
     def execution_duration_microseconds(self) -> Optional[int]:
         """The time spent executing the program."""
@@ -134,7 +127,6 @@ class ExecutionResults:
     @execution_duration_microseconds.setter
     def execution_duration_microseconds(self, value: Optional[int]): ...
 
-
 class Register:
     """
     Data from an individual register.
@@ -153,25 +145,22 @@ class Register:
         - ``from_*``: wrap underlying values as this enum type.
 
     """
-    
+
     def is_i8(self) -> bool: ...
     def is_i16(self) -> bool: ...
     def is_i32(self) -> bool: ...
     def is_f64(self) -> bool: ...
     def is_complex64(self) -> bool: ...
-
     def as_i8(self) -> Optional[List[int]]: ...
     def as_i16(self) -> Optional[List[int]]: ...
     def as_i32(self) -> Optional[List[int]]: ...
     def as_f64(self) -> Optional[List[float]]: ...
     def as_complex64(self) -> Optional[List[complex]]: ...
-
     def to_i8(self) -> List[int]: ...
     def to_i16(self) -> List[int]: ...
     def to_i32(self) -> List[int]: ...
     def to_f64(self) -> List[float]: ...
     def to_complex64(self) -> List[complex]: ...
-
     @staticmethod
     def from_i8(inner: List[int]) -> "Register": ...
     @staticmethod
@@ -183,7 +172,6 @@ class Register:
     @staticmethod
     def from_complex64(inner: List[complex]) -> "Register": ...
 
-
 class QuiltCalibrations:
     """Result of `get_quilt_calibrations`."""
 
@@ -193,7 +181,6 @@ class QuiltCalibrations:
         ...
     @quilt.setter
     def quilt(self, value: str): ...
-
     @property
     def settings_timestamp(self) -> Optional[str]:
         """ISO8601 timestamp of the settings used to generate these calibrations."""
@@ -201,7 +188,6 @@ class QuiltCalibrations:
     @settings_timestamp.setter
     def settings_timestamp(self, value: Optional[str]): ...
 
-
 async def compile(
     quil: str,
     target_device: str,
@@ -230,7 +216,6 @@ async def compile(
     """
     ...
 
-
 def rewrite_arithmetic(
     native_quil: str,
 ) -> RewriteArithmeticResults:
@@ -243,14 +228,13 @@ def rewrite_arithmetic(
 
     Returns:
         A dictionary with the rewritten program and recalculation table (see `RewriteArithmeticResults`).
-    
+
     Raises:
         - ``TranslationError`` If the program could not be translated.
         - ``RewriteArithmeticError`` If the program arithmetic cannot be evaluated.
     """
     ...
 
-
 def build_patch_values(
     recalculation_table: RecalculationTable,
     memory: Memory,
@@ -271,7 +255,6 @@ def build_patch_values(
     """
     ...
 
-
 async def translate(
     native_quil: str,
     num_shots: int,
@@ -289,14 +272,13 @@ async def translate(
 
     Returns:
         An Awaitable that resolves to a dictionary with the compiled program, memory descriptors, and readout sources (see `TranslationResult`).
-    
+
     Raises:
         - ``LoadError`` If there is an issue loading the QCS Client configuration.
         - ``TranslationError`` If the `native_quil` program could not be translated.
     """
     ...
 
-
 async def submit(
     program: str,
     patch_values: Dict[str, List[float]],
@@ -314,14 +296,13 @@ async def submit(
 
     Returns:
         An Awaitable that resolves to the ID of the submitted job.
-    
+
     Raises:
         - ``LoadError`` If there is an issue loading the QCS Client configuration.
         - ``ExecutionError`` If there was a problem during program execution.
     """
     ...
 
-
 async def retrieve_results(
     job_id: str,
     quantum_processor_id: str,
@@ -344,7 +325,6 @@ async def retrieve_results(
     """
     ...
 
-
 async def get_quilc_version(
     client: Optional[QcsClient] = None,
 ) -> str:
@@ -353,7 +333,7 @@ async def get_quilc_version(
 
     Args:
         client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
-    
+
     Raises:
         - ``LoadError`` If there is an issue loading the QCS Client configuration.
         - ``CompilationError`` If there is an issue fetching the version from the quilc compiler.
@@ -389,3 +369,18 @@ async def get_quilt_calibrations(
         - ``QcsGetQuiltCalibrationsError`` If there was a problem fetching Quil-T calibrations.
     """
     ...
+
+async def get_instruction_set_architecture(
+    quantum_processor_id: str, client: Optional[QcsClient]
+) -> InstructionSetArchitecture:
+    """
+    Retrieve the InstructionSetArchitecture (ISA) for the given Quantum Processor ID.
+
+    Args:
+        quantum_processor_id: The ID of the quantum processor
+        client: The QcsClient to use. Loads one using environment configuration if unset - see https://docs.rigetti.com/qcs/references/qcs-client-configuration
+
+    Raises:
+        - ``IsaError`` if there was a problem fetching the ISA.
+    """
+    ...
diff --git a/crates/python/qcs_sdk/qpu/__init__.pyi b/crates/python/qcs_sdk/qpu/__init__.pyi
index e596a2f0a..b9da7d6f1 100644
--- a/crates/python/qcs_sdk/qpu/__init__.pyi
+++ b/crates/python/qcs_sdk/qpu/__init__.pyi
@@ -1,2 +1,6 @@
-class QcsIsaError(RuntimeError):
-    ...
+from .result_data import (
+    QPUResultData as QPUResultData,
+    ReadoutValues as ReadoutValues,
+)
+
+class QcsIsaError(RuntimeError): ...
diff --git a/crates/python/qcs_sdk/qpu/client.pyi b/crates/python/qcs_sdk/qpu/client.pyi
index a2966cded..87cbd762a 100644
--- a/crates/python/qcs_sdk/qpu/client.pyi
+++ b/crates/python/qcs_sdk/qpu/client.pyi
@@ -1,6 +1,5 @@
 from typing import Optional
 
-
 class QcsClient:
     """
     Configuration for connecting and authenticating to QCS API resources.
@@ -17,11 +16,10 @@ class QcsClient:
     ) -> "QcsClient":
         """
         Construct a client from scratch.
-        
+
         Use ``QcsClient.load`` to construct an environment-based profile.
         """
         ...
-    
     @staticmethod
     async def load(
         profile_name: Optional[str] = None,
@@ -33,67 +31,57 @@ class QcsClient:
         See for details: https://docs.rigetti.com/qcs/references/qcs-client-configuration#environment-variables-and-configuration-files
         """
         ...
-
     @property
     def api_url(self) -> str:
         """URL to access the QCS API."""
         ...
-
     @property
     def grpc_api_url(self) -> str:
         """URL to access the gRPC API."""
         ...
-
     @property
     def quilc_url(self) -> str:
         """URL to access the `quilc` compiler."""
         ...
-
     @property
     def qvm_url(self) -> str:
         """URL to access the QVM."""
         ...
 
-
 class QcsClientAuthServer:
     """Authentication server configuration for the QCS API."""
 
+    def __init__(self, client_id: str, issuer: str): ...
     @property
     def client_id(self) -> str: ...
     @client_id.setter
     def client_id(self, value: str): ...
-
     @property
     def issuer(self) -> str: ...
     @issuer.setter
     def issuer(self, value: str): ...
 
-
 class QcsClientTokens:
     """Authentication tokens for the QCS API."""
 
+    def __init__(self, bearer_access_token: str, refresh_token: str): ...
     @property
     def bearer_access_token(self) -> Optional[str]: ...
     @bearer_access_token.setter
     def bearer_access_token(self, value: Optional[str]): ...
-
     @property
     def refresh_token(self) -> Optional[str]: ...
     @refresh_token.setter
     def refresh_token(self, value: Optional[str]): ...
 
-
 class QcsGrpcClientError(RuntimeError):
     """Error encountered while loading a QCS gRPC API client."""
 
-
 class QcsGrpcEndpointError(RuntimeError):
     """Error when trying to resolve the QCS gRPC API endpoint."""
 
-
 class QcsGrpcError(RuntimeError):
     """Error during QCS gRPC API requests."""
 
-
 class QcsLoadError(RuntimeError):
     """Error encountered while loading the QCS API client configuration."""
diff --git a/crates/python/qcs_sdk/qpu/quilc.pyi b/crates/python/qcs_sdk/qpu/quilc.pyi
index 8d36e73b5..a8141c05b 100644
--- a/crates/python/qcs_sdk/qpu/quilc.pyi
+++ b/crates/python/qcs_sdk/qpu/quilc.pyi
@@ -1,11 +1,8 @@
-from typing import Any, Dict, Optional
+from typing import Optional
 
 DEFAULT_COMPILER_TIMEOUT: int
 
-
-class QuilcError(RuntimeError):
-    ...
-
+class QuilcError(RuntimeError): ...
 
 class CompilerOpts:
     """A set of options that determine the behavior of compiling programs with quilc."""
@@ -14,16 +11,10 @@ class CompilerOpts:
     def timeout(self) -> Optional[int]:
         """The number of seconds to wait before timing out. If `None`, there is no timeout."""
         ...
-
     def __new__(
-        cls,
-        timeout: Optional[int] = DEFAULT_COMPILER_TIMEOUT
-    ) -> "CompilerOpts":
-        ...
-
+        cls, timeout: Optional[int] = DEFAULT_COMPILER_TIMEOUT
+    ) -> "CompilerOpts": ...
     @staticmethod
     def default() -> "CompilerOpts": ...
 
-
-class TargetDevice:
-    ...
+class TargetDevice: ...
diff --git a/crates/python/qcs_sdk/qpu/result_data.pyi b/crates/python/qcs_sdk/qpu/result_data.pyi
new file mode 100644
index 000000000..1c0d3b432
--- /dev/null
+++ b/crates/python/qcs_sdk/qpu/result_data.pyi
@@ -0,0 +1,56 @@
+from typing import Dict, List, Optional
+
+class ReadoutValues:
+    """
+    A row of readout values from the QPU. Each row contains all the values emitted
+    to a memory reference across all shots. There is a variant for each possible type
+    the list of readout values could be.
+
+    Variants:
+        - ``integer``: Corresponds to the Quil `BIT`, `OCTET`, or `INTEGER` types.
+        - ``real``: Corresponds to the Quil `REAL` type.
+        - ``complex``: Corresponds to readout values containing complex numbers
+
+    Methods (each per variant):
+        - ``is_*``: if the underlying values are that type.
+        - ``as_*``: if the underlying values are that type, then those values, otherwise ``None``.
+        - ``to_*``: the underlying values as that type, raises ``ValueError`` if they are not.
+        - ``from_*``: wrap underlying values as this enum type.
+
+    """
+
+    def is_integer(self) -> bool: ...
+    def is_real(self) -> bool: ...
+    def is_complex(self) -> bool: ...
+    def as_integer(self) -> Optional[List[int]]: ...
+    def as_real(self) -> Optional[List[float]]: ...
+    def as_f64(self) -> Optional[List[complex]]: ...
+    def to_integer(self) -> List[int]: ...
+    def to_real(self) -> List[float]: ...
+    def to_complex(self) -> List[complex]: ...
+    @staticmethod
+    def from_integer(inner: List[int]) -> "ReadoutValues": ...
+    @staticmethod
+    def from_real(inner: List[float]) -> "ReadoutValues": ...
+    @staticmethod
+    def from_complex(inner: List[complex]) -> "ReadoutValues": ...
+
+class QPUResultData:
+    """
+    Encapsulates data returned from the QPU after executing a job.
+    """
+
+    def __init__(
+        self, mappings: Dict[str, str], readout_values: Dict[str, ReadoutValues]
+    ): ...
+    @property
+    def mappings(self) -> Dict[str, str]: ...
+    """
+    Get the mappings of a memory region (ie. "ro[0]") to it's key name in readout_values
+    """
+
+    @property
+    def readout_values(self) -> Dict[str, ReadoutValues]: ...
+    """
+    Get the mappings of a readout values identifier (ie. "q0") to a set of ``ReadoutValues``
+    """
diff --git a/crates/python/qcs_sdk/qvm/__init__.pyi b/crates/python/qcs_sdk/qvm/__init__.pyi
new file mode 100644
index 000000000..edbea0f16
--- /dev/null
+++ b/crates/python/qcs_sdk/qvm/__init__.pyi
@@ -0,0 +1,1 @@
+from .result_data import QVMResultData as QVMResultData
diff --git a/crates/python/qcs_sdk/qvm/result_data.pyi b/crates/python/qcs_sdk/qvm/result_data.pyi
new file mode 100644
index 000000000..e21638868
--- /dev/null
+++ b/crates/python/qcs_sdk/qvm/result_data.pyi
@@ -0,0 +1,25 @@
+"""
+Do not import this file, it has no exports.
+It is only here to represent the structure of the rust source code 1:1
+"""
+
+from typing import Dict
+
+from .._register_data import RegisterData
+
+class QVMResultData:
+    """
+    Encapsulates data returned from the QVM after executing a program.
+    """
+
+    @staticmethod
+    def from_memory_map(memory: Dict[str, RegisterData]) -> "QVMResultData": ...
+    """
+    Build a ``QVMResultData`` from a mapping of register names to a ``RegisterData`` matrix.
+    """
+
+    @property
+    def memory(self) -> Dict[str, RegisterData]: ...
+    """
+    Get the mapping of register names (ie. "ro") to a ``RegisterData`` matrix containing the register values.
+    """
diff --git a/crates/python/src/executable.rs b/crates/python/src/executable.rs
index be5f43c3d..5e44f7940 100644
--- a/crates/python/src/executable.rs
+++ b/crates/python/src/executable.rs
@@ -1,7 +1,7 @@
 use std::sync::Arc;
 
 use pyo3::{pyclass, FromPyObject};
-use qcs::{Error, Executable, JobHandle, Qpu, Qvm, Service};
+use qcs::{Error, Executable, ExecutionData, JobHandle, Service};
 use rigetti_pyo3::{
     impl_as_mut_for_wrapper, py_wrap_error, py_wrap_simple_enum, py_wrap_type,
     pyo3::{exceptions::PyRuntimeError, pymethods, types::PyDict, Py, PyAny, PyResult, Python},
@@ -100,7 +100,7 @@ impl PyExecutable {
                 .await
                 .execute_on_qvm()
                 .await
-                .map(Qvm::from)
+                .map(ExecutionData::from)
                 .map(|qvm| Python::with_gil(|py| qvm.to_python(py)))
                 .map_err(ExecutionError::from)
                 .map_err(ExecutionError::to_py_err)?
@@ -118,7 +118,7 @@ impl PyExecutable {
                 .await
                 .execute_on_qpu(quantum_processor_id)
                 .await
-                .map(Qpu::from)
+                .map(ExecutionData::from)
                 .map(|qpu| Python::with_gil(|py| qpu.to_python(py)))
                 .map_err(ExecutionError::from)
                 .map_err(ExecutionError::to_py_err)?
@@ -136,7 +136,7 @@ impl PyExecutable {
                 .await
                 .retrieve_results(job_handle.into_inner())
                 .await
-                .map(Qpu::from)
+                .map(ExecutionData::from)
                 .map(|qpu| Python::with_gil(|py| qpu.to_python(py)))
                 .map_err(ExecutionError::from)
                 .map_err(ExecutionError::to_py_err)?
diff --git a/crates/python/src/execution_data.rs b/crates/python/src/execution_data.rs
index 076881545..afe039323 100644
--- a/crates/python/src/execution_data.rs
+++ b/crates/python/src/execution_data.rs
@@ -1,31 +1,63 @@
-use std::{collections::HashMap, time::Duration};
+use std::time::Duration;
 
-use pyo3::{
-    pymethods,
-    types::{PyDelta, PyDict},
-    Py, PyResult, Python,
-};
-use qcs::{Qpu, Qvm, ReadoutMap, RegisterData};
+use numpy::{Complex64, PyArray2};
+use pyo3::{exceptions::PyValueError, pymethods, types::PyDelta, Py, PyResult, Python};
+use qcs::{ExecutionData, RegisterMap, RegisterMatrix, ResultData};
 use qcs_api_client_grpc::models::controller::{readout_values::Values, ReadoutValues};
-use rigetti_pyo3::{py_wrap_data_struct, py_wrap_type, PyWrapper, ToPython};
+use rigetti_pyo3::{
+    py_wrap_data_struct, py_wrap_error, py_wrap_type, py_wrap_union_enum, wrap_error, PyTryFrom,
+    PyWrapper, ToPython, ToPythonError,
+};
 
-use crate::grpc::models::controller::PyReadoutValuesValues;
-use crate::register_data::PyRegisterData;
+use crate::qvm::PyQvmResultData;
+use crate::{grpc::models::controller::PyReadoutValuesValues, qpu::PyQpuResultData};
 
-py_wrap_data_struct! {
-    PyQvm(Qvm) as "QVM" {
-        registers: HashMap<String, RegisterData> => HashMap<String, PyRegisterData> => Py<PyDict>,
-        duration: Option<Duration> => Option<Py<PyDelta>>
+py_wrap_union_enum! {
+    PyResultData(ResultData) as "ResultData" {
+        qpu: Qpu => PyQpuResultData,
+        qvm: Qvm => PyQvmResultData
+    }
+}
+
+wrap_error!(RegisterMatrixConversionError(
+    qcs::RegisterMatrixConversionError
+));
+py_wrap_error!(
+    execution_data,
+    RegisterMatrixConversionError,
+    PyRegisterMatrixConversionError,
+    PyValueError
+);
+
+#[pymethods]
+impl PyResultData {
+    fn to_register_map(&self, py: Python) -> PyResult<PyRegisterMap> {
+        self.as_inner()
+            .to_register_map()
+            .map_err(RegisterMatrixConversionError)
+            .map_err(ToPythonError::to_py_err)?
+            .to_python(py)
     }
 }
 
 py_wrap_data_struct! {
-    PyQpu(Qpu) as "QPU" {
-        readout_data: ReadoutMap => PyReadoutMap,
+    PyExecutionData(ExecutionData) as "ExecutionData" {
+        result_data: ResultData => PyResultData,
         duration: Option<Duration> => Option<Py<PyDelta>>
     }
 }
 
+#[pymethods]
+impl PyExecutionData {
+    #[new]
+    fn __new__(py: Python<'_>, result_data: PyResultData, duration: Option<u64>) -> PyResult<Self> {
+        Ok(Self(ExecutionData {
+            result_data: ResultData::py_try_from(py, &result_data)?,
+            duration: duration.map(Duration::from_micros),
+        }))
+    }
+}
+
 // From gRPC
 py_wrap_data_struct! {
     PyReadoutValues(ReadoutValues) as "ReadoutValues" {
@@ -34,28 +66,96 @@ py_wrap_data_struct! {
 }
 
 py_wrap_type! {
-    PyReadoutMap(ReadoutMap) as "ReadoutMap";
+    PyRegisterMap(RegisterMap) as "RegisterMap";
+}
+
+py_wrap_type! {
+    PyRegisterMatrix(RegisterMatrix) as "RegisterMatrix"
+}
+
+#[pymethods]
+impl PyRegisterMatrix {
+    #[staticmethod]
+    fn from_integer(matrix: &PyArray2<i64>) -> PyRegisterMatrix {
+        Self(RegisterMatrix::Integer(matrix.to_owned_array()))
+    }
+
+    fn to_integer<'a>(&self, py: Python<'a>) -> PyResult<&'a PyArray2<i64>> {
+        if let Some(matrix) = self.as_inner().as_integer() {
+            Ok(PyArray2::from_array(py, matrix))
+        } else {
+            Err(PyValueError::new_err("not a integer register"))
+        }
+    }
+
+    fn as_integer<'a>(&self, py: Python<'a>) -> Option<&'a PyArray2<i64>> {
+        if let Some(matrix) = self.as_inner().as_integer() {
+            Some(PyArray2::from_array(py, matrix))
+        } else {
+            None
+        }
+    }
+
+    fn is_integer(&self) -> bool {
+        matches!(self.as_inner(), RegisterMatrix::Integer(_))
+    }
+
+    #[staticmethod]
+    fn from_real(matrix: &PyArray2<f64>) -> PyRegisterMatrix {
+        Self(RegisterMatrix::Real(matrix.to_owned_array()))
+    }
+
+    fn to_real<'a>(&self, py: Python<'a>) -> PyResult<&'a PyArray2<f64>> {
+        if let Some(matrix) = self.as_inner().as_real() {
+            Ok(PyArray2::from_array(py, matrix))
+        } else {
+            Err(PyValueError::new_err("not a real numbered register"))
+        }
+    }
+
+    fn as_real<'a>(&self, py: Python<'a>) -> Option<&'a PyArray2<f64>> {
+        if let Some(matrix) = self.as_inner().as_real() {
+            Some(PyArray2::from_array(py, matrix))
+        } else {
+            None
+        }
+    }
+
+    fn is_real(&self) -> bool {
+        matches!(self.as_inner(), RegisterMatrix::Real(_))
+    }
+
+    #[staticmethod]
+    fn from_complex(matrix: &PyArray2<Complex64>) -> PyRegisterMatrix {
+        Self(RegisterMatrix::Complex(matrix.to_owned_array()))
+    }
+
+    fn to_complex<'a>(&self, py: Python<'a>) -> PyResult<&'a PyArray2<Complex64>> {
+        if let Some(matrix) = self.as_inner().as_complex() {
+            Ok(PyArray2::from_array(py, matrix))
+        } else {
+            Err(PyValueError::new_err("not a complex numbered register"))
+        }
+    }
+
+    fn as_complex<'a>(&self, py: Python<'a>) -> Option<&'a PyArray2<Complex64>> {
+        if let Some(matrix) = self.as_inner().as_complex() {
+            Some(PyArray2::from_array(py, matrix))
+        } else {
+            None
+        }
+    }
+
+    fn is_complex(&self) -> bool {
+        matches!(self.as_inner(), RegisterMatrix::Complex(_))
+    }
 }
 
 #[pymethods]
-impl PyReadoutMap {
-    pub fn get_readout_values(&self, field: String, index: u64) -> Option<PyReadoutValues> {
+impl PyRegisterMap {
+    pub fn get_register_matrix(&self, register_name: String) -> Option<PyRegisterMatrix> {
         self.as_inner()
-            .get_readout_values(field, index)
-            .map(PyReadoutValues::from)
-    }
-
-    pub fn get_readout_values_for_field(
-        &self,
-        py: Python,
-        field: &str,
-    ) -> PyResult<Option<Vec<Option<PyReadoutValues>>>> {
-        let op = self.as_inner().get_readout_values_for_field(field)?;
-        op.map(|list| {
-            list.into_iter()
-                .map(|op| op.to_python(py))
-                .collect::<PyResult<_>>()
-        })
-        .transpose()
+            .get_register_matrix(&register_name)
+            .map(PyRegisterMatrix::from)
     }
 }
diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs
index fd35e7b56..5805aceec 100644
--- a/crates/python/src/lib.rs
+++ b/crates/python/src/lib.rs
@@ -8,13 +8,15 @@ pub mod executable;
 pub mod execution_data;
 pub mod grpc;
 pub mod qpu;
+pub mod qvm;
 pub mod register_data;
 
 create_init_submodule! {
     classes: [
-        execution_data::PyQpu,
-        execution_data::PyQvm,
-        execution_data::PyReadoutMap,
+        execution_data::PyExecutionData,
+        execution_data::PyResultData,
+        execution_data::PyRegisterMap,
+        execution_data::PyRegisterMatrix,
         executable::PyExecutable,
         executable::PyParameter,
         executable::PyJobHandle,
@@ -23,7 +25,8 @@ create_init_submodule! {
         qpu::client::PyQcsClient
     ],
     errors: [
-        QcsExecutionError
+        QcsExecutionError,
+        execution_data::PyRegisterMatrixConversionError
     ],
     funcs: [
         api::compile,
@@ -38,7 +41,8 @@ create_init_submodule! {
     ],
     submodules: [
         "api": api::init_submodule,
-        "qpu": qpu::init_submodule
+        "qpu": qpu::init_submodule,
+        "qvm": qvm::init_submodule
     ],
 }
 
diff --git a/crates/python/src/qpu/mod.rs b/crates/python/src/qpu/mod.rs
index 859d6df0c..5c2928b62 100644
--- a/crates/python/src/qpu/mod.rs
+++ b/crates/python/src/qpu/mod.rs
@@ -1,11 +1,15 @@
 use pyo3::exceptions::PyRuntimeError;
 use rigetti_pyo3::{create_init_submodule, py_wrap_error, wrap_error};
 
+pub use result_data::{PyQpuResultData, PyReadoutValues};
+
 pub mod client;
 pub mod isa;
 pub mod quilc;
+mod result_data;
 
 create_init_submodule! {
+    classes: [PyQpuResultData, PyReadoutValues],
     errors: [QcsIsaError],
     submodules: [
         "client": client::init_submodule,
diff --git a/crates/python/src/qpu/result_data.rs b/crates/python/src/qpu/result_data.rs
new file mode 100644
index 000000000..c95e434d5
--- /dev/null
+++ b/crates/python/src/qpu/result_data.rs
@@ -0,0 +1,46 @@
+use std::collections::HashMap;
+
+use pyo3::{
+    pymethods,
+    types::{PyComplex, PyFloat, PyInt},
+    Py, PyResult, Python,
+};
+use qcs::qpu::{QpuResultData, ReadoutValues};
+use rigetti_pyo3::{py_wrap_type, py_wrap_union_enum, PyTryFrom, PyWrapper, ToPython};
+
+py_wrap_union_enum! {
+    PyReadoutValues(ReadoutValues) as "ReadoutValues" {
+        integer: Integer => Vec<Py<PyInt>>,
+        real: Real => Vec<Py<PyFloat>>,
+        complex: Complex => Vec<Py<PyComplex>>
+    }
+}
+
+py_wrap_type! {
+    PyQpuResultData(QpuResultData) as "QPUResultData"
+}
+
+#[pymethods]
+impl PyQpuResultData {
+    #[new]
+    fn __new__(
+        py: Python<'_>,
+        mappings: HashMap<String, String>,
+        readout_values: HashMap<String, PyReadoutValues>,
+    ) -> PyResult<Self> {
+        Ok(Self(QpuResultData::from_mappings_and_values(
+            mappings,
+            HashMap::<String, ReadoutValues>::py_try_from(py, &readout_values)?,
+        )))
+    }
+
+    #[getter]
+    fn mappings(&self, py: Python<'_>) -> PyResult<HashMap<String, String>> {
+        self.as_inner().mappings().to_python(py)
+    }
+
+    #[getter]
+    fn readout_values(&self, py: Python<'_>) -> PyResult<HashMap<String, PyReadoutValues>> {
+        self.as_inner().readout_values().to_python(py)
+    }
+}
diff --git a/crates/python/src/qvm/mod.rs b/crates/python/src/qvm/mod.rs
new file mode 100644
index 000000000..7161d7801
--- /dev/null
+++ b/crates/python/src/qvm/mod.rs
@@ -0,0 +1,35 @@
+use qcs::{qvm::QvmResultData, RegisterData};
+use rigetti_pyo3::{
+    create_init_submodule, py_wrap_type,
+    pyo3::{prelude::*, Python},
+    PyTryFrom, PyWrapper, ToPython,
+};
+use std::collections::HashMap;
+
+use crate::register_data::PyRegisterData;
+
+py_wrap_type! {
+    PyQvmResultData(QvmResultData) as "QVMResultData"
+}
+
+#[pymethods]
+impl PyQvmResultData {
+    #[staticmethod]
+    fn from_memory_map(py: Python<'_>, memory: HashMap<String, PyRegisterData>) -> PyResult<Self> {
+        Ok(Self(QvmResultData::from_memory_map(HashMap::<
+            String,
+            RegisterData,
+        >::py_try_from(
+            py, &memory
+        )?)))
+    }
+
+    #[getter]
+    fn memory(&self, py: Python<'_>) -> PyResult<HashMap<String, PyRegisterData>> {
+        self.as_inner().memory().to_python(py)
+    }
+}
+
+create_init_submodule! {
+    classes: [PyQvmResultData],
+}
diff --git a/deny.toml b/deny.toml
index 2879cef42..b426e96c4 100644
--- a/deny.toml
+++ b/deny.toml
@@ -36,6 +36,7 @@ allow = [
     "ISC",
     "MIT",
     "OpenSSL",
+    "BSD-2-Clause",
     "BSD-3-Clause",
     "Unicode-DFS-2016",
 ]


Test patch:
diff --git a/crates/lib/tests/basic_qvm.rs b/crates/lib/tests/basic_qvm.rs
index c107c7591..dad161dd3 100644
--- a/crates/lib/tests/basic_qvm.rs
+++ b/crates/lib/tests/basic_qvm.rs
@@ -19,7 +19,7 @@ MEASURE 1 second
 async fn test_bell_state() {
     const SHOTS: u16 = 10;
 
-    let mut data = Executable::from_quil(PROGRAM)
+    let data = Executable::from_quil(PROGRAM)
         .with_config(ClientConfiguration::default())
         .with_shots(SHOTS)
         .read_from("first")
@@ -28,22 +28,30 @@ async fn test_bell_state() {
         .await
         .expect("Could not run on QVM");
 
-    let first: Vec<Vec<i8>> = data
-        .registers
-        .remove("first")
-        .expect("Missing first buffer")
-        .into_i8()
-        .expect("Produced wrong data type");
-    let second: Vec<Vec<i8>> = data
-        .registers
-        .remove("second")
-        .expect("Missing second buffer")
-        .into_i8()
-        .expect("Produced wrong data type");
+    let first = data
+        .result_data
+        .to_register_map()
+        .expect("should convert to readout map")
+        .get_register_matrix("first")
+        .expect("should have first register")
+        .as_integer()
+        .expect("first register should be integers")
+        .to_owned();
+
+    let second = data
+        .result_data
+        .to_register_map()
+        .expect("should convert to readout map")
+        .get_register_matrix("second")
+        .expect("should have second register")
+        .as_integer()
+        .expect("second register should be integers")
+        .to_owned();
+
+    assert_eq!(first.shape(), [SHOTS.into(), 1]);
+    assert_eq!(second.shape(), [SHOTS.into(), 1]);
 
     for (first, second) in first.into_iter().zip(second) {
-        assert_eq!(first.len(), 1);
-        assert_eq!(second.len(), 1);
-        assert_eq!(first[0], second[0]);
+        assert_eq!(first, second);
     }
 }
diff --git a/crates/lib/tests/mocked_qpu.rs b/crates/lib/tests/mocked_qpu.rs
index 6fa5b7676..a330eb2ba 100644
--- a/crates/lib/tests/mocked_qpu.rs
+++ b/crates/lib/tests/mocked_qpu.rs
@@ -3,11 +3,10 @@
 
 use std::time::Duration;
 
+use ndarray::arr2;
+
 use qcs::Executable;
 use qcs_api_client_common::configuration::{SECRETS_PATH_VAR, SETTINGS_PATH_VAR};
-use qcs_api_client_grpc::models::controller::{
-    readout_values::Values, IntegerReadoutValues, ReadoutValues,
-};
 
 const BELL_STATE: &str = r#"
 DECLARE ro BIT[2]
@@ -19,7 +18,7 @@ MEASURE 0 ro[0]
 MEASURE 1 ro[1]
 "#;
 
-const QPU_ID: &str = "Aspen-9";
+const QPU_ID: &str = "Aspen-M-3";
 
 #[tokio::test]
 async fn successful_bell_state() {
@@ -31,22 +30,14 @@ async fn successful_bell_state() {
         .expect("Failed to run program that should be successful");
     assert_eq!(
         result
-            .readout_data
-            .get_readout_values_for_field("ro")
+            .result_data
+            .to_register_map()
+            .expect("should convert to RegisterMap")
+            .get_register_matrix("ro")
             .expect("should have values for `ro`")
-            .unwrap(),
-        vec![
-            Some(ReadoutValues {
-                values: Some(Values::IntegerValues(IntegerReadoutValues {
-                    values: vec![0, 0],
-                })),
-            }),
-            Some(ReadoutValues {
-                values: Some(Values::IntegerValues(IntegerReadoutValues {
-                    values: vec![1, 1],
-                })),
-            }),
-        ],
+            .as_integer()
+            .expect("`ro` should have integer values"),
+        arr2(&[[0, 1], [0, 1],]),
     );
     assert_eq!(result.duration, Some(Duration::from_micros(8675)));
 }
@@ -131,7 +122,7 @@ mod mock_qcs {
                 warp::reply::json(&isa)
             });
 
-        let translate = warp::path(format!("{}:translateNativeQuilToEncryptedBinary", QPU_ID))
+        let translate = warp::path(format!("{QPU_ID}:translateNativeQuilToEncryptedBinary"))
             .and(warp::post())
             .and(warp::body::json())
             .map(|_request: TranslateNativeQuilToEncryptedBinaryRequest| {
diff --git a/crates/lib/tests/parametric_compilation.rs b/crates/lib/tests/parametric_compilation.rs
index f6ffcd7b2..a5917d987 100644
--- a/crates/lib/tests/parametric_compilation.rs
+++ b/crates/lib/tests/parametric_compilation.rs
@@ -27,18 +27,23 @@ async fn basic_substitution() {
 
     for i in 0..=200 {
         let theta = step * f64::from(i);
-        let mut result = exe
+        let result = exe
             .with_parameter("theta", 0, theta)
             .execute_on_qvm()
             .await
             .expect("Executed on QPU");
-        parametric_measurements.append(
-            &mut result
-                .registers
-                .remove("ro")
-                .expect("Found ro register")
-                .into_i8()
-                .unwrap()[0],
+        parametric_measurements.push(
+            result
+                .result_data
+                .to_register_map()
+                .expect("should convert to RegisterMap")
+                .get_register_matrix("ro")
+                .expect("should have `ro`")
+                .as_integer()
+                .expect("`ro` should have integer values")
+                .get((0, 0))
+                .expect("ro register should have a value in the first index and shot")
+                .to_owned(),
         )
     }
 
diff --git a/crates/python/tests/execution_data/test_execution_data.py b/crates/python/tests/execution_data/test_execution_data.py
new file mode 100644
index 000000000..08f73251a
--- /dev/null
+++ b/crates/python/tests/execution_data/test_execution_data.py
@@ -0,0 +1,91 @@
+from qcs_sdk.qpu import ReadoutValues, QPUResultData
+from qcs_sdk.qvm import QVMResultData
+from qcs_sdk import ResultData, RegisterData, RegisterMatrix
+import numpy as np
+from numpy.testing import assert_array_equal
+import pytest
+
+
+class TestResultData:
+    def test_to_register_map_from_qpu_result_data(self):
+        mappings = {
+            "ro[0]": "qA",
+            "ro[1]": "qB",
+            "ro[2]": "qC",
+        }
+        values = {
+            "qA": ReadoutValues.from_integer([0, 1]),
+            "qB": ReadoutValues.from_integer([1, 2]),
+            "qC": ReadoutValues.from_integer([2, 3]),
+        }
+        result_data = ResultData.from_qpu(QPUResultData(mappings, values))
+        register_map = result_data.to_register_map()
+        ro = register_map.get_register_matrix("ro")
+        assert ro is not None, "'ro' should exist in the register map"
+        ro = ro.as_integer()
+        assert ro is not None, "'ro' should be an integer register matrix"
+        expected = np.array([[0, 1, 2], [1, 2, 3]])
+
+        assert_array_equal(ro, expected)
+
+    def test_to_register_map_from_jagged_qpu_result_data(self):
+        mappings = {
+            "ro[0]": "qA",
+            "ro[1]": "qB",
+            "ro[2]": "qC",
+        }
+        values = {
+            "qA": ReadoutValues.from_integer([0, 1]),
+            "qB": ReadoutValues.from_integer([1]),
+            "qC": ReadoutValues.from_integer([2, 3]),
+        }
+        result_data = ResultData.from_qpu(QPUResultData(mappings, values))
+
+        with pytest.raises(ValueError):
+            result_data.to_register_map()
+
+    def test_to_register_map_from_qvm_result_data(self):
+        qvm_memory_map = {"ro": RegisterData.from_i16([[0, 1, 2], [1, 2, 3]])}
+        qvm_result_data = QVMResultData.from_memory_map(qvm_memory_map)
+        result_data = ResultData.from_qvm(qvm_result_data)
+        register_map = result_data.to_register_map()
+        ro = register_map.get_register_matrix("ro")
+        assert ro is not None, "'ro' should exist in the register map"
+        ro = ro.as_integer()
+        assert ro is not None, "'ro' should be an integer register matrix"
+        expected = np.array([[0, 1, 2], [1, 2, 3]])
+
+        assert_array_equal(ro, expected)
+
+
+class TestRegisterMatrix:
+    def test_integer(self):
+        m = np.array([[0, 1, 2], [1, 2, 3]])
+        register_matrix = RegisterMatrix.from_integer(m)
+        assert register_matrix.is_integer()
+        register_matrix = register_matrix.as_integer()
+        assert (
+            register_matrix is not None
+        ), "register_matrix should be an integer matrix"
+        assert_array_equal(register_matrix, m)
+
+    def test_real(self):
+        m = np.array([[0.0, 1.1, 2.2], [1.1, 2.2, 3.3]])
+        register_matrix = RegisterMatrix.from_real(m)
+        assert register_matrix.is_real()
+        register_matrix = register_matrix.as_real()
+        assert register_matrix is not None, "register_matrix should be a real matrix"
+        assert_array_equal(register_matrix, m)
+
+    def test_complex(self):
+        m = np.array(
+            [
+                [complex(0, 1), complex(1, 2), complex(2, 3)],
+                [complex(1, 2), complex(2, 3), complex(3, 4)],
+            ]
+        )
+        register_matrix = RegisterMatrix.from_complex(m)
+        assert register_matrix.is_complex()
+        register_matrix = register_matrix.as_complex()
+        assert register_matrix is not None, "register_matrix should be a complex matrix"
+        assert_array_equal(register_matrix, m)
diff --git a/crates/python/tests/test_api.py b/crates/python/tests/test_api.py
index eb0993991..f8104b0f0 100644
--- a/crates/python/tests/test_api.py
+++ b/crates/python/tests/test_api.py
@@ -23,7 +23,7 @@ async def test_execute(bell_program: str, device_2q):
     compiled_program = await qcs_sdk.compile(bell_program, device_2q)
     translation_result = await qcs_sdk.translate(compiled_program, 1, "Aspen-11")
     job_id = await qcs_sdk.submit(translation_result["program"], {}, "Aspen-11")
-    await qcs_sdk.retrieve_results(job_id, "Aspen-11")
+    await qcs_sdk.retrieve_results(job_id, "Aspen-M-3")
 
 
 def test_rewrite_arithmetic():
diff --git a/crates/python/tests/test_isa.py b/crates/python/tests/test_isa.py
index 9b2039646..d7e3b6be9 100644
--- a/crates/python/tests/test_isa.py
+++ b/crates/python/tests/test_isa.py
@@ -7,24 +7,19 @@
 from qcs_sdk.qpu.isa import InstructionSetArchitecture, Family
 
 
-
 def ignore_nones(value):
     """Recursively ignore `None` values, useful for comparing json serializations."""
     if isinstance(value, list):
         return [ignore_nones(x) for x in value if x is not None]
     elif isinstance(value, dict):
-        return {
-            key: ignore_nones(val)
-            for key, val in value.items()
-            if val is not None
-        }
+        return {key: ignore_nones(val) for key, val in value.items() if val is not None}
     else:
         return value
 
 
 @pytest.fixture
 def aspen_m_3_json() -> str:
-    filepath = path.join(path.dirname(__file__), "fixtures/aspen-m-3.json" )
+    filepath = path.join(path.dirname(__file__), "fixtures/aspen-m-3.json")
     with open(filepath) as f:
         contents = f.read()
     return contents