Repository: PennyLaneAI/catalyst
Pull request #1598: Sync behaviour of measurement processes with null.qubit + qjit to those in native PennyLane
Author: login: erick-xanadu
Created: 2025-04-01T19:46:40Z | Merged: 2025-04-08T16:11:54Z

**Context:** Circuits executed on the null.qubit device with QJIT were returning uninitialized data from measurement processes, such as `sample()`, `counts()`, `probs()` and `state()`, and the mid-circuit measurement operation `measure()`. The expectation for null devices is that measurements return the result corresponding to the '0' state rather than uninitialized memory. This requirement ensures that the behaviour of null qubit is deterministic, which is important for using it in mock workflows. The `expval()` and `var()` measurement processes already correctly return 0.

In addition, the `measure()` operation was allocating (uninitialized) memory for each measurement result. This is not preferable since we will be pushing large MBQC workloads through null qubit and want to minimize the memory usage as much as possible.

Below are a few example workloads that illustrate the expected behaviour of measurement processes with null.qubit:

**Sample**

`sample()` should return an `n_shots * n_wires` array filled with zeros:

```python
dev = qml.device("null.qubit", wires=2, shots=5)

@qjit
@qml.qnode(dev)
def circuit_sample():
    qml.Hadamard(0)
    qml.Hadamard(1)
    return qml.sample()
```

```pycon
>>> circuit_sample()
array([[0, 0],
       [0, 0],
       [0, 0],
       [0, 0],
       [0, 0]])
```

**Counts**

`counts()` should behave as described in the [Catalyst Quick Start Guide](https://docs.pennylane.ai/projects/catalyst/en/stable/dev/quick_start.html#measurements) and return a pair of arrays representing a dictionary from basis states to the number of observed samples, where the basis state are labelled 0, 1, ..., `2^n_wires - 1` and the number of observations of the '0' state is equal to `n_shots` and the number of observations of all other states are equal to zero:

```python
dev = qml.device("null.qubit", wires=2, shots=100)

@qjit
@qml.qnode(dev)
def circuit_counts():
    qml.Hadamard(0)
    qml.Hadamard(1)
    return qml.counts()
```

```pycon
>>> circuit_counts()
(array([0, 1, 2, 3]), array([100,   0,   0,   0]))
```

**Probs**

`probs()` should return a probability of 1 for the '0' state and a probability of 0 for all other states:

```python
dev = qml.device("null.qubit", wires=2, shots=100)

@qjit
@qml.qnode(dev)
def circuit_probs():
    qml.Hadamard(0)
    qml.Hadamard(1)
    return qml.probs()
```

```pycon
>>> circuit_probs()
array([1., 0., 0., 0.])
```

**State**

`state()` should return the state vector corresponding to the pure ground state:

```python
dev = qml.device("null.qubit", wires=2, shots=100)

@qjit
@qml.qnode(dev)
def circuit_state():
    qml.Hadamard(0)
    qml.Hadamard(1)
    return qml.state()
```

```pycon
>>> circuit_state()
array([1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j])
```

**Description of the Change:** Makes the behaviour of null.qubit + qjit in sync with its behaviour in native PennyLane, where possible, as described above. To this end, the following changes to the Catalyst `NullQubit` runtime have been made:

- Call `std::fill(..., ..., 0)` on all `DataView` objects tracking measurement-process results to zero out the results. Set other elements to their expected values where appropriate.
- Add `device_shots_` member variable to `NullQubit` and update corresponding setter/getter methods. This is necessary to get the correct output for `counts()`.
- Add asserts to the `DataView` iterator increment methods. This is related to the bug described in the comments [here](https://github.com/PennyLaneAI/catalyst/pull/1598#issuecomment-2779178046). No functions, user-facing or otherwise, should trigger this assert.
- Instead of `NullQubit::Measure()` returning a `malloc(bool)`, it now returns a pointer to a class-member static bool. For NullQubit, we always return `0`, i.e. `false`.

This PR also includes a few other changes related to null.qubit in Catalyst:

- Adds runtime backend tests for the `NullQubit` measurement processes.
- Adds frontend tests for the `sample()`, `counts()`, `probs()`, `state()`, `expval()`, and `var()` measurement processes.
- Adds `null.qubit` to the list of devices with Catalyst support.

**Benefits:**

- Better agreement between PennyLane's null.qubit and null.qubit+qjit
- Data initialized to zero by default
- Better testing coverage
- These changes should only affect the null.qubit device; no other devices used in the stack should be affected

**Related GitHub Issues:** Fixes #1596.

[sc-87835]


Linked issue #1596: `qml.counts` yields garbage output on `null.qubit` with QJIT
Consider the following PennyLane circuit using `null.qubit`:

```python
dev = qml.device("null.qubit", wires=2, shots=100)

@qml.qnode(dev)
def circuit_counts():
    qml.Hadamard(0)
    qml.Hadamard(1)
    return qml.counts(wires=0), qml.counts(wires=1)

results = circuit_counts()
print(f"0: {results[0]}")
print(f"1: {results[1]}")
```

The output for `shots=100` is 100 instances of `'00'` for both wires, as expected for `null.qubit`:

```
0: {'00': array(100)}
1: {'00': array(100)}
```

When we QJIT compile this circuit, we get garbage output for the number of counts:

```python
dev = qml.device("null.qubit", wires=2, shots=100)

@catalyst.qjit
@qml.qnode(dev)
def circuit_counts():
    qml.Hadamard(0)
    qml.Hadamard(1)
    return qml.counts(wires=0), qml.counts(wires=1)

results = circuit_counts()
print(f"0: {results[0]}")
print(f"1: {results[1]}")
``` 

Possible output:

```
0: (Array([0, 0], dtype=int64), Array([251136,      0], dtype=int64))
1: (Array([0, 0], dtype=int64), Array([1031600423,          0], dtype=int64))
```

Bear in mind that the output format is different in Catalyst, as described in the [Catalyst Quick Start Guide](https://docs.pennylane.ai/projects/catalyst/en/stable/dev/quick_start.html#measurements).

Running the compiled circuit multiple times yields different results on each iteration:

```python
for i in range(3):
    print(f"--- Trial {i} ---")
    results = circuit_counts_qjit()
    print(f"0: {results[0]}")
    print(f"1: {results[1]}")
```

Possible output:

```
--- Trial 0 ---
0: (Array([0, 0], dtype=int64), Array([123934076844848,      1028680800], dtype=int64))
1: (Array([0, 0], dtype=int64), Array([1028896947,          0], dtype=int64))
--- Trial 1 ---
0: (Array([0, 0], dtype=int64), Array([1028904466,          0], dtype=int64))
1: (Array([0, 0], dtype=int64), Array([1028835491,          0], dtype=int64))
--- Trial 2 ---
0: (Array([0, 0], dtype=int64), Array([1028891542,          0], dtype=int64))
1: (Array([0, 0], dtype=int64), Array([1029382126,          0], dtype=int64))
```
Linked issue comments: user: login: dime10
created_at: 2025-04-01T19:21:42Z
body: Is there an expectation that null.qubit produces sensible values? 
author_association: CONTRIBUTOR
user: login: joeycarter
created_at: 2025-04-01T19:25:53Z
body: Yes 🙂 

For shots=100, for instance, we would expect

```
0: (Array([0, 0], dtype=int64), Array([100,      0], dtype=int64))
1: (Array([0, 0], dtype=int64), Array([100,      0], dtype=int64))
```

Or maybe even this:

```
0: (Array([0], dtype=int64), Array([100], dtype=int64))
1: (Array([0], dtype=int64), Array([100], dtype=int64))
```
author_association: CONTRIBUTOR
user: login: dime10
created_at: 2025-04-01T22:45:40Z
body: Why do we care about the result values of a device whose explicit purpose is to ignore the semantics of the instructions it receives?

I don't really care what the device produces, just trying to understand why this change would be necessary.
author_association: CONTRIBUTOR
user: login: joeycarter
created_at: 2025-04-02T18:18:14Z
body: Upon further reflection, output with all zeros, e.g.

```
(Array([0, 0], dtype=int64), Array([0, 0], dtype=int64))
```

would also be acceptable, since this satisfies our requirement of null qubit that it be deterministic, with return values all of the correct type and shape (the numeric values themselves don't matter).
author_association: CONTRIBUTOR

Diff:
diff --git a/doc/dev/devices.rst b/doc/dev/devices.rst
index 7e86b5f1a4..4584164595 100644
--- a/doc/dev/devices.rst
+++ b/doc/dev/devices.rst
@@ -95,3 +95,8 @@ Supported backend devices include:
 
       See the `Catalyst configuration file <https://github.com/PennyLaneAI/catalyst/blob/main/frontend/catalyst/third_party/oqc/src/oqc.toml>`__
       for natively supported instructions.
+
+  * - ``null.qubit``
+
+    - A device that performs no numerical operations, useful for resource estimation and benchmarking.
+      See the `NullQubit documentation <https://pennylane.ai/devices/null-qubit>`__ for more details.
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index ca28dc0d31..2837845b00 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -4,6 +4,15 @@
 
 <h3>Improvements 🛠</h3>
 
+* The behaviour of measurement processes executed on `null.qubit` with QJIT is now more in line with
+  their behaviour on `null.qubit` *without* QJIT.
+  [(#1598)](https://github.com/PennyLaneAI/catalyst/pull/1598)
+
+  Previously, measurement processes like `qml.sample()`, `qml.counts()`, `qml.probs()`, etc.
+  returned values from uninitialized memory when executed on `null.qubit` with QJIT. This change
+  ensures that measurement processes on `null.qubit` always return the value 0 or the result
+  corresponding to the '0' state, depending on the context.
+
 <h3>Breaking changes 💔</h3>
 
 <h3>Deprecations 👋</h3>
@@ -18,3 +27,5 @@
 
 This release contains contributions from (in alphabetical order):
 
+Joey Carter,
+Erick Ochoa Lopez.
diff --git a/frontend/test/pytest/test_custom_devices.py b/frontend/test/pytest/test_custom_devices.py
index eaff09e496..dabe79c95e 100644
--- a/frontend/test/pytest/test_custom_devices.py
+++ b/frontend/test/pytest/test_custom_devices.py
@@ -70,12 +70,10 @@ def execute(self, circuits, execution_config):
     @qjit
     @qml.qnode(device)
     def f():
-        """This function would normally return False.
-        However, NullQubit as defined in librtd_null_qubit.so
-        has been implemented to always return True."""
+        """Measurements on a NullQubit device always return false (i.e. 0)"""
         return measure(0)
 
-    assert f() == True
+    assert f() == False
 
 
 def test_custom_device_bad_directory():
diff --git a/frontend/test/pytest/test_measurements_results.py b/frontend/test/pytest/test_measurements_results.py
index e69973d1a8..ac12c3fae2 100644
--- a/frontend/test/pytest/test_measurements_results.py
+++ b/frontend/test/pytest/test_measurements_results.py
@@ -1157,5 +1157,158 @@ def purity_circuit():
         assert purity_circuit() == expected
 
 
+class TestNullQubitMeasurements:
+    """Test measurement results with null.qubit."""
+
+    n_shots = 100
+
+    @pytest.mark.parametrize("n_qubits", [0, 1, 2])
+    def test_nullq_sample(self, n_qubits):
+        """Test qml.sample() on null.qubit device."""
+
+        @qjit
+        @qml.qnode(qml.device("null.qubit", wires=n_qubits, shots=self.n_shots))
+        def circuit_sample():
+            for i in range(n_qubits):
+                qml.Hadamard(wires=i)
+            return qml.sample()
+
+        # Explicitly define expected result for sample since qjit outputs results in different
+        # format than native PennyLane
+        expected = np.zeros(shape=(self.n_shots, n_qubits), dtype=np.int64)
+        observed = circuit_sample()
+        assert np.array_equal(observed, expected)
+
+    def test_nullq_sample_per_wire(self):
+        """Test qml.sample() on null.qubit device, returning results per wire."""
+
+        @qjit
+        @qml.qnode(qml.device("null.qubit", wires=2, shots=self.n_shots))
+        def circuit_sample():
+            qml.Hadamard(wires=0)
+            qml.Hadamard(wires=1)
+            return qml.sample(wires=0), qml.sample(wires=1)
+
+        # Explicitly define expected result for sample since qjit outputs results in different
+        # format than native PennyLane
+        expected = np.zeros(shape=(self.n_shots, 1), dtype=np.int64)
+        observed_0, observed_1 = circuit_sample()
+        assert np.array_equal(observed_0, expected)
+        assert np.array_equal(observed_1, expected)
+
+    @pytest.mark.parametrize("n_qubits", [0, 1, 2])
+    def test_nullq_counts(self, n_qubits):
+        """Test qml.counts() on null.qubit device."""
+
+        @qjit
+        @qml.qnode(qml.device("null.qubit", wires=n_qubits, shots=self.n_shots))
+        def circuit_counts():
+            for i in range(n_qubits):
+                qml.Hadamard(wires=i)
+            return qml.counts()
+
+        # Explicitly define expected result for counts since qjit outputs results in different
+        # format than native PennyLane
+        expected = [
+            np.arange(0, 2**n_qubits, dtype=np.int64),
+            np.zeros(shape=2**n_qubits, dtype=np.int64),
+        ]
+        expected[1][0] = self.n_shots
+        observed = circuit_counts()
+        assert np.array_equal(observed, expected)
+
+    def test_nullq_counts_per_wire(self):
+        """Test qml.counts() on null.qubit device, returning results per wire."""
+
+        @qjit
+        @qml.qnode(qml.device("null.qubit", wires=2, shots=self.n_shots))
+        def circuit_counts():
+            qml.Hadamard(wires=0)
+            qml.Hadamard(wires=1)
+            return qml.counts(wires=0), qml.counts(wires=1)
+
+        # Explicitly define expected result for counts since qjit outputs results in different
+        # format than native PennyLane
+        expected = [
+            np.arange(0, 2, dtype=np.int64),
+            np.zeros(shape=2, dtype=np.int64),
+        ]
+        expected[1][0] = self.n_shots
+        observed_0, observed_1 = circuit_counts()
+        assert np.array_equal(observed_0, expected)
+        assert np.array_equal(observed_1, expected)
+
+    @pytest.mark.parametrize("n_qubits", [0, 1, 2])
+    def test_nullq_probs(self, n_qubits):
+        """Test qml.probs() on null.qubit device."""
+
+        @qml.qnode(qml.device("null.qubit", wires=n_qubits, shots=self.n_shots))
+        def circuit_probs():
+            for i in range(n_qubits):
+                qml.Hadamard(wires=i)
+            return qml.probs()
+
+        expected = circuit_probs()
+        observed = qjit(circuit_probs)()
+        assert np.array_equal(observed, expected)
+
+    def test_nullq_probs_per_wire(self):
+        """Test qml.probs() on null.qubit device, returning results per wire."""
+
+        @qml.qnode(qml.device("null.qubit", wires=2, shots=self.n_shots))
+        def circuit_probs():
+            qml.Hadamard(wires=0)
+            qml.Hadamard(wires=1)
+            return qml.probs(wires=0), qml.probs(wires=1)
+
+        expected = circuit_probs()
+        observed = qjit(circuit_probs)()
+        assert np.array_equal(observed, expected)
+
+    @pytest.mark.parametrize("n_qubits", [0, 1, 2])
+    def test_nullq_state(self, n_qubits):
+        """Test qml.state() on null.qubit device."""
+
+        @qml.qnode(qml.device("null.qubit", wires=n_qubits, shots=None))
+        def circuit_state():
+            for i in range(n_qubits):
+                qml.Hadamard(wires=i)
+            return qml.state()
+
+        expected = circuit_state()
+        observed = qjit(circuit_state)()
+        assert np.array_equal(observed, expected)
+
+    @pytest.mark.parametrize("n_qubits", [1, 2])
+    def test_nullq_expval(self, n_qubits):
+        """Test qml.expval() on null.qubit device."""
+
+        @qml.qnode(qml.device("null.qubit", wires=n_qubits, shots=self.n_shots))
+        def circuit_expval():
+            for i in range(n_qubits):
+                qml.Hadamard(wires=i)
+
+            return qml.expval(qml.X(0)), qml.expval(qml.Y(0)), qml.expval(qml.Z(0))
+
+        expected = circuit_expval()
+        observed = qjit(circuit_expval)()
+        assert np.array_equal(observed, expected)
+
+    @pytest.mark.parametrize("n_qubits", [1, 2])
+    def test_nullq_var(self, n_qubits):
+        """Test qml.var() on null.qubit device."""
+
+        @qml.qnode(qml.device("null.qubit", wires=n_qubits, shots=self.n_shots))
+        def circuit_var():
+            for i in range(n_qubits):
+                qml.Hadamard(wires=i)
+
+            return qml.var(qml.X(0)), qml.var(qml.Y(0)), qml.var(qml.Z(0))
+
+        expected = circuit_var()
+        observed = qjit(circuit_var)()
+        assert np.array_equal(observed, expected)
+
+
 if __name__ == "__main__":
     pytest.main(["-x", __file__])
diff --git a/runtime/include/DataView.hpp b/runtime/include/DataView.hpp
index 6cf50f249e..b78063cee6 100644
--- a/runtime/include/DataView.hpp
+++ b/runtime/include/DataView.hpp
@@ -59,6 +59,7 @@ template <typename T, size_t R> class DataView {
             int64_t idx;
             for (int64_t i = R; i > 0; --i) {
                 idx = i - 1;
+                RT_ASSERT(view.sizes[idx] > 0);
                 if (indices[idx]++ < view.sizes[idx] - 1) {
                     next_axis = idx;
                     break;
@@ -77,6 +78,7 @@ template <typename T, size_t R> class DataView {
             int64_t idx;
             for (int64_t i = R; i > 0; --i) {
                 idx = i - 1;
+                RT_ASSERT(view.sizes[idx] > 0);
                 if (indices[idx]++ < view.sizes[idx] - 1) {
                     next_axis = idx;
                     break;
diff --git a/runtime/lib/backend/null_qubit/NullQubit.hpp b/runtime/lib/backend/null_qubit/NullQubit.hpp
index f5ea09c54d..9f35fe4560 100644
--- a/runtime/lib/backend/null_qubit/NullQubit.hpp
+++ b/runtime/lib/backend/null_qubit/NullQubit.hpp
@@ -49,7 +49,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     NullQubit &operator=(NullQubit &&) = delete;
 
     /**
-     * @brief Doesn't Allocate a qubit.
+     * @brief Allocate a "null" qubit.
      *
      * @return `QubitIdType`
      */
@@ -60,7 +60,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Allocate a vector of qubits.
+     * @brief Allocate a vector of "null" qubits.
      *
      * @param num_qubits The number of qubits to allocate.
      *
@@ -77,7 +77,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Release a qubit.
+     * @brief Release a qubit.
      */
     void ReleaseQubit(QubitIdType q)
     {
@@ -88,7 +88,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Release all qubits.
+     * @brief Release all qubits.
      */
     void ReleaseAllQubits()
     {
@@ -97,28 +97,28 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Get the number of allocated qubits.
+     * @brief Get the number of allocated qubits.
      *
      * @return `size_t`
      */
     [[nodiscard]] auto GetNumQubits() const -> size_t { return num_qubits_; }
 
     /**
-     * @brief Doesn't Set the number of device shots.
+     * @brief Set the number of device shots.
      *
      * @param shots The number of noise shots
      */
-    void SetDeviceShots(size_t shots) {}
+    void SetDeviceShots(size_t shots) { device_shots_ = shots; }
 
     /**
-     * @brief Doesn't Get the number of device shots.
+     * @brief Get the number of device shots.
      *
      * @return `size_t`
      */
-    [[nodiscard]] auto GetDeviceShots() const -> size_t { return 0; }
+    [[nodiscard]] auto GetDeviceShots() const -> size_t { return device_shots_; }
 
     /**
-     * @brief Doesn't Set the PRNG of the device.
+     * @brief Doesn't set the PRNG of the device.
      *
      * The Catalyst runtime enables seeded program execution on non-hardware devices.
      * A random number generator instance is managed by the runtime to predictably
@@ -136,7 +136,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     void SetDevicePRNG([[maybe_unused]] std::mt19937 *gen) {}
 
     /**
-     * @brief Doesn't Start recording a quantum tape if provided.
+     * @brief Doesn't start recording a quantum tape if provided.
      *
      * @note This is backed by the `Catalyst::Runtime::CacheManager<ComplexT>` property in
      * the device implementation.
@@ -144,7 +144,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     void StartTapeRecording() {}
 
     /**
-     * @brief Doesn't Stop recording a quantum tape if provided.
+     * @brief Doesn't stop recording a quantum tape if provided.
      *
      * @note This is backed by the `Catalyst::Runtime::CacheManager<ComplexT>` property in
      * the device implementation.
@@ -152,36 +152,42 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     void StopTapeRecording() {}
 
     /**
-     * @brief Not the Result value for "Zero" used in the measurement process.
+     * @brief Not the result value for "Zero" used in the measurement process.
      *
      * @return `Result`
      */
-    [[nodiscard]] auto Zero() const -> Result { return NULL; }
+    [[nodiscard]] auto Zero() const -> Result
+    {
+        return const_cast<Result>(&GLOBAL_RESULT_FALSE_CONST);
+    }
 
     /**
-     * @brief Not the Result value for "One"  used in the measurement process.
+     * @brief Not the result value for "One"  used in the measurement process.
      *
      * @return `Result`
      */
-    [[nodiscard]] auto One() const -> Result { return NULL; }
+    [[nodiscard]] auto One() const -> Result
+    {
+        return const_cast<Result>(&GLOBAL_RESULT_TRUE_CONST);
+    }
 
     /**
-     * @brief Not A helper method to print the state vector of a device.
+     * @brief Not a helper method to print the state vector of a device.
      */
     void PrintState() {}
 
     /**
-     * @brief Doesn't Prepare subsystems using the given ket vector in the computational basis.
+     * @brief Doesn't prepare subsystems using the given ket vector in the computational basis.
      */
     void SetState(DataView<std::complex<double>, 1> &, std::vector<QubitIdType> &) {}
 
     /**
-     * @brief Doesn't Prepare a single computational basis state.
+     * @brief Doesn't prepare a single computational basis state.
      */
     void SetBasisState(DataView<int8_t, 1> &, std::vector<QubitIdType> &) {}
 
     /**
-     * @brief Doesn't Apply a single gate to the state vector of a device with its name if this is
+     * @brief Doesn't apply a single gate to the state vector of a device with its name if this is
      * supported.
      */
     void NamedOperation(const std::string &name, const std::vector<double> &params,
@@ -192,7 +198,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Apply a given matrix directly to the state vector of a device.
+     * @brief Doesn't apply a given matrix directly to the state vector of a device.
      *
      */
     void MatrixOperation(const std::vector<std::complex<double>> &,
@@ -203,7 +209,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Construct a named (Identity, PauliX, PauliY, PauliZ, and Hadamard)
+     * @brief Doesn't construct a named (Identity, PauliX, PauliY, PauliZ, and Hadamard)
      * or Hermitian observable.
      *
      * @return `ObsIdType` Index of the constructed observable
@@ -215,14 +221,14 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Construct a tensor product of observables.
+     * @brief Doesn't construct a tensor product of observables.
      *
      * @return `ObsIdType` Index of the constructed observable
      */
     auto TensorObservable(const std::vector<ObsIdType> &) -> ObsIdType { return 0.0; }
 
     /**
-     * @brief Doesn't Construct a Hamiltonian observable.
+     * @brief Doesn't construct a Hamiltonian observable.
      *
      * @return `ObsIdType` Index of the constructed observable
      */
@@ -233,63 +239,127 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
     }
 
     /**
-     * @brief Doesn't Compute the expected value of an observable.
+     * @brief Doesn't compute the expected value of an observable.
+     *
+     * Always return 0.
      *
      * @return `double` The expected value
      */
     auto Expval(ObsIdType) -> double { return 0.0; }
 
     /**
-     * @brief Doesn't Compute the variance of an observable.
+     * @brief Doesn't compute the variance of an observable.
+     *
+     * Always return 0.
      *
      * @return `double` The variance
      */
     auto Var(ObsIdType) -> double { return 0.0; }
 
     /**
-     * @brief Doesn't Get the state-vector of a device.
+     * @brief Doesn't get the state-vector of a device.
+     *
+     * Always fills the state vector corresponding to the pure ground state, e.g. [1, 0] for a
+     * one-qubit system, [1, 0, 0, 0] for a two-qubit system, etc.
      */
-    void State(DataView<std::complex<double>, 1> &) {}
+    void State(DataView<std::complex<double>, 1> &state)
+    {
+        auto iter = state.begin();
+        *iter = 1.0;
+        if (num_qubits_ > 0) {
+            ++iter;
+            std::fill(iter, state.end(), 0.0);
+        }
+    }
 
     /**
-     * @brief Doesn't Compute the probabilities of each computational basis state.
+     * @brief Doesn't compute the probabilities of each computational basis state.
+     *
+     * Always fills a probability of 1 for the ground basis state, and 0 for all other basis states,
+     * e.g. [1, 0] for a one-qubit system, [1, 0, 0, 0] for a two-qubit system, etc.
      */
-    void Probs(DataView<double, 1> &) {}
+    void Probs(DataView<double, 1> &probs)
+    {
+        auto iter = probs.begin();
+        *iter = 1.0;
+        if (num_qubits_ > 0) {
+            ++iter;
+            std::fill(iter, probs.end(), 0.0);
+        }
+    }
 
     /**
-     * @brief Doesn't Compute the probabilities for a subset of the full system.
+     * @brief Doesn't compute the probabilities for a subset of the full system.
+     *
+     * Same behaviour as Probs().
      */
-    void PartialProbs(DataView<double, 1> &, const std::vector<QubitIdType> &) {}
+    void PartialProbs(DataView<double, 1> &probs, const std::vector<QubitIdType> &)
+    {
+        Probs(probs);
+    }
 
     /**
-     * @brief Doesn't Compute samples with the number of shots on the entire wires,
-     * returing raw samples.
+     * @brief Doesn't compute samples with the number of shots on the entire wires,
+     * returning raw samples.
+     *
+     * Always fills array of samples with 0.
      */
-    void Sample(DataView<double, 2> &, size_t) {}
+    void Sample(DataView<double, 2> &samples, size_t)
+    {
+        // If num_qubits == 0, the samples array is unallocated (shape=(shots, 0)), so don't fill
+        if (num_qubits_ > 0) {
+            std::fill(samples.begin(), samples.end(), 0.0);
+        }
+    }
 
     /**
      * @brief Doesn't Compute partial samples with the number of shots on `wires`,
      * returing raw samples.
      *
+     * Same behaviour as Sample().
+     *
      * @param samples The pre-allocated `DataView<double, 2>`representing a matrix of
      * shape `shots * numWires`. The built-in iterator in `DataView<double, 2>`
      * iterates over all elements of `samples` row-wise.
      */
-    void PartialSample(DataView<double, 2> &, const std::vector<QubitIdType> &, size_t) {}
+    void PartialSample(DataView<double, 2> &samples, const std::vector<QubitIdType> &, size_t)
+    {
+        Sample(samples, 0U);
+    }
 
     /**
-     * @brief Doesn't Sample with the number of shots on the entire wires, returning the
+     * @brief Doesn't sample with the number of shots on the entire wires, returning the
      * number of counts for each sample.
+     *
+     * Always fills eigenvalues with integers ranging from 0, 1, ..., 2**num_qubits
+     * Always sets the first element of counts to `shots` and fills the rest with 0.
      */
-    void Counts(DataView<double, 1> &, DataView<int64_t, 1> &, size_t) {}
+    void Counts(DataView<double, 1> &eigvals, DataView<int64_t, 1> &counts, size_t shots)
+    {
+        auto iter_eigvals = eigvals.begin();
+        *iter_eigvals = 0.0;
+        ++iter_eigvals;
+
+        auto iter_counts = counts.begin();
+        *iter_counts = shots;
+        ++iter_counts;
+
+        if (num_qubits_ > 0) {
+            std::iota(iter_eigvals, eigvals.end(), 1.0);
+            std::fill(iter_counts, counts.end(), 0);
+        }
+    }
 
     /**
      * @brief Doesn't Partial sample with the number of shots on `wires`, returning the
      * number of counts for each sample.
+     *
+     * Same behaviour as Counts().
      */
-    void PartialCounts(DataView<double, 1> &, DataView<int64_t, 1> &,
-                       const std::vector<QubitIdType> &, size_t)
+    void PartialCounts(DataView<double, 1> &eigvals, DataView<int64_t, 1> &counts,
+                       const std::vector<QubitIdType> &, size_t shots)
     {
+        Counts(eigvals, counts, shots);
     }
 
     /**
@@ -297,12 +367,7 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
      *
      * @return `Result` The measurement result
      */
-    auto Measure(QubitIdType, std::optional<int32_t>) -> Result
-    {
-        bool *ret = (bool *)malloc(sizeof(bool));
-        *ret = true;
-        return ret;
-    }
+    auto Measure(QubitIdType, std::optional<int32_t>) -> Result { return this->Zero(); }
 
     /**
      * @brief Doesn't Compute the gradient of a quantum tape, that is cached using
@@ -319,6 +384,11 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
 
   private:
     std::size_t num_qubits_{0};
+    std::size_t device_shots_{0};
     Catalyst::Runtime::QubitManager<QubitIdType, size_t> qubit_manager{};
+
+    // static constants for RESULT values
+    static constexpr bool GLOBAL_RESULT_TRUE_CONST = true;
+    static constexpr bool GLOBAL_RESULT_FALSE_CONST = false;
 };
 } // namespace Catalyst::Runtime::Devices
diff --git a/runtime/tests/Test_NullQubit.cpp b/runtime/tests/Test_NullQubit.cpp
index 0d2ef7bd5f..ed268fff3f 100644
--- a/runtime/tests/Test_NullQubit.cpp
+++ b/runtime/tests/Test_NullQubit.cpp
@@ -143,6 +143,30 @@ TEST_CASE("Test a NullQubit circuit with num_qubits=4 and observables", "[NullQu
     CHECK(obs_keys.empty());
 }
 
+TEST_CASE("Test a NullQubit circuit with num_qubits=1 that performs a measurement", "[NullQubit]")
+{
+    std::unique_ptr<NullQubit> sim = std::make_unique<NullQubit>();
+
+    // state-vector with #qubits = n
+    constexpr size_t n = 1;
+    std::vector<QubitIdType> Qs;
+    Qs.reserve(n);
+    Qs.push_back(sim->AllocateQubit());
+
+    sim->StartTapeRecording();
+    sim->NamedOperation("Hadamard", {}, {Qs[0]}, false);
+
+    auto m = sim->Measure(Qs[0], {} /*postselect*/);
+
+    auto &&[num_ops, num_obs, num_params, op_names, obs_keys] = sim->CacheManagerInfo();
+    CHECK(num_ops == 0);
+    CHECK(num_obs == 0);
+    CHECK(num_params == 0);
+    CHECK(op_names.empty());
+    CHECK(obs_keys.empty());
+    CHECK(*m == false); // Measurement of NullQubit should always return 0 (false)
+}
+
 TEST_CASE("Test __catalyst__qis__Sample with num_qubits=2 and PartialSample calling Hadamard, "
           "ControlledPhaseShift, IsingYY, and CRX quantum operations",
           "[CoreQIS]")
@@ -200,7 +224,7 @@ TEST_CASE("NullQubit (no) Basis vector", "[NullQubit]")
     sim->State(view);
 
     CHECK(view.size() == 8);
-    CHECK(view(0).real() == Catch::Approx(0.0).epsilon(1e-5));
+    CHECK(view(0).real() == Catch::Approx(1.0).epsilon(1e-5));
     CHECK(view(0).imag() == Catch::Approx(0.0).epsilon(1e-5));
 }
 
@@ -219,7 +243,7 @@ TEST_CASE("test AllocateQubits", "[NullQubit]")
     sim->State(view);
 
     CHECK(state.size() == 4);
-    CHECK(state[0].real() == Catch::Approx(0.0).epsilon(1e-5));
+    CHECK(state[0].real() == Catch::Approx(1.0).epsilon(1e-5));
     CHECK(state[0].imag() == Catch::Approx(0.0).epsilon(1e-5));
 }
 
@@ -258,7 +282,7 @@ TEST_CASE("Mix Gate test R(X,Y,Z) num_qubits=4", "[NullQubit]")
     sim->State(view);
 
     CHECK(view.size() == 16);
-    CHECK(view(0).real() == Catch::Approx(0.0).epsilon(1e-5));
+    CHECK(view(0).real() == Catch::Approx(1.0).epsilon(1e-5));
     CHECK(view(0).imag() == Catch::Approx(0.0).epsilon(1e-5));
 }
 
@@ -319,3 +343,219 @@ TEST_CASE("Test __catalyst__qis__Gradient_params Op=[Hadamard,RZ,RY,RZ,S,T,Param
     delete[] buffer;
     delete[] buffer_tp;
 }
+
+TEST_CASE("Test NullQubit measurement processes with num_qubits=0", "[NullQubit]")
+{
+    std::unique_ptr<NullQubit> sim = std::make_unique<NullQubit>();
+
+    constexpr size_t num_qubits = 0;
+    constexpr size_t shots = 100;
+
+    SECTION("State")
+    {
+        std::vector<std::complex<double>> state(1);
+        DataView<std::complex<double>, 1> state_view(state);
+        sim->State(state_view);
+
+        CHECK(state_view(0).real() == Catch::Approx(1.0).margin(1e-5));
+        CHECK(state_view(0).imag() == Catch::Approx(0.0).margin(1e-5));
+    }
+
+    SECTION("Probs")
+    {
+        std::vector<double> probs(1);
+        DataView<double, 1> probs_view(probs);
+        sim->Probs(probs_view);
+
+        CHECK(probs_view(0) == Catch::Approx(1.0).margin(1e-5));
+    }
+
+    SECTION("PartialProbs")
+    {
+        std::vector<double> probs(1);
+        DataView<double, 1> probs_view(probs);
+        std::vector<QubitIdType> wires;
+        sim->PartialProbs(probs_view, wires);
+
+        CHECK(probs_view(0) == Catch::Approx(1.0).margin(1e-5));
+    }
+
+    SECTION("Sample")
+    {
+        double *_data_aligned = nullptr;
+        size_t _offset = 0U;
+        size_t _sizes[2] = {shots, num_qubits};
+        size_t _strides[2] = {1, 1};
+
+        DataView<double, 2> sample_view(_data_aligned, _offset, _sizes, _strides);
+        sim->Sample(sample_view, shots);
+
+        CHECK(sample_view.size() == 0);
+    }
+
+    SECTION("PartialSample")
+    {
+        double *_data_aligned = nullptr;
+        size_t _offset = 0U;
+        size_t _sizes[2] = {shots, num_qubits};
+        size_t _strides[2] = {1, 1};
+
+        DataView<double, 2> sample_view(_data_aligned, _offset, _sizes, _strides);
+        std::vector<QubitIdType> wires;
+        sim->PartialSample(sample_view, wires, shots);
+
+        CHECK(sample_view.size() == 0);
+    }
+
+    SECTION("Counts")
+    {
+        std::vector<int64_t> counts(1);
+        DataView<int64_t, 1> counts_view(counts);
+        std::vector<double> eigvals(1);
+        DataView<double, 1> eigvals_view(eigvals);
+        sim->Counts(eigvals_view, counts_view, shots);
+
+        CHECK(eigvals_view(0) == Catch::Approx(0.0).margin(1e-5));
+        CHECK(counts_view(0) == shots);
+    }
+
+    SECTION("PartialCounts")
+    {
+        std::vector<int64_t> counts(1);
+        DataView<int64_t, 1> counts_view(counts);
+        std::vector<double> eigvals(1);
+        DataView<double, 1> eigvals_view(eigvals);
+        std::vector<QubitIdType> wires;
+        sim->PartialCounts(eigvals_view, counts_view, wires, shots);
+
+        CHECK(eigvals_view(0) == Catch::Approx(0.0).margin(1e-5));
+        CHECK(counts_view(0) == shots);
+    }
+}
+
+TEST_CASE("Test NullQubit measurement processes with num_qubits=1", "[NullQubit]")
+{
+    std::unique_ptr<NullQubit> sim = std::make_unique<NullQubit>();
+
+    constexpr size_t num_qubits = 1;
+    constexpr size_t shots = 100;
+
+    std::vector<QubitIdType> Qs;
+    Qs.reserve(num_qubits);
+    Qs.push_back(sim->AllocateQubit());
+
+    SECTION("State")
+    {
+        std::vector<std::complex<double>> state(2);
+        DataView<std::complex<double>, 1> state_view(state);
+        sim->State(state_view);
+
+        CHECK(state_view(0).real() == Catch::Approx(1.0).margin(1e-5));
+        CHECK(state_view(0).imag() == Catch::Approx(0.0).margin(1e-5));
+        CHECK(state_view(1).real() == Catch::Approx(0.0).margin(1e-5));
+        CHECK(state_view(1).imag() == Catch::Approx(0.0).margin(1e-5));
+    }
+
+    SECTION("Probs")
+    {
+        std::vector<double> probs(2);
+        DataView<double, 1> probs_view(probs);
+        sim->Probs(probs_view);
+
+        CHECK(probs_view(0) == Catch::Approx(1.0).margin(1e-5));
+        CHECK(probs_view(1) == Catch::Approx(0.0).margin(1e-5));
+    }
+
+    SECTION("PartialProbs")
+    {
+        std::vector<double> probs(2);
+        DataView<double, 1> probs_view(probs);
+        std::vector<QubitIdType> wires;
+        sim->PartialProbs(probs_view, wires);
+
+        CHECK(probs_view(0) == Catch::Approx(1.0).margin(1e-5));
+        CHECK(probs_view(1) == Catch::Approx(0.0).margin(1e-5));
+    }
+
+    SECTION("Sample")
+    {
+        double _data_aligned[shots * num_qubits];
+        size_t _offset = 0U;
+        size_t _sizes[2] = {shots, num_qubits};
+        size_t _strides[2] = {1, 1};
+
+        DataView<double, 2> sample_view(_data_aligned, _offset, _sizes, _strides);
+        sim->Sample(sample_view, shots);
+
+        CHECK(sample_view.size() == shots * num_qubits);
+        CHECK(sample_view(0, 0) == 0.0);
+    }
+
+    SECTION("PartialSample")
+    {
+        double _data_aligned[shots * num_qubits];
+        size_t _offset = 0U;
+        size_t _sizes[2] = {shots, num_qubits};
+        size_t _strides[2] = {1, 1};
+
+        DataView<double, 2> sample_view(_data_aligned, _offset, _sizes, _strides);
+        std::vector<QubitIdType> wires;
+        sim->PartialSample(sample_view, wires, shots);
+
+        CHECK(sample_view.size() == shots * num_qubits);
+        CHECK(sample_view(0, 0) == 0.0);
+    }
+
+    SECTION("Counts")
+    {
+        std::vector<int64_t> counts(2);
+        DataView<int64_t, 1> counts_view(counts);
+        std::vector<double> eigvals(2);
+        DataView<double, 1> eigvals_view(eigvals);
+        sim->Counts(eigvals_view, counts_view, shots);
+
+        CHECK(eigvals_view(0) == Catch::Approx(0.0).margin(1e-5));
+        CHECK(eigvals_view(1) == Catch::Approx(1.0).margin(1e-5));
+        CHECK(counts_view(0) == shots);
+        CHECK(counts_view(1) == 0);
+    }
+
+    SECTION("PartialCounts")
+    {
+        std::vector<int64_t> counts(2);
+        DataView<int64_t, 1> counts_view(counts);
+        std::vector<double> eigvals(2);
+        DataView<double, 1> eigvals_view(eigvals);
+        std::vector<QubitIdType> wires;
+        sim->PartialCounts(eigvals_view, counts_view, wires, shots);
+
+        CHECK(eigvals_view(0) == Catch::Approx(0.0).margin(1e-5));
+        CHECK(eigvals_view(1) == Catch::Approx(1.0).margin(1e-5));
+        CHECK(counts_view(0) == shots);
+        CHECK(counts_view(1) == 0);
+    }
+}
+
+TEST_CASE("Test NullQubit::Zero()", "[NullQubit]")
+{
+    std::unique_ptr<NullQubit> sim = std::make_unique<NullQubit>();
+
+    CHECK(*sim->Zero() == false);
+}
+
+TEST_CASE("Test NullQubit::One()", "[NullQubit]")
+{
+    std::unique_ptr<NullQubit> sim = std::make_unique<NullQubit>();
+
+    CHECK(*sim->One() == true);
+}
+
+TEST_CASE("Test NullQubit device shots methods", "[NullQubit]")
+{
+    std::unique_ptr<NullQubit> sim = std::make_unique<NullQubit>();
+
+    for (size_t i = 0; i < 3; i++) {
+        sim->SetDeviceShots(i);
+        CHECK(sim->GetDeviceShots() == i);
+    }
+}
\ No newline at end of file


Reviews:
user: login: erick-xanadu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T20:20:58Z
user: login: dime10
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T22:43:36Z
user: login: erick-xanadu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T23:29:04Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T18:25:56Z
user: login: erick-xanadu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T18:31:56Z
user: login: dime10
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T18:53:53Z
user: login: mlxd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T15:27:43Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T15:45:50Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T20:49:59Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:08:53Z
user: login: dime10
body: Thanks Joey!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:56:49Z
user: login: dime10
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T22:57:59Z
user: login: joeycarter
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:46:16Z
user: login: dime10
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T15:03:22Z

Review comments:
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = malloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: erick-xanadu
body: ```suggestion
    void *ptr = calloc(size, sizeof(char));
```
created_at: 2025-04-01T20:20:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = calloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: dime10
body: Not sure if this is a big deal or not, but we are changing de allocation semantics globally here 🤔 
created_at: 2025-04-01T22:43:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = calloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: erick-xanadu
body: I don't think it is too big of a deal :) We can chat more about it tomorrow.
created_at: 2025-04-01T23:29:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = calloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: joeycarter
body: @dime10 & @erick-xanadu, anything more to discuss here?
created_at: 2025-04-02T18:25:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = calloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: erick-xanadu
body: I'm happy with these changes. :+1: I'll wait until David comments on this.
created_at: 2025-04-02T18:31:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = calloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: dime10
body: Just discussed this with Erick, we'd like to zero the memory buffers in the device itself, so that we can make sure there are no other repercussions once the release is over (e.g. performance).
created_at: 2025-04-02T18:53:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,5 +321,9 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
   private:
     std::size_t num_qubits_{0};
     Catalyst::Runtime::QubitManager<QubitIdType, size_t> qubit_manager{};
+
+    // static constants for RESULT values
+    static constexpr bool GLOBAL_RESULT_TRUE_CONST = true;
+    static constexpr bool GLOBAL_RESULT_FALSE_CONST = false;
path: runtime/lib/backend/null_qubit/NullQubit.hpp
user: login: mlxd
body: Just a comment, making these `constexpr` will introduce a segfault if somebody tries to modify the values via the `One/Zero` calls through a const_cast
If these values are defined as `const` instead the compiler will refuse to build, as it should. 

Example:
```c++
#include<iostream>
class ASDF{
private:
static constexpr int res = 1;

public:
ASDF() = default;

int* func() const{
    return const_cast<int*>(&res);
}

void func1(){
    auto r = func();
    (*r)++;

}

};

int main(){
    ASDF a{};
    //a.func1();
    auto res = a.func();
    (*res)++;
    std::cout << *(a.func()) << std::endl;;
}
```

Modification of the `constexpr` -> `const` will cause a compile-time failure, rather than a runtime failure, due to the `*res)++`.
created_at: 2025-04-04T15:27:42Z
author_association: MEMBER
diff_hunk: @@ -320,5 +321,9 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
   private:
     std::size_t num_qubits_{0};
     Catalyst::Runtime::QubitManager<QubitIdType, size_t> qubit_manager{};
+
+    // static constants for RESULT values
+    static constexpr bool GLOBAL_RESULT_TRUE_CONST = true;
+    static constexpr bool GLOBAL_RESULT_FALSE_CONST = false;
path: runtime/lib/backend/null_qubit/NullQubit.hpp
user: login: joeycarter
body: I meant to ask you about this. Making the member variable `static const` rather than `static constexpr` requires declaring it outside of the class (as I understand it). Also, modifying the result of a function that returns a pointer to the `static const` variable still results in a segfault:

```cpp
#include<iostream>
class ASDF{
private:
    static constexpr int val_constexpr = 1;
    static const int val_const = 1;

public:
ASDF() = default;

int* func_constexpr() const {
    return const_cast<int*>(&val_constexpr);
}

int* func_const() const {
    return const_cast<int*>(&val_const);
}

};

// declare it outside of the class definition
// without this, get linker error "undefined reference to `ASDF::val_const'"
const int ASDF::val_const;

int main(){
    ASDF a{};
    auto* res_constexpr = a.func_constexpr();

    // segfault
    // (*res_constexpr)++;
    // std::cout << *(a.func_constexpr()) << std::endl;

    auto* res_const = a.func_const();
    (*res_const)++;  // Still results in seg fault
}
```

Seems to me the only way to get a compile-time error is to change the signature to `const int* func_const() const`, although I'm not sure what the consequences of doing this are for the rest of the stack.
created_at: 2025-04-04T15:45:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,5 +321,9 @@ struct NullQubit final : public Catalyst::Runtime::QuantumDevice {
   private:
     std::size_t num_qubits_{0};
     Catalyst::Runtime::QubitManager<QubitIdType, size_t> qubit_manager{};
+
+    // static constants for RESULT values
+    static constexpr bool GLOBAL_RESULT_TRUE_CONST = true;
+    static constexpr bool GLOBAL_RESULT_FALSE_CONST = false;
path: runtime/lib/backend/null_qubit/NullQubit.hpp
user: login: joeycarter
body: Discussed offline, we'll stick with the `constexpr` version for now. We may reevaluate the use of returning a raw pointer `Result` value in the future.
created_at: 2025-04-07T20:49:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,7 +152,7 @@ void __catalyst__host__rt__unrecoverable_error()
 
 void *_mlir_memref_to_llvm_alloc(size_t size)
 {
-    void *ptr = malloc(size);
+    void *ptr = calloc(size, sizeof(char));
path: runtime/lib/capi/RuntimeCAPI.cpp
user: login: joeycarter
body: I've reverted this change back to using `malloc`.
created_at: 2025-04-07T22:08:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,6 +59,7 @@ template <typename T, size_t R> class DataView {
             int64_t idx;
             for (int64_t i = R; i > 0; --i) {
                 idx = i - 1;
+                RT_ASSERT(view.sizes[idx] > 0);
path: runtime/include/DataView.hpp
user: login: dime10
body: Is this safe when one of the dims is 0, or will it trigger the assert? 
created_at: 2025-04-07T22:51:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,6 +4,15 @@
 
 <h3>Improvements 🛠</h3>
 
+* The behaviour of measurement processes executed on `null.qubit` with QJIT is now more in line with
+  their behaviour on `null.qubit` *without* QJIT.
+  [(#1598)](https://github.com/PennyLaneAI/catalyst/pull/1598)
+
+  Previously, measurement processes like `qml.sample()`, `qml.counts()`, `qml.probs()`, etc.
+  returned values from uninitialized memory when executed on `null.qubit` with QJIT. This change
+  ensures that measurement processes on `null.qubit` always return the result corresponding to the
+  '0' state.
path: doc/releases/changelog-dev.md
user: login: dime10
body: I think this statement is almost correct, except for the expval measurement. The "0" state typically doesn't produce and expval of 0 (although this might depend on the observable, but PauliZ would yield +1 in the 0 state).
created_at: 2025-04-07T22:56:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,6 +59,7 @@ template <typename T, size_t R> class DataView {
             int64_t idx;
             for (int64_t i = R; i > 0; --i) {
                 idx = i - 1;
+                RT_ASSERT(view.sizes[idx] > 0);
path: runtime/include/DataView.hpp
user: login: dime10
body: Ah just read your message about it, sounds good 👍 
created_at: 2025-04-07T22:57:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,6 +4,15 @@
 
 <h3>Improvements 🛠</h3>
 
+* The behaviour of measurement processes executed on `null.qubit` with QJIT is now more in line with
+  their behaviour on `null.qubit` *without* QJIT.
+  [(#1598)](https://github.com/PennyLaneAI/catalyst/pull/1598)
+
+  Previously, measurement processes like `qml.sample()`, `qml.counts()`, `qml.probs()`, etc.
+  returned values from uninitialized memory when executed on `null.qubit` with QJIT. This change
+  ensures that measurement processes on `null.qubit` always return the result corresponding to the
+  '0' state.
path: doc/releases/changelog-dev.md
user: login: joeycarter
body: Good point! Updated in b6afbcbb.
created_at: 2025-04-08T14:46:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-01T20:35:26Z
body: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/catalyst/pull/1598?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.87%. Comparing base [(`bcf6c6d`)](https://app.codecov.io/gh/PennyLaneAI/catalyst/commit/bcf6c6d8d7d1ed83382d4e887861a9123afae271?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`d4c38b5`)](https://app.codecov.io/gh/PennyLaneAI/catalyst/commit/d4c38b51c9518ba80b1c44f1cd7f146f2153d6cd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 185 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1598      +/-   ##
==========================================
+ Coverage   96.60%   96.87%   +0.26%     
==========================================
  Files          80       80              
  Lines        8606     8822     +216     
  Branches      832      832              
==========================================
+ Hits         8314     8546     +232     
+ Misses        238      222      -16     
  Partials       54       54              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/catalyst/pull/1598?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: paul0403
created_at: 2025-04-04T13:56:12Z
body: > Instead of returning a `malloc(bool)`, return a pointer to a class-member static bool.

Hey, a bit late to this, but essentially are you proposing that the null qubit measurements always return a singleton zero to reduce memory usage? (and deterministically produce zeros)
author_association: MEMBER
user: login: joeycarter
created_at: 2025-04-04T15:47:01Z
body: > > Instead of returning a `malloc(bool)`, return a pointer to a class-member static bool.
> 
> Hey, a bit late to this, but essentially are you proposing that the null qubit measurements always return a singleton zero to reduce memory usage? (and deterministically produce zeros)

That's the idea, yes :slightly_smiling_face: 
author_association: CONTRIBUTOR
user: login: joeycarter
created_at: 2025-04-04T16:01:13Z
body: I've uncovered a bug that may block this PR. Suppose we change the implementation of `NullQubit::Sample()` from

https://github.com/PennyLaneAI/catalyst/blob/46d839e57b09b125a7baf25a02b19d7268901426/runtime/lib/backend/null_qubit/NullQubit.hpp#L268

to a version that fills the `samples` array with zeros:

```cpp
void Sample(DataView<double, 2> &samples, size_t)
{
    std::fill(samples.begin(), samples.end(), 0.0);
}
```

There appears to be a bug in the `DataView` class's iterator that results in a segfault when the number of wires is 0:

https://github.com/PennyLaneAI/catalyst/blob/46d839e57b09b125a7baf25a02b19d7268901426/runtime/include/DataView.hpp#L56-L72

Here's a minimal example to reproduce the issue (with the modified implementation of `NullQubit::Sample()`):

```python
import pennylane as qml

@qml.qjit
@qml.qnode(qml.device("null.qubit", wires=0, shots=10))
def circuit():
    return qml.sample()
```

In short, the issue is that `view.sizes[idx] == 0` with `idx == 1` on the first iteration, resulting in an underflow error when subtracting 1 from it (since `view.sizes` is of type `size_t`). The end result is that `next_axis` keeps incrementing by one indefinitely until it segfaults, when in this case it should have stayed at `-1` from the beginning.

I don't think this is a user-facing bug, just a bug that is preventing us to use `std::fill` internally. I'm looking into a fix.
author_association: CONTRIBUTOR
user: login: joeycarter
created_at: 2025-04-07T22:53:00Z
body: > I've uncovered a bug that may block this PR. Suppose we change the implementation of `NullQubit::Sample()` from ...

I was able to circumvent this issue by only calling `std::fill` on the `sample` DataView if `num_qubits > 0`. If `num_qubits == 0`, the `sample` array is unallocated so there are no elements to set to zero. However, the issue in the `DataView::iterator` increment operator is still present when the size along at least one axis is 0. There shouldn't currently be any code pathway (user-facing or internal) that triggers the issue. I've added an assert to hopefully make it easier to track down should it come up in the future.

I've created a separate issue for this here: #1621.
author_association: CONTRIBUTOR