Guide: Using the Observable Primitive
While the Circuit class provides a convenient high-level interface, you can also work directly with the Observable primitive for maximum control. This is useful for algorithm development, debugging, or when integrating ProPauli into other simulation frameworks.
The Heisenberg Picture
ProPauli works in the Heisenberg picture, where the quantum state is considered fixed (as the \(|0\rangle^{\otimes n}\) state) and the operators (observables) evolve. When we apply a gate \(U\) to a circuit, the corresponding transformation on an observable \(O\) is \(O \rightarrow U^\dagger O U\).
Because we build the circuit forward but evolve the observable backward, the operations are applied in reverse order. For a circuit \(U = U_k \dots U_2 U_1\), the final observable \(O'\) is calculated as:
The apply_* methods on the Observable class each compute one of these \(U_i^\dagger O U_i\) steps.
Manual Evolution Example
Let’s manually evolve an observable through a simple two-qubit circuit that prepares a Bell state. The forward circuit is H on qubit 0, followed by CX with control 1 and target 0.
// Start with a simple Z observable on the first of two qubits
Observable obs{ "ZI" };
std::cout << "Initial observable: " << obs << std::endl;
// Evolve the observable backward through a CNOT gate
// Circuit: --[CX]--
// |
// *
obs.apply_cx(1, 0); // Note: control and target are for the forward circuit
std::cout << "After CX(1,0): " << obs << std::endl;
// Evolve backward through an H gate on qubit 0
// Circuit: --[H]--[CX]--
// |
// ------*----
obs.apply_clifford(Clifford_Gates_1Q::H, 0);
std::cout << "After H(0): " << obs << std::endl;
The final observable is XX. Its expectation value on the initial \(|00\rangle\) state is 0.
Splitting, Merging, and Truncating
When working directly with an observable, you are responsible for managing its complexity.
Splitting occurs when applying non-Clifford gates like apply_rz. An observable that was a single Pauli term can become a sum of two terms.
Observable split_obs{ "X" };
std::cout << "\nInitial observable: " << split_obs << std::endl;
std::cout << "Size: " << split_obs.size() << std::endl;
// Applying Rz splits the observable
split_obs.apply_rz(0, 1.57f);
std::cout << "After Rz(0): " << split_obs << std::endl;
std::cout << "Size: " << split_obs.size() << std::endl;
After several splitting operations, you will likely have many identical Pauli strings with different coefficients. You are responsible for calling merge() to combine them and truncate() to remove negligible terms.
Observable complex_obs({ PauliTerm("IXYZ", 0.5f), PauliTerm("IXYZ", 0.2f), PauliTerm("IIII", 0.01f) });
std::cout << "\nInitial complex observable: " << complex_obs << std::endl;
// Merge identical terms
complex_obs.merge();
std::cout << "After merge: " << complex_obs << std::endl;
// Truncate terms with small coefficients
complex_obs.truncate(CoefficientTruncator<>(0.1f));
std::cout << "After truncate: " << complex_obs << std::endl;