lccfq_natsim is a wire-compatible, developer-local quantum simulator for the LCCFQ project. It
implements the same gRPC contract as lccfq-backend, the production HPC backend, so that
lccfq-lang can target either endpoint without code changes. The two backends differ in where they
run (a developer's laptop vs. HPC) and in one debug extension that natsim adds for local
development, but on the canonical wire they are indistinguishable to any conformant client.
The simulation engine is a dense numpy statevector over np.complex128 with little-endian qubit
indexing and a PCG64 RNG. The native gate set is rx, ry, and sqiswap — the XYiSW-family
gates targeted by lccfq-lang's pfaff_v1 transpiler. The marker symbols measure, reset,
and nop are recognized and accepted at dispatch but treated as no-ops, consistent with a
hardware target that does not support mid-circuit measurement.
The canonical gRPC wire is owned by lccfq-backend. It exposes two services: QPUExecutor, with
RPCs Ping, SubmitCircuitTask, and SubmitTestTask; and QPUExecutorExtensions, with
GetResult. natsim implements both services wire-identically, so any client speaking to the real
backend can be redirected to natsim by changing its [network] configuration block.
natsim additionally exposes a QPUExecutorDebug service with a single GetStatevector RPC. This
service is explicitly absent from the production backend and is not part of the canonical wire; its
sole purpose is to let developers inspect the simulator's internal amplitude array during
development. It is gated behind the [engine].debug configuration flag: when debug=false (the
default), GetStatevector returns PERMISSION_DENIED.
lccfq-lang imports its Client from lccfq-backend and connects to whichever host:port its
[network] block specifies. Swapping between natsim and the real backend is a configuration
change, not a code change.
Inside natsim, circuit execution follows a straightforward path. The QPUExecutorServicer receives
a SubmitCircuitTask RPC, decodes the gate sequence from the protobuf, drives the QPUAbstraction
FSM from IDLE to BUSY, runs the circuit through the Engine, stores the result in a ResultStore,
and drives the FSM back to IDLE. The GetResult RPC on QPUExecutorExtensions retrieves the
stored result by task ID.
Both lccfq_backend.api.protobufs_compiled.qpu_service_pb2 and
lccfq_natsim.api.generated.qpu_service_pb2 declare messages (ExecutorRequest, Gate,
GetResultResponse, and others) in the anonymous proto package. Importing both _pb2 modules
into the same Python process raises:
TypeError: Couldn't build proto file into descriptor pool: duplicate symbol 'ExecutorRequest'
This is by design. natsim is a server process, not an importable library. Any test or script that
drives natsim from lccfq-lang — which transitively imports lccfq-backend — must spawn natsim
as a subprocess. The canonical pattern for doing this, including mTLS certificate provisioning and
full round-trip assertions, is in scripts/smoke_lang_roundtrip.py.
Python 3.12 is required. Development uses uv:
git clone <repo-url> lccfq_natsim
cd lccfq_natsim
uv syncThis installs runtime and dev dependencies into .venv/. The lccfq-natsim console script lands
at .venv/bin/lccfq-natsim.
If you need to consume natsim as an editable dependency from another project's environment:
uv pip install -e /path/to/lccfq_natsimuv run lccfq-natsim --config config/example.tomlconfig/example.toml ships a 4-qubit insecure configuration: mtls=false, gRPC on
127.0.0.1:50053, Prometheus on 127.0.0.1:9090. It is suitable for local smoke testing where
TLS is not required.
To see human-readable log output, add --verbose:
uv run lccfq-natsim --config config/example.toml --verboseWithout --verbose, structlog emits newline-delimited JSON. With --verbose, it switches to
ConsoleRenderer — coloured, human-readable output. In both modes the servicer emits a structured
log line on each submitted circuit that includes task_id, n_gates, shots,
n_qubits_touched, and composition (a gate-symbol-to-count map ordered by frequency). A
companion line on completion carries task_id, n_outcomes, and shots.
Configuration is a TOML file with five sections. All sections must be present; [noise] may be
empty.
Controls the gRPC server binding and optional mTLS.
| Key | Type | Default | Description |
|---|---|---|---|
host |
string | "127.0.0.1" |
Bind address |
port |
int | 50053 |
Bind port; 0 requests an ephemeral port |
mtls |
bool | true |
Enable mutual TLS |
cert_path |
path | — | Server certificate (required when mtls=true) |
key_path |
path | — | Server private key (required when mtls=true) |
ca_path |
path | — | CA certificate used to verify client certificates (required when mtls=true) |
Controls the Prometheus HTTP exposition endpoint.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Start the metrics HTTP server |
host |
string | "127.0.0.1" |
Bind address |
port |
int | 9090 |
Bind port; 0 requests an ephemeral port |
Four counters are exposed: tasks_submitted_total, tasks_completed_total,
task_duration_seconds, and queue_depth.
| Key | Type | Default | Description |
|---|---|---|---|
backend |
string | "statevector" |
Simulator backend; currently only "statevector" is implemented |
max_qubits |
int | 24 |
Hard upper bound on circuit width |
default_seed |
int | — | RNG seed used when a circuit request supplies no seed |
debug |
bool | false |
Gates the QPUExecutorDebug.GetStatevector RPC; false denies all calls with PERMISSION_DENIED |
| Key | Type | Description |
|---|---|---|
name |
string | Logical device identifier reported in responses |
qubit_count |
int | Number of qubits the engine exposes (must be ≥ 1) |
Reserved. The [noise] section may be present and either empty or contain arbitrary keys; the
build_noise factory yields IdealNoise regardless of content. Typed channel fields land in a
future release (see Noise scaffolding under Development).
Two configs ship in config/.
config/example.toml is a 10-qubit, insecure (mtls=false) configuration intended for quick
local demos that do not involve lccfq-lang. Point a gRPC client directly at
127.0.0.1:50053.
config/natsim_mtls.toml enables mTLS (mtls=true) with certificate paths under
/tmp/smoke-certs/, 10 qubits, and metrics disabled. It pairs with lccfq-lang's
config/natsim.toml for full lang-to-natsim round-trip demonstrations. Before using it, the cert
files must exist at the configured paths.
Certificates are managed by lccfq-backend's CertificateManager. The smoke script provisions a
temporary CA, server certificate, and client certificate in one call sequence:
from lccfq_backend.api.certificates.certificate_manager import CertificateManager
from pathlib import Path
cm = CertificateManager(Path("/tmp/smoke-certs"))
cm.setup_ca_and_server(hostname="localhost")
cm.create_client_certificate(user_id="dev")After this, ca.crt, server.crt, and server.key are under /tmp/smoke-certs/, and the client
certificate is under /tmp/smoke-certs/clients/. The paths in config/natsim_mtls.toml reflect
this layout.
scripts/smoke_lang_roundtrip.py is the definitive integration test for the full stack. It:
- Provisions mTLS certificates in a temporary directory using
CertificateManager. - Writes a natsim TOML config pointing at those certificates.
- Writes a
lccfq-langQPU TOML config pointing at natsim. - Spawns natsim as a subprocess via its console-script entry (
.venv/bin/lccfq-natsim), bypassing the descriptor-pool collision described above. - Waits for natsim to bind its port.
- Constructs a
lccfq_lang.QPUinstance (which issues aPingin__init__). - Submits a 2-qubit native-gate circuit:
ry(π/2)on qubit 0,sqiswapon qubits 0–1, followed by ameasuremarker. - Asserts the returned distribution sums to the requested shot count and that all keys are well-formed bitstrings.
Run it from lccfq-lang's environment (which has lccfq_lang and lccfq_backend installed):
cd /path/to/lccfq-lang
.venv/bin/python /path/to/lccfq_natsim/scripts/smoke_lang_roundtrip.pyuv run pytest # 366+ tests across unit and integration suites
uv run ruff check .
uv run ruff format .
uv run mypy src/lccfq_natsimThe wire definition lives at src/lccfq_natsim/api/protos/qpu_service.proto. After editing the
.proto, regenerate the compiled stubs:
uv run python scripts/gen_proto.pyThis overwrites src/lccfq_natsim/api/generated/qpu_service_pb2.py and
qpu_service_pb2_grpc.py. Hand-written type stubs at
src/lccfq_natsim/api/generated/qpu_service_pb2.pyi and qpu_service_pb2_grpc.pyi must be
updated manually whenever the proto changes, because grpc_tools does not generate .pyi files.
The package lives under src/lccfq_natsim/ and is divided into eight subpackages.
api owns everything at the gRPC boundary: the .proto source, the generated _pb2 and _grpc
files, and the hand-written .pyi stubs.
engine is the simulation core. It holds the Statevector and Engine classes, the gate kernel
registry (kernels/), the dispatch layer that enforces the native gate set, the sampling
primitive, RNG utilities, and the noise hook interface.
runtime owns the in-process task lifecycle: the QPUAbstraction FSM, the ResultStore, the
task queue, and CircuitResult.
service holds the four gRPC servicers (QPUExecutorServicer, QPUExecutorExtensionsServicer,
QPUExecutorDebugServicer), the asyncio server bootstrap, and the Prometheus metrics HTTP server.
config contains the Pydantic Settings model and its five sub-models, along with the
Settings.from_toml factory.
obs provides structured logging via structlog (log.py) and the four Prometheus counter
registrations (metrics.py).
ir defines GateInstr and TestInstr — the typed internal representations of gates that the
engine consumes after proto decoding.
cli is the lccfq-natsim console-script entry point (serve.py): argument parsing,
configuration loading, logging setup, and the top-level asyncio.run(serve(settings)) call.
runtime/fsm.py implements a three-state machine for the simulated QPU lifecycle. Unlike
lccfq-backend's richer six-state machine (which models physical QPU degradation and
accessibility), natsim models an ideal QPU with no degradation, so only three states carry
behavioral meaning.
INIT --[READY]--> IDLE --[TASK_STARTED]--> BUSY --[TASK_FINISHED]--> IDLE
QPUExecutorServicer.SubmitCircuitTask checks can_transition(TASK_STARTED) before accepting a
circuit. If the FSM is not IDLE — for example because a prior circuit is still executing — it
returns success=False without touching state. This enforces the single-tenant, synchronous
FIFO execution model.
The noise infrastructure is in place but Kraus-channel sampling is not yet wired. The design rests on three components.
engine/noise/protocol.py defines the NoiseModel structural protocol. Any conformant
implementation must provide a kraus_ops_for(symbol, params, qubits) method returning a list of
np.complex128 Kraus operators satisfying trace preservation: sum_i K_i† K_i = I. Single-qubit
operators have shape (2, 2); two-qubit operators have shape (4, 4). An empty list signals
"no noise on this gate." The qubit ordering follows natsim's little-endian convention throughout.
engine/noise/ideal.py provides IdealNoise, which unconditionally returns [] for every gate.
engine/noise/factory.py provides build_noise(NoiseConfig) -> NoiseModel, which currently
always returns IdealNoise() regardless of the config content.
engine/noise/helpers.py contains stubs for depolarizing, amplitude_damping, and dephasing.
Each raises NotImplementedError; they exist as anchors for the Kraus-sampling implementation
that lands in a future release.
The Engine already calls kraus_ops_for after every gate dispatch. When the returned list is
non-empty, it raises NotImplementedError (Kraus application is the deferred work). When it is
empty — which is always the case today — execution proceeds without overhead. This design means
that noise=None and noise=IdealNoise() produce byte-identical output, which is a tested
invariant.
The conformance anchor for future channel implementations is
tests/engine/test_noise_conformance.py::assert_kraus_conformant. Any Kraus list returned by a
new noise model should pass through this helper before being merged.
Apache-2.0. See LICENSE at the repository root.