Manage Simulation Complexity

As a simulation progresses, the number of terms in an Observable can grow exponentially, making the simulation slow or memory-intensive. pyrauli provides powerful tools (Truncator and SchedulingPolicy objects) to manage this complexity automatically during a run() call.

Remove small coefficients

Use a CoefficientTruncator.

This truncator removes any Pauli terms whose coefficient magnitude is below a given threshold.

obs = Observable([PauliTerm("I", 0.99), PauliTerm("Y", 0.01)])
truncator = CoefficientTruncator(0.1)

removed_count = obs.truncate(truncator) # 1

Available truncators

  • NeverTruncator: never truncate observable.

  • CoefficientTruncator: truncate terms with absolute coefficient value below set threshold.

  • WeightTruncator: truncate terms with Pauli weight above set threshold.

  • LambdaTruncator: truncate terms if they match a predicate (Python function or lambda).

  • KeepNTruncator: truncate least significant terms if their number exceed a threshold. (Keep at most N different terms)

  • MultiTruncator: Combine a list of truncators into one.

Using a custom Truncator with your own logic

Use a LambdaTruncator.

This allows you to provide a custom Python function that filters the terms.

obs = Observable([PauliTerm("IXI", 0.5), PauliTerm("IZY", 0.5)])
truncator = LambdaTruncator(lambda pt: 'Y' in repr(pt))

removed_count = obs.truncate(truncator) # 1 (removed IZY)

Note

This truncator removes PauliTerm containing \(Y\). However, this is not very efficient.

Quantifying Truncation Error

Using a truncator can significantly speed up simulations, but it introduces a numerical error by discarding parts of the observable. pyrauli tracks an estimate of this error, allowing you to balance performance and accuracy.

The expectation_value() method returns a tuple containing both the expectation value and the accumulated truncation error.

The following example uses a CoefficientTruncator and inspects the resulting error.

# Use a truncator that will remove terms with small coefficients
truncator = CoefficientTruncator(0.1)

circuit = Circuit(2, truncator=truncator)
circuit.h(0)
circuit.h(1)
circuit.cx(0, 1)
circuit.rz(1, 0.01) # This gate splits the observable
circuit.h(0)
circuit.h(1)

observable = Observable("ZZ")

# The second value returned is the estimated error
ev, err = circuit.expectation_value(observable)
print(circuit.run(observable))

print(f"Expectation value: {ev:.4f}")
print(f"Estimated truncation error: {err:.4f}")

# For comparison, run without truncation 
circuit.set_truncator(NeverTruncator())

exact_ev, _ = circuit.expectation_value(observable)
print(f"Exact expectation value: {exact_ev:.4f}")
print(f"Actual error: {abs(exact_ev - ev):.4f}")

Controlling when truncation is applied

Use a SchedulingPolicy.

Scheduling Policies give you fine-grained control over when a Truncator is applied during the simulation. It can query a SimulationState object and other information to make a decision.

The following example uses a LambdaPolicy to apply a truncator only at specific depths.

# 1. Define the custom policy as a Python function
def merge_after_second_split(state: SimulationState, op_type: OperationType, timing: Timing) -> bool:
    """This policy returns True only for a Merge event after 2 splitting gates have been applied."""
    
    # We only care about the 'After' timing for merges
    if timing != Timing.After:
        return False
        
    # We only care about SplittingGate operations
    if op_type != OperationType.SplittingGate:
        return False
        
    # The core logic: check the number of splitting gates already applied.
    if state.nb_splitting_gates_applied == 2:
        return True
        
    return False

# 2. Create the LambdaPolicy from the Python function
custom_merge_policy = LambdaPolicy(merge_after_second_split)

# 3. Create a circuit that uses this policy
qc = Circuit(
    nb_qubits=1,
    truncator=NeverTruncator(),
    merge_policy=custom_merge_policy
)

# 4. Build a circuit with two splitting gates (Rz)
# Each Rz gate will double the number of terms in the observable.
qc.add_operation("Rz", qubit=0, param=0.5)  # 1st splitting gate
qc.add_operation("Rz", qubit=0, param=0.5)  # 2nd splitting gate

# 5. Run the simulation
# The 'X' observable will split into 2 terms at the first Rz, and then 4 terms at the second.
# The merge policy should trigger *only* after the second Rz, merging the 4 terms back down.
obs_in = Observable("X")
obs_out = qc.run(obs_in)

Note

See SimulationState, OperationType, Timing and LambdaPolicy for more information.