Guide: Building Quantum Circuits
The Circuit class is the primary high-level interface for constructing and simulating quantum circuits. This guide explores its features in detail, from basic gate application to advanced optimization with truncators and custom scheduling policies.
Basic Circuit Construction
A circuit is initialized with the number of qubits it will operate on. Gates are then added sequentially using the add_operation method.
// 1. Initialize an 8-qubit circuit
Circuit qc{ 8 };
// 2. Build the quantum circuit
// Apply a Hadamard gate to every qubit
for (unsigned i = 0; i < 8; ++i) {
qc.add_operation("H", i);
}
// Create a chain of CNOT gates
for (unsigned i = 0; i < 7; ++i) {
qc.add_operation("CX", i, i + 1);
}
// 3. Define the observable to measure
// We'll measure the observable ZZZZZZZZ
std::string z_obs_str(8, 'Z');
Observable obs{ z_obs_str };
// 4. Run the simulation
auto final_obs = qc.run(obs);
// 5. Get the expectation value
auto exp_val = final_obs.expectation_value();
std::cout << "Expectation value of " << z_obs_str << ": " << exp_val << std::endl;
Managing Complexity with Truncators
As a simulation runs, especially with non-Clifford gates or noise, the number of terms in the Observable can grow exponentially. Truncators are essential tools for keeping the simulation tractable by removing terms that are unlikely to contribute significantly to the final expectation value.
ProPauli provides two built-in truncators:
CoefficientTruncator: Removes terms whose coefficient has a magnitude below a specified threshold.
WeightTruncator: Removes terms whose Pauli weight (the number of non-identity operators) is above a specified threshold.
They are passed to the Circuit constructor.
Circuit qc{ 64,
combine_truncators(CoefficientTruncator<>{ 0.001f }, // remove terms with coefficient below 0.001
WeightTruncator<>{ 6 } // remove terms with pauli weight > 6
) };
// Apply a layer of Hadamard gates
for (unsigned i = 0; i < 64; ++i)
qc.add_operation("H", i);
// Entangling layer
for (unsigned i = 0; i < 63; ++i) {
qc.add_operation("CX", i, i + 1);
}
auto result = qc.run(Observable{ std::string(64, 'Z') });
std::cout << "Expectation value: " << result.expectation_value() << std::endl;
Creating a Custom Truncator
You can define custom truncation logic by creating a class that inherits from Truncator and implements the truncate method.
However, a more straightforward approach for simple predicates is to use the PredicateTruncator, which takes any callable (like a lambda) that returns true for terms that should be removed.
// A custom truncator that removes Pauli terms with a specific weight
auto predicate = [](const auto& pt) { return pt.pauli_weight() == 2; };
Circuit qc{ 4, std::make_shared<PredicateTruncator<decltype(predicate)>>(predicate) };
qc.add_operation("H", 0);
qc.add_operation("H", 1);
qc.add_operation("Rz", 0, 1.57f);
qc.add_operation("CX", 0u, 1u);
auto result = qc.run(Observable{ "XXXX" });
std::cout << "Expectation value: " << result.expectation_value() << std::endl;
Controlling Optimization with Scheduling Policies
Merging and truncating are powerful but can be computationally expensive. Scheduling Policies give you fine-grained control over when these optimizations are applied during the simulation.
The two main built-in policies are:
AlwaysAfterSplittingPolicy (Default): Applies the optimization (merge or truncate) immediately after a “splitting” gate (like Rz or Amplitude Damping) is applied. This is often a good default strategy.
NeverPolicy: Disables the optimization entirely.
Policies are passed to the Circuit constructor.
// Policies are passed to the Circuit constructor to control optimization.
// This circuit will merge identical Pauli terms only after a splitting gate is applied.
Circuit qc(4, std::make_shared<NeverTruncator<>>(), {},
std::make_shared<AlwaysAfterSplittingPolicy>(), // Merge policy
std::make_shared<NeverPolicy>() // Truncate policy
);
Custom Scheduling Policies
For advanced use cases, you can create your own policy by inheriting from SchedulingPolicy. This allows you to implement complex logic based on the full simulation state.
For example, here is a policy that only merges Pauli terms after every second splitting gate:
// A custom policy that merges after every 2 splitting gates have been applied.
class MergeEveryTwoSplitsPolicy : public SchedulingPolicy {
public:
MergeEveryTwoSplitsPolicy() = default;
bool should_apply(SimulationState const& state, OperationType op_type, Timing timing) override {
// We only want to merge after a splitting gate is applied
if (op_type != OperationType::SplittingGate || timing != Timing::After) {
return false;
}
// Check if the number of applied splitting gates is a multiple of 2
return state.get_nb_splitting_gates_applied() % 2 == 0;
}
};
You would then use it in a circuit like this:
Circuit qc(4, std::make_shared<NeverTruncator<>>(), {},
std::make_shared<MergeEveryTwoSplitsPolicy>(), // Use our custom merge policy
std::make_shared<NeverPolicy>());
// Add 4 splitting gates
qc.add_operation("Rz", 0, 0.5f); // No merge
qc.add_operation("Rz", 1, 0.5f); // Merge will happen here
qc.add_operation("Rz", 2, 0.5f); // No merge
qc.add_operation("Rz", 3, 0.5f); // Merge will happen here
auto result = qc.run(Observable("XXXX"));
std::cout << "Simulation with custom scheduler finished." << std::endl;