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;