Steven GellerQuantum Computing, End to End

Book contents

Current section

Part V. Practical Software

  1. Python and Quantum Programming Workflow
  2. Building a Small Simulator From Scratch
  3. Qiskit, Primitives, and Circuit Execution
  4. Cirq and Alternative Toolchains
  5. Transpilation and Hardware-Aware Compilation
  6. Noise Models and Noisy Simulation
  7. Benchmarking Quantum Programs
  8. Resource Estimation for Fault-Tolerant Algorithms
  9. Reproducible Quantum Labs

Part V. Practical Software · Chapter 37

Building a Small Simulator From Scratch

The fastest way to find out whether you actually understand quantum circuits is to implement one. A vector, a handful of matrices, and a sampler — a few dozen lines of Python — will expose every gap that reading hides.

Lab
In this chapter 12 sections

Represent an n-qubit pure state as a normalized complex vector in declared big-endian basis order, apply gates as unitary linear maps to selected axes, compute Born probabilities exactly, and place seeded sampling behind a separate interface with invariant tests.

The implementation is intentionally small enough to distrust line by line. That is its value: an ordering convention, an in-place update, or an invalid normalization can no longer disappear behind a framework call.

Paired indices for a one-qubit gatetarget q1 in a three-qubit, q0-most-significant vector000 ↔ 010001 ↔ 011100 ↔ 110101 ↔ 111Each pair is copied before the 2×2 update; no 8×8 matrix is built.
Figure 37.1. The target bit selects pairs separated by one stride. Every other bit is held fixed, making the index convention executable rather than implicit.

A state vector with a declared index map

For n qubits, allocate N=2nN=2^n complex amplitudes. This reference uses big-endian display: q0 is the most-significant bit, so basis string 101 is integer index five and q0 contributes weight four. The zero constructor sets amplitude zero to one and every other amplitude to zero. Pure states inhabit this exponentially growing complex vector space [Nielsen and Chuang].

The constructor should reject a vector unless

i=0N1ψi2=1\sum_{i=0}^{N-1}|\psi_i|^2=1(37.1)

within absolute tolerance 10−12. Silent normalization is tempting and wrong: it can turn a caller’s bug into a plausible state. Validate dimensions, finite values, and norm; report the faulty quantity.

Applying a one-qubit matrix without building 2^n by 2^n

A gate on target qubit q mixes amplitude pairs whose indices differ only at bit q. With big-endian display, the target mask is 1 << (n - 1 - q). Iterate indices whose target bit is zero, copy the pair (a0,a1), and write (u00*a0+u01*a1, u10*a0+u11*a1). Copying both old values before either write prevents an in-place corruption that often passes basis-state tests and fails superpositions.

If UU=IU^\dagger U=I, then the pair norm is preserved; summing over disjoint pairs preserves the whole norm. Test the implementation anyway. A transposed gate, wrong mask, or aliased write can be algebraically unitary and operationally wrong.

Why the pair update is the tensor product

Fix every bit except the target. The two corresponding basis states span a two-dimensional slice of the full vector, with amplitudes a0a_0 and a1a_1. Applying UU to that qubit applies the same 2×22\times2 map to every such slice. This is exactly IUII\otimes\cdots\otimes U\otimes\cdots\otimes I, evaluated without materializing its mostly zero 2n×2n2^n\times2^n matrix. There are 2n12^{n-1} disjoint pairs, so a one-qubit gate costs O(2n)O(2^n) arithmetic and a copied output vector costs O(2n)O(2^n) memory.

A diagnostic trace should print the target mask and the first few index pairs. For three qubits, target q1 must produce (0,2), (1,3), (4,6), and (5,7). If the trace instead begins (0,4), the implementation is applying the gate to q0. That one line often localizes an ordering defect faster than comparing eight complex amplitudes after a long circuit.

CNOT exposes every ordering decision

CNOT flips the target bit exactly when the control bit is one. Do not start with Bell states. First assert the complete two-qubit truth table: 000000\mapsto00, 010101\mapsto01, 101110\mapsto11, 111011\mapsto10 for control q0q_0 and target q1q_1 under this book’s display. Then repeat with control and target exchanged. Those eight cases identify which integer mask belongs to which logical label.

Qiskit and Cirq expose their own ordering and output conventions [Qiskit documentation] [Cirq documentation]. An adapter may reverse a displayed string without changing the state. That is a translation, not a reason to make the core simulator ambiguous. OpenQASM defines serialized circuit semantics and QIR defines an intermediate representation, but neither settles a Python library’s in-memory endianness [OpenQASM 3 specification] [QIR Alliance specification].

The implementation can be read as a permutation. For each source index ii, compute the control mask; if it is set, move the amplitude to imti\mathbin{\oplus}m_t, otherwise leave it at ii. Every destination must be written exactly once. Summing into a zeroed vector makes that bijection visible and avoids a second in-place hazard: swapping an amplitude twice while iterating both members of a pair.

Control equal to target is not a degenerate CNOT; it is an invalid request and should raise before mutation. Negative or out-of-range labels fail for the same reason. Public methods need these rejection paths because Python’s shifts and negative indices can otherwise turn a bad circuit into a different, executable circuit.

The Bell fixture has exact and stochastic halves

Apply H to q0 and then CNOT(q0,q1). The exact vector is (1/√2, 0, 0, 1/√2). The Born rule maps an amplitude to its squared magnitude [Nielsen and Chuang], producing probabilities 0.5 on 00 and 11. These are deterministic numerical assertions at tolerance 10−12.

Sampling is a different API. It consumes probabilities, a shot count, and an optional seed; it returns integer counts. Assert that counts total the shots and impossible outcomes remain absent for the ideal Bell state. Use the seed only to stabilize a regression transcript. An unseeded scientific check needs a declared binomial interval, not an exact count.

Construct the cumulative distribution in basis-index order and force its final boundary to one after checking the probability sum within tolerance. The correction is only for floating-point roundoff; it must not rescue a vector whose norm is materially wrong. Given a uniform variate r[0,1)r\in[0,1), return the first index whose cumulative mass exceeds rr. Tests should cover zero shots, a negative shot request, a basis state with probability one, and a distribution whose tiny tail would expose an off-by-one comparison.

Inspectable state-vector simulator with Bell CLI

Bell trace in q0-most-significant order
stepstate vectorinvariant
00\lvert 00 \rangle(1,0,0,0)norm 1
H(q0)(1/2,0,1/2,0)(1/\sqrt{2},0,1/\sqrt{2},0)indices 0,2
CNOT(q0,q1)(1/2,0,0,1/2)(1/\sqrt{2},0,0,1/\sqrt{2})indices 0,3
measure{00:.5,11:.5}Born probabilities sum 1
from math import sqrt
H = ((1/sqrt(2), 1/sqrt(2)), (1/sqrt(2), -1/sqrt(2)))
state = [1+0j, 0j, 0j, 0j]

def one_qubit(vector, gate, target, n=2):
    out = vector[:]
    mask = 1 << (n - 1 - target)
    for i in range(len(vector)):
        if i & mask: continue
        j = i | mask; a, b = vector[i], vector[j]
        out[i] = gate[0][0]*a + gate[0][1]*b
        out[j] = gate[1][0]*a + gate[1][1]*b
    return out

def cnot(vector, control, target, n=2):
    out = [0j] * len(vector)
    cm = 1 << (n - 1 - control); tm = 1 << (n - 1 - target)
    for i, amplitude in enumerate(vector): out[i ^ tm if i & cm else i] += amplitude
    return out

product_state = one_qubit(state, H, 0)
state = cnot(product_state, 0, 1)
missing_entangler = cnot(product_state, 1, 0)
expected = [1/sqrt(2), 0, 0, 1/sqrt(2)]
assert all(abs(a-b) <= 1e-12 for a,b in zip(state, expected))
assert abs(sum(abs(a)**2 for a in state)-1) <= 1e-12
assert missing_entangler == product_state and missing_entangler != state
print(f"PASS: 37 simulator Bell_support={[i for i,a in enumerate(state) if abs(a) > 1e-12]} missing_entangler_support={[i for i,a in enumerate(missing_entangler) if abs(a) > 1e-12]}")

Repository check: cd labs && python -m unittest tests.test_simulator -v. The suite and the documented seed-7 CLI output must agree at complex tolerance 10−12.

Tests that catch plausible-looking wrong simulators

Reject non-power-of-two dimensions and non-normalized input. Apply H twice to several states, not only 0\lvert 0 \rangle. Exercise CNOT on every basis state in both directions. Check a phase-sensitive superposition so a sign bug cannot hide behind computational-basis probabilities. Assert impossible Bell outcomes, exact probability sums, sample totals, and error messages for invalid qubit indices. Include a randomized unitary test with a fixed generator seed, but keep small analytic fixtures as the diagnostic core.

A controlled-Z extension is especially useful: its computational-basis probabilities do not change, yet the relative phase of 11\lvert 11 \rangle does. The Bell state used above happens to acquire a relative minus sign, not a global phase; an X-basis measurement or later interference reveals it.

Test relative phase by composing operations, not by inspecting a comment. Prepare (00+11)/2(|00\rangle+|11\rangle)/\sqrt2, apply CZ, then apply H to both qubits. The resulting probability distribution differs from the unmodified Bell path even though the distribution immediately after CZ does not. This fixture catches a simulator that stores only magnitudes, conjugates at the wrong point, or quietly rounds complex amplitudes to real values.

Property tests complement, rather than replace, exact cases. Generate a small normalized vector and a unitary matrix from a fixed seed; assert norm preservation and agreement with a slow reference matrix product. Then shrink a failure to the explicit vector, matrix, target, and mask. A seed without the generated inputs is not a reproducible counterexample.

Where the model ends

Memory and gate work grow exponentially with qubit count. This implementation has no mid-circuit measurement, reset, classical control, density matrices, leakage, or device noise. It is an oracle for small pure-state circuits, not a performance competitor to optimized simulators and not a proxy for hardware. Its public API earns trust only through its declared domain and tests.

Those omissions are architectural, not missing convenience methods. Mid-circuit measurement changes a pure state conditionally and requires a classical outcome record; a density-matrix backend changes storage from 2n2^n amplitudes to 4n4^n complex entries; leakage requires states outside the qubit basis. Adding any one of them changes invariants and tests. A small simulator stays trustworthy by refusing unsupported operations rather than attaching device-sounding names to an ideal state vector.

Numerical comparison needs a policy too. Compare complex amplitudes with an absolute tolerance on small analytic fixtures and compare state vectors up to global phase only when the contract permits it. Probability agreement is weaker: states with different relative phase can share every computational-basis probability. The API should expose amplitudes for exact tests, probabilities for measurement oracles, and counts for sampling; converting all three into one dictionary would erase the evidence that locates a failure.

Every public method named here now has a corresponding rejection or invariant test: constructor dimension and norm, one-qubit target range and pair update, CNOT control/target validity and truth table, Born probability normalization, and sampler shot totals. That coverage is more meaningful than a line percentage because it follows the semantic contracts a caller can violate.

Controlled-Z extension

Prompt: Implement a controlled-Z operation using the simulator's indexing convention, add exact basis-state tests, and show its action on the Bell state.

Deliverable: A function, four basis tests, one Bell-state invariant test, and a 150-word explanation of global versus relative phase.

Pass condition: All tests pass at tolerance 1e-12, normalization remains one, and the explanation correctly identifies the changed phase without claiming a computational-basis probability change.

Reference result

Format: Reference patch and expected vector table for |00>, |01>, |10>, |11>, and the Bell input.

Verification: Run the added unit tests and independently compare each output against diagonal CZ = diag(1,1,1,-1).

The basis vectors remain unchanged except |11>, whose amplitude gains a minus sign. On the Bell input the result is (0011)/2(\lvert00\rangle-\lvert11\rangle)/\sqrt{2}: normalization and computational-basis probabilities stay fixed, but the relative phase changes and can be exposed by a later interference measurement.

Companion work

Artifacts for this chapter

These entries resolve to checked-in local source. Commands are reproduced exactly from the chapter manifest, and source-embedded fixtures are exported as direct downloads.

  1. Reproduce or test

    python3 tools/validate_briefs.py --briefs data/editorial_briefs_36_63.json --from 36 --through 63 --check-rewritten-sources --execute-artifacts

Provenance

Sources and review

  1. Michael A. Nielsen and Isaac L. Chuang. Quantum Computation and Quantum Information. Cambridge University Press. 2010textbook
  2. IBM Quantum. Qiskit documentation. IBM. 2026official documentation
  3. Google Quantum AI. Cirq documentation. Google. 2026official documentation
  4. OpenQASM Technical Steering Committee. OpenQASM 3 specification. Linux Foundation Joint Development Foundation. 2026official technical specification
  5. QIR Alliance. Quantum Intermediate Representation specification and projects. Linux Foundation Joint Development Foundation. 2026official technical specification

The load-bearing claims in the chapter are mapped inline to this registered source set. A citation supports only the bounded claim beside it.

Cite this chapter