Building an Agentic Quantum Computing System
Quantum kernels and agentic systems
Building an Agentic Quantum Computing System
Quantum kernels and agentic systems
Read this story for free: link
Multi-agent systems are now being used to solve complex and hard problems, but those problems need a vast space to capture every pattern, and even big knowledge graphs fall behind as the problem grows. A quantum kernel solves a bigger version of this, because it maps the data into a vast space that grows with every qubit, far larger than a normal computer can hold or easily copy. In this blog we are going to integrate a multi-agent system with quantum computing algorithms and see how far the two can go together.

The whole system: a local model agent drives the quantum engine, and classical baselines on the same data are the judge (Created by Fareed Khan)
Here is everything we build, top to bottom, one component at a time:
- The data contracts: a handful of typed records that every stage agrees on, so the engine never hard-codes a single domain.
- The domain adapters: load network intrusion, credit-card fraud, and particle-physics data into one common shape, and split it the unsupervised way (train on normal only).
- The encoder: compress raw features down to a tiny latent space, because the number of latent values has to equal the number of qubits.
- The quantum core: feature maps that encode data into a quantum state, a fidelity kernel that measures overlap between two states, and a one-class SVM that turns that kernel into an anomaly score.
- The classical opponents: an RBF one-class SVM, an Isolation Forest, and an autoencoder, all judged on the same latent space so the fight is fair.
- The engine: one function that wires every stage together, runs the quantum path and the classical path, and writes a reproducible run record.
- The experiments: a sweep over qubit counts and entanglement that tells us where quantum actually helps and where it falls apart.
- The agent: a locally hosted Qwen3–14B model that calls our pipeline as a tool, reads the metrics, and reports the result in plain language.
Quantum computing might be a new or less familiar term for you, but we are going to learn everything from scratch, so if you are new to it, do not worry, we will build up every idea step by step.
All the code is available in my GitHub repository:
Before we build anything, I want to show you the single result that the whole project is built around, so you know where we are heading. On the network-intrusion domain, with 8 qubits and an entangled feature map, the quantum kernel scores like this against the two classical baselines:
#### OUTPUT ####
method roc_auc average_precision
quantum:dense:qsvm 0.9916 0.9562
rbf_ocsvm 0.9580 0.5726
isolation_forest 0.9157 0.5715
The quantum kernel reaches a ROC-AUC of 0.9916 while the best classical method sits at 0.9580. That is a clear win, and we will reproduce it together step by step.
We simply run both methods on identical data and report whichever one wins. The classical baselines are the judge, and sometimes they win.
Table of Contents
- The problem and the shape of the system
- Setting up the project ∘ The data contract ∘ The component interfaces ∘ The registry and config
- Getting the data: three domains ∘ Network intrusion (KDD) ∘ Credit-card fraud (ULB) ∘ Particle physics (LHC)
- Compressing to a latent space: the encoder
- The quantum core ∘ A crash course in qubits and circuits ∘ The feature maps: dense, angle, and IQP encodings ∘ The fidelity kernel: measuring how alike two states are ∘ The quantum scorers: QSVM, QKMeans, and QKMedians
- The classical opponents (a fair fight)
- Scoring and the run record
- Wiring it together: the engine
- Does quantum actually help? The experiments ∘ The sweep ∘ Quantum versus classical, per domain ∘ Entanglement matters (the headline) ∘ The limit: kernel concentration ∘ Reading one decision: the per-sample trace
- The agent that runs the whole thing ∘ Why an agent ∘ The tools the agent can call ∘ The local model client ∘ The state machine ∘ The four agents: planner, infra, quantum, and evaluator ∘ Watching it run ∘ Grounding the agent in papers
- Recapping our pipeline
The problem and the shape of the system
Let us pin down the problem, because the precise version is what the code has to follow. The shape is the same in all three domains: we have a lot of normal data, we have almost no examples of the thing we actually care about, and we have to score every new sample by how strange it looks.
That is what makes it hard in practice. The data we need most, the fraud, the intrusion, the new particle, is exactly the data we almost never have, so we cannot simply train a model on examples of it.
We are doing unsupervised anomaly detection. That means three things.
- First, we train on normal data only, and the model never sees a labelled anomaly during training.
- Second, we test on a mixed set of normal and anomalous samples, and that test set does carry labels, but only so that we can measure how well we did.
- Third, every method produces an anomaly score for each test sample, where a higher score means more anomalous. We fix that convention everywhere, so the scoring math stays uniform no matter which method produced the scores.

We train on normal data only, and test on a held-out mix that keeps its labels for scoring (Created by Fareed Khan)
We also fix the label convention. A label of 0 is normal (the background), and a label of 1 is an anomaly (the signal). If an underlying estimator happens to return the opposite sign, it is that method’s job to flip it before handing the score back. It sounds like a small detail, but it is the kind that quietly corrupts a benchmark if you do not enforce it everywhere.
The central question of the whole project is simple to state. Can a quantum kernel separate anomalies from normal data better than a strong classical method, on the same compressed data?
We are going to answer that question carefully across three very different domains.
Setting up the project
All the code lives in a Python package called qadx, which is short for Quantum Anomaly Detection. The codebase is fairly large, so instead of dumping the entire tree, here is the map of the parts that matter. Each folder is one responsibility, and we will walk through them in roughly this order.
qad-engine/
├── src/qadx/
│ ├── types.py # the data contracts: RawData, DatasetSplit, LatentSplit, ...
│ ├── interfaces.py # the abstract base classes every component implements
│ ├── config.py # the typed, YAML-loadable run configuration
│ ├── registry.py # name -> class registries for swappable parts
│ ├── engine.py # AnomalyEngine: wires one full run together
│ ├── quantum/ # feature maps, device, the fidelity kernel, quantum scorers
│ ├── adapters/ # domain data loaders (kdd, fraud, lhc, sanity)
│ ├── encoders/ # classical compressors (pca, autoencoders)
│ ├── baselines/ # classical opponents (rbf ocsvm, isolation forest, ae)
│ ├── metrics/ # scoring + the quantum-vs-classical comparison + sweeps
│ └── agents/ # the LangGraph multi-agent loop + the local-LLM client
├── configs/ # YAML run configs that deep-merge on top of base.yaml
└── scripts/ # the sweep driver, the agent runner, the plot generator

How the qadx package is organized by responsibility (Created by Fareed Khan)
The nice thing about this layout is that the engine in the middle does not know anything about quantum computing, or fraud, or physics. It only knows about the abstract interfaces.
Everything concrete plugs in by name through a registry. That is what lets the same engine run a credit-card fraud benchmark and a particle-physics benchmark without changing a line.
You can run the whole thing on a plain CPU with no GPU at all, which is how I validated it before spending a second on the GPU. Here is the quickstart:
# create a virtual environment and install the local (CPU) extra
py -m venv .venv && . .venv/Scripts/activate
pip install -e ".[local]"
# validate the kernel and the pipeline on the CPU (39 pass, 1 skip)
pytest -q
# run one end-to-end experiment on the CPU
qadx run --config configs/domain_fraud.yaml
For the heavy runs, the quantum simulator and the language model both live on one NVIDIA H100 80GB GPU. The quantum kernel runs on PennyLane, an open-source quantum-computing library, using its lightning.gpu device, which is a statevector simulator, basically a program that mimics a quantum computer by tracking the full quantum state in ordinary memory, here on the GPU for speed.
The device layer falls back to a CPU simulator automatically when no GPU is present. So the same code runs on your laptop and on the H100, it just runs slower on the laptop. We will get to the GPU numbers later. For now, let us build the foundation.
The data contract
Before any logic, we have to decide what data looks like as it moves between stages. I like to do this with a small set of typed records, because it means every stage has a clear agreement with the next one, and a mistake shows up immediately instead of three stages later. These records live in types.py, and the module docstring states the conventions we just discussed so they are impossible to forget.
@dataclass
class RawData:
"""Raw, domain-native samples after ingestion (pre-encoding)."""
features: np.ndarray # (N, F) float array, model-ready features
labels: np.ndarray # (N,) int array, 0 normal, 1 anomaly
feature_names: list[str] | None = None
meta: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.features = np.asarray(self.features, dtype=np.float64)
self.labels = np.asarray(self.labels).astype(int).ravel()
if self.features.ndim != 2:
raise ValueError(f"features must be 2-D (N,F); got {self.features.shape}")
if self.features.shape[0] != self.labels.shape[0]:
raise ValueError("features and labels must have the same N")
@property
def anomaly_rate(self) -> float:
return float(self.labels.mean()) if self.labels.size else 0.0
RawData is what an adapter produces straight after it loads a domain. It is just a feature matrix, a label vector, optional names, and a free-form meta dictionary for record-keeping. The __post_init__ does the boring but important work of coercing types and checking shapes, so a malformed dataset cannot sneak past the front door.
The anomaly_rate property is a convenience we will print a lot, because the first thing you want to know about any anomaly dataset is how rare the anomalies actually are.
The next record is the train/test split. This is where the unsupervised rule gets baked in.
@dataclass
class DatasetSplit:
"""Train/test split in the original feature space.
``X_train`` is normal-only (unsupervised). ``y_test`` carries the test labels.
"""
X_train: np.ndarray
X_test: np.ndarray
y_test: np.ndarray
y_train: np.ndarray | None = None
meta: dict[str, Any] = field(default_factory=dict)
Notice there is no y_train that we actually use for learning. It is kept only for bookkeeping, and it is expected to be all zeros, because the training set is normal-only. The labels we care about live in y_test. After the encoder runs, we get the same idea but in the compressed latent space.
@dataclass
class LatentSplit:
"""Train/test split after the classical encoder compresses to latent space.
``Z_train`` / ``Z_test`` are ``(n, latent_dim)``. This is what the quantum
kernel and the classical baselines both consume (a fair fight on identical Z).
"""
Z_train: np.ndarray
Z_test: np.ndarray
y_test: np.ndarray
latent_dim: int
meta: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.Z_train = np.asarray(self.Z_train, dtype=np.float64)
self.Z_test = np.asarray(self.Z_test, dtype=np.float64)
self.y_test = np.asarray(self.y_test).astype(int).ravel()
if self.Z_train.shape[1] != self.latent_dim:
raise ValueError(
f"Z_train width {self.Z_train.shape[1]} != latent_dim {self.latent_dim}"
)
That one sentence in the docstring, “a fair f ight on identical Z”, is the central rule of the whole project. The quantum kernel and every classical baseline consume the same LatentSplit. They never get different views of the data. So when one of them wins, it wins because of the method, not because it got an easier version of the problem.
The last two records are the outputs. A ScoreResult holds the per-sample scores from one method, and an EvalMetrics holds the numbers we report.
@dataclass
class ScoreResult:
"""Output of a Scorer: per-test-sample anomaly scores (higher = more anomalous)."""
scores: np.ndarray
y_test: np.ndarray
method: str
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class EvalMetrics:
"""Standard anomaly-detection metrics for one (method, domain) result."""
method: str
roc_auc: float
average_precision: float
n_train: int
n_test: int
anomaly_rate: float
extra: dict[str, Any] = field(default_factory=dict)
So the data flows through the system as a chain of these records, one feeding the next.

The data contract: each stage produces one typed record that feeds the next (Created by Fareed Khan)
There is one more record, RunManifest, that captures everything needed to reproduce a run (the config hash, the seed, the device, package versions, the git SHA, and per-stage wall times). We will look at it when we get to the engine, because that is where it gets written.
The component interfaces
With the data shapes agreed on, we can define what a component is. There are exactly four kinds of swappable thing in the system, and each one is an abstract base class in interfaces.py.
A DomainAdapter turns a domain’s raw data into our common shape. An Encoder compresses features into a latent space. A KernelBackend computes a similarity matrix. A Scorer turns a representation into anomaly scores. That is the whole vocabulary.

The four component interfaces the engine wires together (Created by Fareed Khan)
class DomainAdapter(ABC):
"""Turns a domain's raw data into the common RawData / DatasetSplit form."""
name: str = "base" # short unique name used in configs / the registry
@abstractmethod
def ingest(self, cfg: Any) -> RawData:
"""Load + clean raw data into a RawData (features, labels, names)."""
@abstractmethod
def split(self, raw: RawData, cfg: Any) -> DatasetSplit:
"""Make a normal-only train split and a mixed, labelled test split."""
@abstractmethod
def label_meaning(self) -> dict[str, str]:
"""Human-readable meaning of labels, e.g. {"0": "background", "1": "signal"}."""
The Encoder is just as small. It fits on the normal-only training features and transforms any split into a latent matrix. The important attribute is latent_dim, because that number has to match the qubit count later.
class Encoder(ABC):
"""Classical (often GPU) compressor: raw features -> low-dim latent Z."""
latent_dim: int = 0 # dimensionality of the latent space the encoder emits
@abstractmethod
def fit(self, X: np.ndarray) -> "Encoder":
"""Fit on normal-only training features. Returns self."""
@abstractmethod
def transform(self, X: np.ndarray) -> np.ndarray:
"""Map features -> latent (n, latent_dim)."""
The two interfaces that the quantum and classical methods share are KernelBackend and Scorer. A kernel produces a Gram matrix, which is just a table of pairwise similarities. A scorer produces anomaly scores. The important part is how a scorer declares which representation it wants.
class Scorer(ABC):
"""Produces anomaly scores (higher = more anomalous)."""
# "kernel" (fit/score take Gram matrices) or "latent" (take Z matrices).
consumes: str = "latent"
@abstractmethod
def fit(self, X_train: np.ndarray) -> "Scorer":
"""Fit on normal-only training representation (K_train or Z_train)."""
@abstractmethod
def score(self, X_test: np.ndarray) -> np.ndarray:
"""Return (m,) anomaly scores; higher == more anomalous."""
That consumes attribute is the mechanism that lets the quantum path and the classical baselines share one loop. A scorer with consumes == "kernel" is handed a precomputed Gram matrix, which is what the quantum kernel SVM wants. A scorer with consumes == "latent" is handed the latent matrix directly, which is what an Isolation Forest wants.
The engine reads this one attribute and feeds each scorer the representation it asked for. We will see exactly how when we build the engine.
The registry and config
To plug a class in by name, we register it. The registry is deliberately tiny, just four dictionaries and a pair of decorator-plus-getter functions for each.

A name in the config maps through the registry to the right class (Created by Fareed Khan)
ADAPTERS: dict[str, type] = {}
ENCODERS: dict[str, type] = {}
KERNELS: dict[str, type] = {}
SCORERS: dict[str, type] = {}
# usage at the bottom of, say, quantum/qsvm.py:
# @register_scorer("qsvm")
# class QuantumOCSVM(Scorer): ...
Registration happens as a side effect of importing the module that defines the class. So there is a small bootstrap.py whose only job is to import every implementation module, wrapped in a try/except, so the CPU-only core still loads even when optional packages like torch or the GPU plugin are missing.
def load_all(strict: bool = False) -> dict[str, bool]:
"""Import every implementation module. Returns {module: ok}. Idempotent."""
global _loaded
status: dict[str, bool] = {}
for mod in _IMPL_MODULES:
try:
importlib.import_module(mod)
status[mod] = True
except Exception as exc: # optional dep missing or impl not built yet
status[mod] = False
if strict:
raise
log.info("skipped %s (%s: %s)", mod, type(exc).__name__, exc)
_loaded = True
return status
This pattern is why you can pip install the lightweight core and still run the quantum kernel on a CPU, and only later add torch when you want the autoencoder encoders. A missing dependency degrades to a skipped module, not a crash.
The run itself is described by a single typed config, built with pydantic so a malformed YAML is caught with a clear message. The config has a section per concern, and the defaults are the Phase-0 setup we ship with.
class QuantumCfg(BaseModel):
backend: Literal["lightning.gpu", "lightning.qubit", "default.qubit"] = "default.qubit"
n_qubits: int = 8
feature_map: Literal["dense", "dense_all", "dense_no_ent", "angle", "iqp"] = "dense"
reps: int = 2
scorer: Literal["qsvm", "qkmeans", "qkmedians"] = "qsvm"
nu: float = Field(0.1, description="OneClassSVM nu (anomaly fraction upper bound)")
assume_normalized_kernel: bool = True
A run config is loaded by deep-merging a chosen YAML on top of configs/base.yaml, so each scenario only has to state what it changes. Here is the config the agent actually ran on the GPU, which is also the smallest config that produces our headline result.
seed: 0
domain:
adapter: kdd
n_train: 60
n_test: 300
test_anomaly_rate: 0.1
params:
seed: 0
encoder:
kind: pca
latent_dim: 8
standardize: true
quantum:
backend: lightning.gpu
n_qubits: 8
feature_map: dense
reps: 2
scorer: qsvm
baselines:
- rbf_ocsvm
- isolation_forest
Read that config slowly, because it is the whole experiment in fifteen lines. We are on the KDD intrusion domain. We train on 60 normal samples and test on 300 samples that are 10% anomalies.
We compress to an 8-dimensional latent space with PCA, encode it on 8 qubits with the entangled dense feature map, and score with a quantum one-class SVM. And we race it against two classical baselines. Every number in this blog comes from configs that look just like this one.
Getting the data: three domains
Before we can split anything, we have to get the data onto disk. A small script does it with one short function per dataset, and the two tabular sets come from public mirrors, so there is no login or API token to deal with.
def download_ulb(dest: Path) -> Path:
"""Fetch ULB credit-card fraud from OpenML (data_id 1597) and save a CSV."""
from sklearn.datasets import fetch_openml
bunch = fetch_openml(data_id=1597, as_frame=True, parser="auto")
df = bunch.frame.copy()
df["Class"] = df["Class"].astype(int) # 0 = genuine, 1 = fraud
df.to_csv(dest / "creditcard.csv", index=False)
return dest / "creditcard.csv"
def download_kdd(dest: Path) -> Path:
"""Fetch KDD Cup 99 (the 10% subset) via scikit-learn and save a CSV."""
from sklearn.datasets import fetch_kddcup99
bunch = fetch_kddcup99(subset=None, percent10=True, as_frame=True)
df = bunch.frame.rename(columns={"labels": "label"})
# labels arrive as bytes like b'normal.', so decode them for a clean CSV
df["label"] = df["label"].apply(lambda v: v.decode() if isinstance(v, bytes) else str(v))
df.to_csv(dest / "kddcup99.csv", index=False)
return dest / "kddcup99.csv"
Each one just calls a scikit-learn or OpenML fetcher and writes a CSV. We run them by name from the command line, and they print what they wrote.
python scripts/download_data.py --dataset ulb
python scripts/download_data.py --dataset kdd
#### OUTPUT ####
[ulb] fetching OpenML data_id=1597 (CreditCardFraudDetection) ...
[ulb] wrote data/creditcard.csv rows=284807 cols=31 anomalies(Class==1)=492
The physics set is different. It lives on Zenodo with no single stable download link, so its function points us at the record instead of fetching blindly.
def download_lhc(dest: Path) -> None:
"""Print the Zenodo record + wget guidance (no hardcoded direct link)."""
print("[lhc] HEP datasets are hosted on Zenodo; no stable direct link is hardcoded.")
print(f"[lhc] record : {ZENODO_LHC_RECORD}")
print("[lhc] Once you have a file's download URL, fetch it with:")
print(f"[lhc] wget -c -P {dest} '<FILE_DOWNLOAD_URL_FROM_RECORD>'")
#### OUTPUT ####
[lhc] HEP datasets are hosted on Zenodo; no stable direct link is hardcoded.
[lhc] record : https://zenodo.org/record/2629073
[lhc] Once you have a file's download URL, fetch it with:
[lhc] wget -c -P data '<FILE_DOWNLOAD_URL_FROM_RECORD>'
The adapters can also fetch the tabular sets themselves the first time they run, so the script is really for caching the files on disk. Now we load the data. Every adapter ends with the same train/test split, because the split policy has to be identical across domains for the comparison to mean anything. That shared logic lives in make_unsupervised_split, and the core of it is short.

Three very different domains load into one common data shape (Created by Fareed Khan)
normal_idx = np.flatnonzero(y == 0)
anom_idx = np.flatnonzero(y == 1)
# --- Train: normal-only, sampled without replacement ---------------------
train_sel = _take(rng, normal_idx, n_train)
train_mask = np.zeros(y.shape[0], dtype=bool)
train_mask[train_sel] = True
# Pools available for the test set (everything not used for training).
pool_normal = normal_idx[~train_mask[normal_idx]]
pool_anom = anom_idx # anomalies are never used for training
The training set is drawn from normal rows only, and the anomalies are never touched during training.
The test set is then built from held-out rows, and if the config asks for a specific test_anomaly_rate we resample to hit it, otherwise we keep the data’s natural rate but guarantee at least a couple of anomalies so the ROC and average-precision numbers are even defined. The whole thing is driven by one seed, so a given dataset and config always produce the same split.
Network intrusion (KDD)
The first domain is the KDD Cup 99 network-intrusion dataset, a classic anomaly-detection benchmark. We use scikit-learn’s built-in fetcher with the SA subset, which is built specifically for anomaly detection: it is mostly normal traffic with a small fraction of intrusions.
The label policy is simple, the connection type normal. is the only non-anomaly, and everything else is an intrusion.

A network connection becomes 41 features and a normal-or-intrusion label (Created by Fareed Khan)
@register_adapter("kdd")
class KDDCup99Adapter(DomainAdapter):
name = "kdd"
CAT_COLS = ("protocol_type", "service", "flag") # ordinal-encode these
def ingest(self, cfg: Any) -> RawData:
from sklearn.datasets import fetch_kddcup99
bunch = fetch_kddcup99(subset="SA", percent10=True, as_frame=True, shuffle=False)
df = bunch.frame.copy()
raw_labels = df["labels"].to_numpy()
feat_df = df.drop(columns=["labels"])
label_str = _as_str(raw_labels)
normal_mask = np.char.strip(label_str.astype(str)) == "normal."
labels = (~normal_mask).astype(int) # 1 == intrusion (anomaly)
features, feature_names = self._encode(feat_df) # ordinal-encode 3 cat cols
...
def label_meaning(self) -> dict[str, str]:
return {"0": "normal network connection", "1": "intrusion/attack (anomaly)"}
The system exposes a small tool that just loads a dataset and reports its shape, which is the first thing I run against any new domain. Here is what KDD looks like when it lands.
#### OUTPUT ####
dataset 'kdd' ready: 100655 samples, 41 features, anomaly_rate=0.0033
You can see that we have about a hundred thousand network connections, 41 features each, and only 0.33% of them are intrusions. That tiny anomaly rate is exactly why this is an anomaly-detection problem and not a classification problem. There are not enough intrusions to train a classifier on, but there are plenty of normal connections to learn what normal looks like.
Credit-card fraud (ULB)
The second domain is the ULB credit-card-fraud dataset, the famous one with 28 anonymous PCA components plus Time and Amount, and a Class label where 1 means fraud.
It loads from a local CSV when you have one, otherwise it pulls from OpenML and caches it. The one thing the adapter has to get right is the label, because different sources encode it differently, so it coerces the Class column into a clean 0/1 array.

A card transaction becomes 30 features and a genuine-or-fraud label (Created by Fareed Khan)
def _coerce_binary_label(s: pd.Series) -> np.ndarray:
"""Map a label column to {0,1} ints, where 1 == fraud/anomaly."""
# Use pandas dtype checks: np.issubdtype THROWS on pandas CategoricalDtype
# (OpenML returns the ULB 'Class' column as Categorical(['0','1'])).
if pd.api.types.is_bool_dtype(s):
return s.astype(int).to_numpy()
if pd.api.types.is_numeric_dtype(s):
return (s.to_numpy() != 0).astype(int)
# Categorical / string label: treat anything that looks like a positive class.
norm = s.astype(str).str.strip().str.lower()
positives = {"1", "1.0", "fraud", "yes", "true", "y", "t", "anomaly", "signal"}
return norm.isin(positives).astype(int).to_numpy()
The label can arrive as a number, a boolean, or a pandas categorical (OpenML returns the ULB Class column as a categorical of '0' and '1'), so the function checks the dtype with the pandas helpers and otherwise matches against a set of common positive-class strings. That keeps the loader robust no matter which source the data came from. Here is the fraud data once it loads.
#### OUTPUT ####
dataset 'fraud_ulb' ready: 284807 samples, 30 features, anomaly_rate=0.0017
You can see almost three hundred thousand transactions, 30 features, and an even tinier anomaly rate of 0.17%. Fraud is rarer than intrusions, which is going to make it a harder, more classical-friendly problem, and we will see that in the results.
Particle physics (LHC)
The third domain is the most involved one in the project, the LHC Olympics 2020 dataset for new-physics searches. The raw data is a table where each row is a collision event, stored as 700 hadrons with their momentum and angles, plus a truth label that says whether a hypothetical new particle was injected.
Raw hadrons are not something you feed a model directly, so the adapter does the high-energy-physics preprocessing first. It clusters each event’s hadrons into jets with the anti-kT algorithm, the standard recipe physicists use to group a spray of particles into jets, takes the two leading jets, and computes physically meaningful features from them.

A collision event is clustered into jets, then nine jet features and a label (Created by Fareed Khan)
_JET_FEATURE_NAMES = [
"mjj", # dijet invariant mass (probes a new particle)
"mj1", "mj2", # individual jet masses
"mj1_minus_mj2", # the mass difference
"pt_j1", "pt_j2", # transverse momenta of the two leading jets
"deltaR_j1j2", # angular separation between them
"tau21_j1", # n-subjettiness ratio of jet 1 (jet substructure)
"tau21_j2", # and of jet 2
]
If you do not speak physics, here is all you need. A jet is a spray of particles that comes from a single quark or gluon.
The invariant mass mjj of the two leading jets is the key signal: if some new heavy particle decayed into those two jets, their combined mass piles up at the particle’s mass instead of spreading out smoothly. The n-subjettiness tau21 measures whether a jet looks like it came from one prong or two, which is how you tell a fast-moving new particle from ordinary background.
So we turn a messy 2100-number event into nine clean, physical numbers, and from there it is the same anomaly-detection problem as fraud, just with QCD background as “normal” and a new-physics signal as the “anomaly”.
The clustering itself uses the fastjet library, the same tool physicists use on LHC data. We build a massless particle from each hadron’s momentum and angles, run the anti-kT algorithm with a jet radius of 1.0, and take the two highest-momentum jets that come out.
# build massless PseudoJets from each event's (pT, eta, phi) hadrons
particles = []
for k in range(pt.size):
px = pt[k] * math.cos(phi[k])
py = pt[k] * math.sin(phi[k])
pz = pt[k] * math.sinh(eta[k])
e = pt[k] * math.cosh(eta[k]) # massless: E = |p|
particles.append(fastjet.PseudoJet(px, py, pz, e))
# cluster into large-R jets with anti-kT (R = 1.0), keep the two leading jets
jet_def = fastjet.JetDefinition(fastjet.antikt_algorithm, self.JET_R)
jets = fastjet.sorted_by_pt(fastjet.ClusterSequence(particles, jet_def).inclusive_jets())
j1 = jets[0]
j2 = jets[1] if len(jets) > 1 else None
From those two jets, the dijet invariant mass falls out of the summed four-vectors, and that single number mjj is where a new particle would show up as a bump. This preprocessing is the reason the LHC adapter is the most involved of the three.
To stay testable on a machine without the physics libraries, the adapter also accepts a precomputed feature file, so you can do the heavy clustering once on the GPU box and then load the jet features anywhere. The labels mean exactly what you would expect.
def label_meaning(self) -> dict[str, str]:
return {"0": "QCD background (normal)", "1": "BSM resonance signal (anomaly)"}
Three domains, three completely different kinds of data, one common shape. That is the benefit of the contracts and adapters we built. From here on, nothing else in the system cares whether a row is a network packet, a credit-card swipe, or a particle collision.
Compressing to a latent space: the encoder
We have to compress the data first. We cannot feed 41 network features or 30 fraud features straight into a quantum circuit, and there is a very concrete reason why. First the word itself: a latent space is just a compressed summary of each sample in a handful of numbers, the same idea as squeezing many correlated columns down to a few.
In this system, each latent value is encoded on exactly one qubit. So the width of the latent space has to equal the number of qubits. If we want an 8-qubit circuit, we need an 8-dimensional latent space, and the encoder is the thing that gets us there.

The encoder compresses raw features to a latent Z, and each latent value becomes one qubit (Created by Fareed Khan)
@register_encoder("pca")
class PCAEncoder(Encoder):
"""Optional standardisation followed by PCA down to latent_dim components."""
def __init__(self, ecfg: EncoderCfg) -> None:
self.latent_dim: int = int(ecfg.latent_dim)
self.standardize: bool = bool(ecfg.standardize)
self._scaler: StandardScaler | None = None
self._pca: PCA | None = None
def fit(self, X: np.ndarray) -> "PCAEncoder":
Xf = _as_2d_float(X)
if self.standardize: # z-score so no column dominates the axes
self._scaler = StandardScaler()
Xf = self._scaler.fit_transform(Xf)
self._pca = PCA(n_components=self.latent_dim, random_state=0)
self._pca.fit(Xf) # fit on normal-only training data
return self
def transform(self, X: np.ndarray) -> np.ndarray:
Xf = _as_2d_float(X)
if self._scaler is not None:
Xf = self._scaler.transform(Xf)
return np.ascontiguousarray(self._pca.transform(Xf), dtype=np.float64)
This latent_dim == n_qubits rule is the single most important rule in the project, and the engine refuses to run if it is broken. We will see that check in a moment. For now, the simplest encoder that satisfies it is PCA, short for principal component analysis, a standard way to squeeze many correlated features down to a few uncorrelated ones, and it is always available because it only needs scikit-learn.
The key thing about fit is that it is called on the normal-only training set, and then transform is applied to both train and test. The PCA axes are learned from normal data alone, so there is no leakage of anomaly information into the representation.
If you want something more expressive than PCA, there are two torch autoencoders in encoders/. The tabular one is a plain symmetric MLP that compresses through hidden layers down to the bottleneck. The physics one is tuned for jet features, using batch-norm and a smooth GELU activation, because jet measurements have a few very large values and a bare ReLU MLP behaves worse on them.
def _encoder_block(torch: Any, dims: list[int]) -> Any:
"""Dense blocks: Linear -> BatchNorm -> GELU per hidden step, linear bottleneck."""
nn = torch.nn
layers: list[Any] = []
for i in range(len(dims) - 1):
layers.append(nn.Linear(dims[i], dims[i + 1]))
if i < len(dims) - 2:
layers.append(nn.BatchNorm1d(dims[i + 1]))
layers.append(nn.GELU())
return nn.Sequential(*layers)
Whichever encoder we pick, the output is the same shape, an (n, latent_dim) matrix. To see that the encoder is actually doing useful work, here is the KDD latent space projected down to two dimensions for plotting, coloured by the true label.

The KDD latent space the encoder produces, with normal points clustered and anomalies pushed out (Created by Fareed Khan)
We can already see the split:
- The normal connections (circles) sit in a loose cloud.
- The intrusions (triangles) are pushed to one side as clear outliers.
- So the encoder alone already separates the classes somewhat.
The quantum kernel’s job is to take this latent space and sharpen that separation into a score. So let us build the quantum part.
The quantum core
This is the core of the system, so we have to go slowly. We will build it in three steps. First a crash course in what a qubit and a circuit actually are. Then the feature map that turns our latent vector into a quantum state. Then the fidelity kernel that measures how similar two states are.
If quantum computing is new to you, that is fine, because we only need a small, concrete piece of it, and I will keep everything grounded in the actual code.

The quantum core in three steps: feature map, fidelity kernel, scorer (Created by Fareed Khan)
A crash course in qubits and circuits
A classical bit is either 0 or 1. A qubit is basically the quantum version of a bit, except that until you measure it, it can hold a blend of 0 and 1 at the same time. That blend is called a superposition.
The everyday picture is a spinning coin, which is neither heads nor tails while it is in the air and only becomes one or the other when it lands, and landing is the moment we measure it. A more formal picture is the Bloch sphere, where a single qubit’s state is a point on a sphere and a rotation gate slides it around the surface.
This ability to hold many possibilities at once, before we look, is the one quantum property we actually use, and it is what gives the model a much larger space to separate normal data from anomalies in.

A qubit state: a blend of 0 and 1 whose squared amplitudes are the measurement probabilities (Created by Fareed Khan)
Let’s understand this quickly:
- A qubit is α parts 0 plus β parts 1, a blend of the two.
- α and β are the amplitudes, like volume knobs for 0 and for 1.
- Their squared sizes must add up to one, because they are probabilities.
- Measuring gives 0 with chance
|α|^2and 1 with chance|β|^2. - The feature map just turns these knobs from the data before we measure.

A qubit lives in superposition, n qubits span 2^n combinations, RY rotates by the data, CNOT entangles (Created by Fareed Khan)
The reason qubits are powerful is that they multiply. One qubit is a blend of 2 possibilities. Two qubits are a blend of 4. Eight qubits are a blend of 2 to the power of 8, which is 256 possibilities all at once.
So an 8-qubit state lives in a 256-dimensional space, and that large space is where we are going to measure similarity.
We shape a quantum state by applying gates, which are basically the quantum version of operations, the same way AND and OR are operations on classical bits. We only need two kinds.
An RY gate is basically a dial that rotates one qubit by an angle, and we set that angle from the data, so the rotation is how a data value gets into the circuit. A CNOT gate acts on two qubits and ties them together so they can no longer be described independently, which is called entanglement.
Entanglement is the quantum way of capturing relationships between features, which is what matters when two fields look normal on their own but are suspicious in combination, exactly the pattern that fraud and intrusion detection care about. The last tool is the adjoint of a gate, which is basically the undo button, running the gate backwards, and we will use it to compare two states. That is the entire toolkit.
The feature maps: dense, angle, and IQP encodings
A feature map is basically the quantum version of feature engineering, a small circuit that takes a latent vector and turns it into a quantum state so the kernel has something quantum to compare. If you have ever scaled or transformed features before feeding them to a model, this is the same idea, except the output is a quantum state instead of a plain vector.
The pattern is always the same: rotate each qubit by one latent value with an RY gate, then optionally entangle the qubits with CNOTs, and repeat the whole thing a few times to make it richer.

The dense feature map: scale the data into angles, RY-encode each qubit, entangle with a CNOT ring, repeat (Created by Fareed Khan)
There is one catch we have to handle before the rotations. Our latent values come out of PCA or an autoencoder, and they are not bounded to a friendly range. A PCA component can easily be plus or minus ten. An RY rotation wraps around every 2 pi, so a raw value of ten would spin the qubit many times and the kernel would become chaotic and useless.
The fix is to squash every latent value through tanh and scale it into the range minus pi to pi, which is smooth and keeps the ordering of the values intact.
_ANGLE_SCALE = np.pi # rotation angles live in (-pi, pi) after tanh-squashing
def _scaled_angles(z: Sequence[float]) -> np.ndarray:
"""Squash latent components into a sane rotation range (-pi, pi)."""
arr = np.asarray(z, dtype=np.float64).ravel()
return _ANGLE_SCALE * np.tanh(arr)

The feature map encodes a latent vector into an n-qubit state, one rotation angle per qubit (Created by Fareed Khan)
We can explain this simply:
U_phi(z)is the feature-map circuit, the recipe that encodes the data.- Applying it to the all-zeros start state gives the encoded state
|phi(z)>. - Each angle
theta_iis one latent value squashed throughpi * tanh. - The
(x)njust means we usenqubits, one per latent value.
With the angles sorted, the default feature map is dense. It encodes each value as an RY rotation, then entangles the qubits with a ring of CNOT gates, where qubit 0 talks to qubit 1, qubit 1 to qubit 2, and so on around to the last qubit talking back to qubit 0.
def dense(z: Sequence[float], wires: Sequence[int], reps: int) -> None:
"""Dense encoding with a nearest-neighbour CNOT ring."""
wires = list(wires)
angles = _scaled_angles(z)
n = len(wires)
for _ in range(int(reps)):
_encode_layer(angles, wires) # RY(angle[i]) on each wire i
if n > 1:
for i in range(n):
qml.CNOT(wires=[wires[i], wires[(i + 1) % n]]) # the ring
The single most important comparison in the whole project is between dense and its twin, dense_no_ent, which is exactly the same encoding with the entangling gates removed.
def dense_no_ent(z: Sequence[float], wires: Sequence[int], reps: int) -> None:
"""Dense encoding with NO entangling gates (the entanglement ablation)."""
wires = list(wires)
angles = _scaled_angles(z)
for _ in range(int(reps)):
_encode_layer(angles, wires) # RY rotations only, nothing ties the qubits together
These two feature maps differ by nothing except entanglement. So if dense beats dense_no_ent, the difference is the entanglement doing the work, and not anything else. That single controlled comparison is the most defensible scientific result in the entire project, and we will measure it carefully later.
There are three more feature maps available, and the engine can pick any of them by name.
dense_all entangles every pair of qubits instead of just neighbours, angle is PennyLane’s built-in angle embedding (the same RY rotations, packaged up), and iqp uses an IQP embedding, short for Instantaneous Quantum Polynomial, a heavier encoding that also mixes pairs of inputs together rather than encoding each value on its own qubit.
def dense_all(z, wires, reps):
"""RY encoding, then an all-to-all CNOT mesh (maximum entanglement)."""
angles = _scaled_angles(z)
for _ in range(int(reps)):
_encode_layer(angles, wires)
for i in range(len(wires)):
for j in range(i + 1, len(wires)):
qml.CNOT(wires=[wires[i], wires[j]])
def angle(z, wires, reps):
"""PennyLane's built-in RY angle embedding, repeated reps times."""
angles = _scaled_angles(z)
for _ in range(int(reps)):
qml.AngleEmbedding(angles, wires=wires, rotation="Y")
def iqp(z, wires, reps):
"""IQP embedding: adds higher-order interaction terms between qubits."""
qml.IQPEmbedding(_scaled_angles(z), wires=wires, n_repeats=int(reps))
All five maps are registered in one dictionary and resolved by name through get_feature_map, so swapping the encoding is a one-word change in the config. The pair that tells the story, though, is dense versus dense_no_ent, so that is the comparison we will keep coming back to.
Here is the actual dense circuit on 8 qubits with 2 repetitions, drawn by PennyLane. This is what each sample physically becomes.

The dense feature-map circuit: RY rotations encode the data, CNOTs entangle, repeated twice (Created by Fareed Khan)
We can read it left to right:
- Each wire is a qubit.
- The
RYboxes are the data-driven rotations that encode each value. - The vertical connectors are the CNOT gates that entangle neighbouring qubits.
- The whole pattern repeats twice, because
repsis 2.
The fidelity kernel: measuring how alike two states are
Now the key idea, and first a word on what a kernel even is, because the whole project hangs on it. A kernel is basically a function that scores how similar two data points are, and it is the workhorse behind classical methods like the SVM.
Similarity is also what a lot of practical systems run on, from matching look-alike transactions to recommending similar products, so a better similarity measure is not an abstract win.
Our kernel scores similarity by asking how much two samples overlap once they are encoded as quantum states. Two identical states overlap completely, two very different states barely overlap at all, and that overlap is a number between 0 and 1 that behaves exactly like a similarity score.
In quantum terms this overlap is called the fidelity, so you can read fidelity as the quantum cousin of cosine similarity, and it is computed with one short circuit.

The overlap circuit: encode z1, run the encoding for z2 backwards, measure, and the probability of all zeros is the fidelity (Created by Fareed Khan)
The method is this. Prepare the state for the first sample by running its feature map. Then run the feature map for the second sample backwards, using the adjoint.
If the two samples are identical, the backward run perfectly undoes the forward run and you are guaranteed to land back in the all-zeros state. If they differ, the undo is imperfect and you land in all-zeros only some of the time. So the probability of measuring all zeros is exactly the fidelity between the two encoded states.

The fidelity kernel is the squared overlap of two encoded states, equal to the probability of the all-zeros outcome (Created by Fareed Khan)
Let us break this down simply:
- The kernel is the squared overlap of the two encoded states.
- It is 1 when the two samples are identical, near 0 when they are very different.
- The right side is the same value as one circuit: encode the first, undo the second.
- The answer is just the probability of measuring all zeros.
- So one short circuit returning probabilities is all the code we need.
In code, the entire kernel value is that one circuit.
def _overlap(z1: np.ndarray, z2: np.ndarray):
fmap(z1, wires, reps) # prepare phi(z1)
qml.adjoint(fmap)(z2, wires, reps) # run the z2 encoding backwards
return qml.probs(wires=range(self.n_qubits))
self._qnode = qml.QNode(_overlap, self.device, diff_method=qcfg.diff_method)
def kernel_value(self, z1: np.ndarray, z2: np.ndarray) -> float:
"""Fidelity kernel k(z1, z2) = probs[0] of the overlap circuit."""
z1 = self._prep(self._check_len(z1, "kernel_value(z1)"))
z2 = self._prep(self._check_len(z2, "kernel_value(z2)"))
probs = self._qnode(z1, z2)
return float(np.asarray(probs)[0]) # probability of the all-zeros bitstring
There is one more piece of care in the kernel, and it is the same kind of scaling problem we saw with the rotations. The tanh squashing saturates for values beyond about plus or minus two, and a saturated kernel loses all of its structure because every pair looks the same.
So the kernel fits a StandardScaler on the training latent and rescales the inputs into the near-linear part of tanh before encoding them. That scaler is fitted once on the normal-only training set and reused for the test set, so train and test share one consistent encoding.
def gram_train(self, Z: np.ndarray) -> np.ndarray:
"""Symmetric (n, n) training Gram matrix."""
# Fit the input normalizer on the (normal-only) training latent; reused by
# gram_test so train and test share one consistent encoding.
self._scaler = StandardScaler().fit(Z)
return np.asarray(
qml.kernels.square_kernel_matrix(
Z, self.kernel_value,
assume_normalized_kernel=self.qcfg.assume_normalized_kernel,
),
dtype=np.float64,
)
The gram_train method builds the full Gram matrix, which is the table of fidelities between every pair of training samples.

The Gram matrix collects every pairwise fidelity, and it is symmetric with entries between 0 and 1 (Created by Fareed Khan)
We can read this simply as:
- A Gram matrix is the full table of pairwise similarities, like a correlation or distance matrix.
- Entry
K_ijis the kernel (similarity) between samplesiandj. - It is symmetric, so
Kequals its own transpose. - Every entry sits between 0 and 1, because fidelity is a probability.
- Those properties are what make
Ka valid kernel for the SVM.
PennyLane’s square_kernel_matrix calls our kernel_value for each pair. There is a matching gram_test that builds the rectangular table between every test sample and every training sample. Those two matrices are everything the scorer needs.
Where does this circuit actually run? PennyLane lets us pick a device, and we want the GPU when we have it. The device factory tries the GPU simulator first, and falls back gracefully to a CPU simulator if the GPU is missing, logging a warning at each step so you always know what you got.
def make_device(n_wires: int, backend: str) -> "qml.devices.Device":
if backend == "lightning.gpu":
if _device_importable("lightning.gpu"):
return qml.device("lightning.gpu", wires=n_wires)
log.warning("lightning.gpu unavailable; falling back to lightning.qubit ...")
backend = "lightning.qubit"
if _device_importable(backend):
return qml.device(backend, wires=n_wires)
return qml.device("default.qubit", wires=n_wires) # always present, pure Python
So what does a Gram matrix look like? Here is the quantum kernel on the 60 KDD training samples, drawn as a heatmap.

The quantum kernel Gram matrix on KDD: a bright diagonal of self-similarity, mostly low off-diagonal overlap (Created by Fareed Khan)
We can see a couple of things here:
- The bright diagonal is every sample being perfectly similar to itself.
- The off-diagonal is darker, so most pairs are only weakly similar.
- That sparse, barely-overlapping structure gives the SVM room to draw a tight boundary.
We will see in a moment why that structure quietly breaks down as we add more qubits.
Before we trust any of this, we should check that the kernel actually behaves like a correct fidelity kernel and not like a bug. The test suite pins down exactly that, and these are not soft checks, they are mathematical properties that a correct fidelity kernel must satisfy.
#### OUTPUT ####
tests/test_kernel_correctness.py::test_gram_train_is_valid_fidelity_matrix PASSED
tests/test_kernel_correctness.py::test_gram_train_is_psd PASSED
tests/test_kernel_correctness.py::test_kernel_value_matches_statevector PASSED
tests/test_kernel_correctness.py::test_entanglement_changes_the_kernel PASSED
tests/test_kernel_correctness.py::test_lightning_matches_default_qubit PASSED
In plain language, those five tests confirm that the Gram matrix is symmetric with a unit diagonal (every sample is perfectly similar to itself) and every entry between 0 and 1, and that it is positive semi-definite (the technical condition a similarity table must satisfy for an SVM to be able to use it).
They also confirm that our circuit value matches the overlap computed directly from the quantum amplitudes (the textbook way), that turning entanglement on actually changes the kernel, and that the GPU simulator produces the same numbers as the CPU one.
That last check matters because it means the GPU is a pure speedup, not a different computation. With the kernel verified, we can turn it into an anomaly score.
The quantum scorers: QSVM, QKMeans, and QKMedians
A kernel gives us similarities, but we want a single anomaly score per sample. The cleanest way to do that is a one-class SVM on the precomputed kernel.
A one-class SVM is basically a model that learns the shape of normal and flags anything that falls outside it, which is exactly the tool you want for fraud, intrusion, or defect detection, where you have plenty of normal examples and almost no labelled anomalies.
It learns a boundary that wraps tightly around the normal training data in kernel space, and then for any new sample it tells us which side of that boundary the sample falls on and by how much. Points well inside the boundary are normal, points outside are anomalous.
@register_scorer("qsvm")
class QuantumOCSVM(Scorer):
"""One-class SVM on a precomputed quantum Gram matrix."""
consumes = "kernel" # this scorer is fed Gram matrices, not raw latents
def __init__(self, cfg: RunCfg) -> None:
self.nu = float(cfg.quantum.nu)
self.svm = OneClassSVM(kernel="precomputed", nu=self.nu)
def fit(self, X_train: np.ndarray) -> "QuantumOCSVM":
"""Fit on the normal-only (n, n) training Gram matrix."""
K = np.asarray(X_train, dtype=np.float64)
self.svm.fit(K)
return self
def score(self, X_test: np.ndarray) -> np.ndarray:
"""Anomaly scores for the (m, n) test Gram matrix (higher = anomalous)."""
K = np.asarray(X_test, dtype=np.float64)
return -self.svm.decision_function(K).ravel()
Two details make this follow our conventions. First, consumes = "kernel", so the engine hands it the Gram matrices we just built instead of the raw latent vectors. Second, scikit-learn’s decision_function returns a high value for inliers, which is backwards from what we want, so we negate it in score. After the negation, a higher number means more anomalous, exactly as the contract demands.
That nu parameter, set to 0.1 by default, is roughly the fraction of training points the SVM is allowed to treat as outliers, so it controls how tight the boundary is.

The one-class SVM decision in kernel space, negated so a higher score means more anomalous (Created by Fareed Khan)
Let us go through it quickly:
f(x)is the SVM’s decision: a weighted sum of how similarxis to the support vectors.- The support vectors are the few training points that define the boundary.
f(x)is positive for points the model treats as normal.- We flip the sign to get the score
s(x), so higher means more anomalous. - The most extreme outliers then get the highest scores.

The Gram matrix feeds a one-class SVM, whose decision function we negate so higher means more anomalous (Created by Fareed Khan)
There are two more quantum scorers in quantum/qkmeans.py, a kernel k-means and a kernel k-medians, which cluster the normal data in kernel space and score a test point by its distance to the nearest cluster. They never form explicit centroids, because we cannot, the points only live as kernel values.
Instead they use the kernel trick, the idea that you can compute distances and clusters using only the pairwise similarities and never the raw coordinates, which is exactly what lets a quantum kernel slot into an otherwise classical clustering algorithm.

The squared kernel distance from a point to a cluster, written purely with kernel values (Created by Fareed Khan)
We can take it term by term:
- The first term is the point’s similarity to itself, a constant (1 for our kernel).
- The middle term is how similar the point is to the cluster members on average.
- The last term measures how tightly the cluster holds together.
- A point far from every cluster gets a large distance.
- Its smallest distance across clusters becomes its anomaly score.
# d(x, C)^2 = K(x, x) - 2 * mean_{i in C} K(x, x_i) + mean_{i, j in C} K(x_i, x_j)
def score(self, X_test: np.ndarray) -> np.ndarray:
K = np.asarray(X_test, dtype=np.float64) # (m, n) test-vs-train Gram
kxx = np.ones(K.shape[0]) # K(x, x) = 1 for a normalized kernel
dist2 = np.empty((K.shape[0], len(self._members)))
for c in range(len(self._members)):
members = self._members[c]
cross = K[:, members].mean(axis=1) # mean K(x, x_i) over cluster members
const = float(self._within[c]) # mean K(x_i, x_j) within the cluster
dist2[:, c] = kxx - 2.0 * cross + const
return np.maximum(dist2.min(axis=1), 0.0) # nearest-cluster distance, higher = anomalous
A test point that sits far from every normal cluster gets a large distance and therefore a high anomaly score, which is the same intuition as the one-class SVM expressed through clustering instead of a boundary. The k-medians variant is the same idea but uses a single representative point per cluster (the medoid) instead of the cluster mean.
The one-class SVM is our default because it is the strongest and the simplest to reason about, so it is the one we will use for every result in this blog. The clustering scorers are there to show that the same quantum kernel can drive more than one kind of detector, since once you have the Gram matrix, the choice of scorer is just a choice.
The classical opponents (a fair fight)
The quantum kernel means nothing on its own. We have to race it against strong classical methods on the same latent space, because that is the only way to know if it is any good. We use three opponents, and they all live in baselines/. The most direct one is the RBF one-class SVM, which is the exact same algorithm as our quantum scorer but with a classical Gaussian kernel instead of a quantum one.

The quantum kernel and every classical baseline score the same latent Z (Created by Fareed Khan)
@register_scorer("rbf_ocsvm")
class RBFOneClassSVM(Scorer):
"""One-Class SVM with a classical RBF kernel (gamma="scale")."""
consumes = "latent" # this one takes the latent matrix directly
def fit(self, X_train: np.ndarray) -> "RBFOneClassSVM":
self._svm = OneClassSVM(kernel="rbf", gamma="scale", nu=self.nu)
self._svm.fit(X_train)
return self
def score(self, X_test: np.ndarray) -> np.ndarray:
# decision_function: >0 inlier, <0 outlier -> negate so higher == anomalous.
return -np.asarray(self._svm.decision_function(X_test), dtype=np.float64).ravel()
This is the cleanest comparison in the project. The quantum SVM and the RBF SVM are the same model with one part swapped, the kernel. If the quantum kernel beats the RBF kernel, it is the quantum similarity that did it.
Notice that consumes = "latent" here, so the engine feeds this scorer the latent matrix Z directly instead of a Gram matrix, because scikit-learn builds the RBF kernel internally. That single consumes attribute is what lets both scorers share one engine loop.
The second opponent is an Isolation Forest, a strong, kernel-free tree method that isolates anomalies by how few random splits it takes to separate them.
The third is an autoencoder reconstruction scorer, which trains a small autoencoder on the normal latent data and scores a test sample by how badly the autoencoder fails to reconstruct it, on the logic that a model trained on normal data reconstructs normal data well and anomalies poorly.
All three consume the same latent Z the quantum kernel saw. There is no way for the quantum method to get an easier version of the problem.
Scoring and the run record
Every method, quantum or classical, produces a ScoreResult, and we need to turn that into the numbers we report. We do it with one small function.
We use two standard metrics. ROC-AUC measures how well the scores rank anomalies above normals across all thresholds, where 0.5 is random and 1.0 is perfect. Average precision is the area under the precision-recall curve, which is more sensitive when anomalies are very rare, as ours are.
def evaluate(sr: ScoreResult, n_train: int) -> EvalMetrics:
"""Compute ROC-AUC and average precision for a ScoreResult."""
y = np.asarray(sr.y_test).astype(int).ravel()
scores = np.asarray(sr.scores, dtype=np.float64).ravel()
if np.unique(y).size < 2: # a single-class test set makes AUC undefined
return EvalMetrics(..., roc_auc=float("nan"), average_precision=float("nan"), ...)
if not np.all(np.isfinite(scores)): # sanitise NaN/inf before they corrupt the metric
finite = scores[np.isfinite(scores)]
fill = float(finite.min()) if finite.size else 0.0
scores = np.where(np.isfinite(scores), scores, fill)
roc = float(roc_auc_score(y, scores))
ap = float(average_precision_score(y, scores))
return EvalMetrics(method=sr.method, roc_auc=roc, average_precision=ap, ...)
For the physics domain there is also a third curve worth computing, the significance improvement characteristic, or SIC. It is a staple of LHC anomaly searches, and it answers a physicist’s question rather than a machine-learning one: if I cut on this score, how much does the statistical significance of a signal improve?
It is the true-positive rate divided by the square root of the false-positive rate, computed only where some background survives so we never divide by zero.
def sic_curve(y: np.ndarray, scores: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Significance Improvement Characteristic: (tpr, sic) with sic = tpr / sqrt(fpr)."""
fpr, tpr, _ = roc_points(y, scores)
mask = fpr > 0.0 # only where some background survives the cut
return tpr[mask], tpr[mask] / np.sqrt(fpr[mask])
The metrics module keeps ROC, precision-recall, and SIC side by side, so the same scores can be read the machine-learning way (AUC and average precision) or the physics way (significance improvement), without re-running anything.
There is one more diagnostic that is special to quantum kernels and that we will lean on heavily, called kernel concentration. It is simply the variance of the off-diagonal Gram entries.
def kernel_concentration(K: np.ndarray) -> float:
"""Variance of the off-diagonal Gram entries (kernel concentration diagnostic)."""
n = K.shape[0]
off = K[~np.eye(n, dtype=bool)]
return float(np.var(off))

Kernel concentration is the variance of the off-diagonal Gram entries (Created by Fareed Khan)
Let’s understand it step by step:
- We look only at the off-diagonal entries, since the diagonal is always 1.
- Spread-out values mean the kernel separates samples well, so the variance is high.
- Values that collapse to one number drop the variance toward zero.
- A variance near zero is the warning sign that the kernel has gone flat.
Here is why it matters. As you add qubits, fidelity kernels tend to concentrate, which basically means every pair of samples starts to look equally similar, the off-diagonal variance collapses toward zero, and the SVM loses its ability to tell samples apart because everything looks the same.
A higher variance means a more informative kernel, and a variance near zero is a warning sign.
This is the quantum machine-learning version of a barren plateau, which is the same flavour of problem as the vanishing gradients people hit when scaling deep networks, where the signal you need to learn from flattens out as the model grows. It is a fundamental limit, not a tuning issue, and we are going to watch it happen live.
When the engine finishes a run, it writes a small folder of artifacts so the run is fully reproducible: a manifest.json with the config hash, seed, device, package versions, and per-stage wall times, a metrics.csv with one row per method, a scores.npz with the raw per-sample scores, and a summary.json.

Every run writes four files: manifest, metrics, scores, and summary (Created by Fareed Khan)
Here is the summary.json from the KDD run.
#### OUTPUT ####
{
"run_id": "kdd-1886d8229a90",
"domain": "kdd",
"seed": 0,
"device": "lightning.gpu",
"results": {
"quantum:dense:qsvm": {"roc_auc": 0.991604, "average_precision": 0.956216},
"rbf_ocsvm": {"roc_auc": 0.958024, "average_precision": 0.572567},
"isolation_forest": {"roc_auc": 0.915740, "average_precision": 0.571487}
},
"best_method": "quantum:dense:qsvm",
"kernel_concentration": 0.028968,
"n_test": 300,
"anomaly_rate": 0.1
}
Every number in that file was produced by the run itself, and we can re-open the scores.npz to inspect any individual sample, which is exactly what we will do later when we trace one decision end to end.
Wiring it together: the engine
Now we have all the pieces, and we need to wire them together. The engine is the one function that does that. It resolves every component by name from the config, so adding a new domain or scorer never touches this file. The first thing it does is enforce our rule.
def run(self, cfg: RunCfg, created_at: str, run_id: str | None = None) -> dict[str, Any]:
bootstrap.ensure_loaded()
set_global_seed(cfg.seed)
# --- INVARIANT: one latent component per qubit. ----------------------
if cfg.encoder.latent_dim != cfg.quantum.n_qubits:
raise ValueError(
"Engine invariant violated: encoder.latent_dim "
f"({cfg.encoder.latent_dim}) must equal quantum.n_qubits "
f"({cfg.quantum.n_qubits}). Each latent component is encoded on "
"exactly one qubit; lock these together (sweeps do this for you)."
)
After that guard, it walks the pipeline. It ingests and splits the data, fits the encoder on the normal-only training set and transforms both splits, then runs the quantum path: build the training Gram matrix, build the test Gram matrix, fit the quantum scorer, and score. Each stage is wrapped in a tiny timer so we know where the wall-clock time went.
# --- Encoder: compress to latent Z (fit on normal-only train). -------
with _timed(wall_times, "encode"):
enc = get_encoder(cfg.encoder.kind)(cfg.encoder)
enc.fit(split.X_train)
Z_train = np.asarray(enc.transform(split.X_train), dtype=np.float64)
Z_test = np.asarray(enc.transform(split.X_test), dtype=np.float64)
# --- Quantum path: fidelity kernel -> kernel scorer. -----------------
with _timed(wall_times, "quantum_kernel"):
qkernel = get_kernel("quantum")(cfg.quantum)
K_train = np.asarray(qkernel.gram_train(Z_train), dtype=np.float64)
K_test = np.asarray(qkernel.gram_test(Z_test, Z_train), dtype=np.float64)
kconc = kernel_concentration(K_train)
with _timed(wall_times, "quantum_score"):
qscorer = get_scorer(cfg.quantum.scorer)(cfg)
qscorer.fit(K_train)
q_scores = np.asarray(qscorer.score(K_test), dtype=np.float64).ravel()
Then it runs every classical baseline on the same Z_train and Z_test. This is the loop where the consumes attribute does its job. A kernel scorer already ran above on the Gram matrices, and these latent scorers run here on the latent matrices, all inside one engine.
# --- Classical baselines on the SAME latent Z (consumes="latent"). ---
for name in cfg.baselines:
with _timed(wall_times, f"baseline:{name}"):
try:
s = get_scorer(name)(cfg)
s.fit(Z_train)
b_scores = np.asarray(s.score(Z_test), dtype=np.float64).ravel()
b_sr = ScoreResult(scores=b_scores, y_test=y_test, method=name)
metrics.append(evaluate(b_sr, n_train))
except Exception as exc:
# e.g. ae_recon without torch installed — skip, don't crash.
log.warning("baseline '%s' skipped (%s: %s)", name, type(exc).__name__, exc)

One run: ingest and split, encode to Z, then the quantum kernel and the classical baselines both score the same Z, then evaluate and persist (Created by Fareed Khan)
That whole pipeline runs from one command. The CLI exposes three subcommands, run for a single experiment, sweep for the parameter grid, and agent for the language-model loop. Let us run the KDD config we looked at earlier and read the table it prints.
qadx run --config configs/agent_run.yaml
#### OUTPUT ####
=== metrics — kdd ===
method roc_auc average_precision n_train n_test anomaly_rate
quantum:dense:qsvm 0.9916 0.9562 60 300 0.1
rbf_ocsvm 0.9580 0.5726 60 300 0.1
isolation_forest 0.9157 0.5715 60 300 0.1
run_dir: runs/kdd-1886d8229a90
You can see our headline result there, produced end to end by the engine. The quantum kernel reaches 0.9916 ROC-AUC against 0.9580 for the best classical method, and the gap on average precision is even wider, 0.9562 versus 0.5726. On this domain, in this setting, the quantum kernel genuinely separates intrusions from normal traffic better than the classical baselines do.
The average-precision gap is the more striking number, because with only 10% anomalies, average precision is the metric that punishes false alarms, and the quantum kernel is ranking the actual intrusions far more tightly at the top.
It is worth looking at where the time went, because it tells you what is expensive about quantum machine learning. Here are the per-stage wall times from that run’s manifest.
#### OUTPUT ####
"wall_times": {
"ingest": 1.6638,
"split": 0.0009,
"encode": 0.0026,
"quantum_kernel": 68.9741,
"quantum_score": 0.0009,
"baseline:rbf_ocsvm": 0.0022,
"baseline:isolation_forest": 0.0757
}
Almost the entire run, 69 seconds of it, is the quantum kernel. Everything else, including both classical baselines, finishes in well under a tenth of a second combined.
That is the cost of quantum machine learning today: building the Gram matrix means evaluating an overlap circuit for every pair of samples, and even on an H100 simulator that dominates the wall clock. The classical methods are essentially free by comparison.
So when we talk about a quantum “win”, we should remember it is a win on the metric, paid for with three orders of magnitude more compute. That is the kind of trade-off you only see if you measure it, which is why the engine times every stage.
Does quantum actually help? The experiments
One run on one domain proves nothing. To answer the question properly we have to sweep across qubit counts, feature maps, and random seeds, and across all three domains. That is what the sweep driver does. It runs the full engine for each cell of a grid and records the quantum AUC, the best classical AUC, and whether quantum won.

One sweep gives us three findings (Created by Fareed Khan)
The sweep
The sweep is a simple set of nested loops over qubits, feature maps, and seeds, calling engine.run once per cell and pulling out the numbers we care about.

Each grid cell runs the engine and compares quantum to the best classical (Created by Fareed Khan)
for q in qubits:
for fmap in maps:
for seed in seeds:
ov = {
"seed": seed,
"domain": {"adapter": args.domain, "n_train": args.n_train,
"n_test": args.n_test, "test_anomaly_rate": args.test_anom,
"params": {"seed": seed}},
"encoder": {"kind": "pca", "latent_dim": q, "standardize": True},
"quantum": {"backend": args.backend, "n_qubits": q,
"feature_map": fmap, "reps": args.reps, "scorer": "qsvm"},
"baselines": ["rbf_ocsvm", "isolation_forest"],
}
cfg = load_config(overrides=ov)
out = eng.run(cfg, created_at=datetime.now().isoformat())
qrow = next((r for r in out["metrics"] if r["method"].startswith("quantum")), None)
clf = [r for r in out["metrics"] if not r["method"].startswith("quantum")]
best_clf = max((r["roc_auc"] for r in clf), default=float("nan"))
win = qrow["roc_auc"] >= best_clf
Notice that latent_dim is set to q on every cell, so the rule stays satisfied automatically as we sweep the qubit count. Running this on KDD over 4, 8, 12, and 16 qubits, for both the entangled and the no-entanglement feature maps, prints a table like this.
#### OUTPUT ####
backend=lightning.gpu domain=kdd qubits=[4, 8, 12, 16] maps=['dense', 'dense_no_ent'] seeds=[0]
------------------------------------------------------------------------------------------------
qubits map seed quantum_AUC best_clf_AUC win q_AP secs
4 dense 0 0.925 0.958 no 0.457 53.4
4 dense_no_ent 0 0.586 0.958 no 0.139 28.5
8 dense 0 0.992 0.958 YES 0.956 70.6
8 dense_no_ent 0 0.707 0.958 no 0.182 38.4
12 dense 0 0.933 0.958 no 0.572 97.6
12 dense_no_ent 0 0.679 0.958 no 0.170 48.3
16 dense 0 0.938 0.985 no 0.601 124.5
16 dense_no_ent 0 0.597 0.985 no 0.138 59.5
You can see a lot in that little table, and three patterns jump out. The quantum kernel only beats classical at 8 qubits. The entangled dense map is dramatically better than dense_no_ent at every qubit count. And the wall-clock time climbs steadily with qubits, from 53 seconds at 4 qubits to 124 seconds at 16. Let us pull each of those three patterns apart, because each one is a separate lesson.
Quantum versus classical, per domain
First, the cross-domain picture. The single KDD win is encouraging, but is it a fluke of one domain? We ran the same kind of sweep on all three domains and took the best dense cell from each. Here is the comparison.

Best quantum AUC versus best classical AUC per domain: quantum wins on KDD, classical edges it on LHC and fraud (Created by Fareed Khan)
The story is mixed, and worth stating plainly:
- KDD: quantum wins clearly, its bar is taller than the classical bar.
- LHC: quantum and classical are neck and neck (0.744 versus 0.724), inside the seed noise.
- Fraud: classical wins, the RBF SVM at 0.875 against the best quantum at 0.850.
So the quantum kernel is competitive everywhere and genuinely best in exactly one setting. That is a clear result, and it is the kind of result you would never report if you were trying to sell quantum computing, which is precisely why it is worth reporting.
It is worth looking at the raw LHC sweep, because it shows how thin the margin is. The quantum kernel only crosses the classical line on two cells out of eighteen, both at seed 2, and even then barely.
#### OUTPUT ####
qubits map seed quantum_auc best_clf_auc win
4 dense 0 0.6298 0.7214 no
4 dense 1 0.6284 0.7925 no
4 dense 2 0.6870 0.7427 no
6 dense 2 0.7437 0.7236 YES
8 dense 0 0.6041 0.6570 no
8 dense 1 0.5706 0.6386 no
8 dense 2 0.7438 0.7236 YES
The LHC data is genuinely hard. Most of the time the Isolation Forest, sitting around 0.72, beats the quantum kernel, and only one lucky seed nudges quantum ahead. The fraud sweep is even clearer about who wins.
#### OUTPUT ####
qubits map seed quantum_auc best_clf_auc win
8 dense 0 0.8505 0.8746 no
8 dense 1 0.7609 0.8426 no
8 dense_no_ent 0 0.6890 0.8746 no
8 dense_no_ent 1 0.6093 0.8426 no
On fraud the quantum kernel never wins, the classical RBF SVM is simply stronger on this data. So the picture is clear and worth stating plainly: quantum is competitive on all three, best on one, and beaten on another.
Even the KDD win is not as solid as a single number makes it look. When we run the winning 8-qubit setup across five seeds, the quantum kernel only comes out ahead on two of them.
#### OUTPUT ####
qubits map seed quantum_auc best_clf_auc win
8 dense 0 0.9916 0.9580 YES
8 dense 1 0.9419 0.9998 no
8 dense 2 0.9990 0.9980 YES
8 dense 3 0.9443 0.9741 no
8 dense 4 0.9896 0.9956 no
The quantum kernel averages 0.973 across the five seeds, which is strong, but the classical baseline also moves around and wins three of the five races.
So the plain summary of the headline is this: the quantum kernel produces our single best score (0.9916, and even 0.9990 on seed 2), it is genuinely competitive on every seed, but calling it a clean win requires picking the right seed. That is exactly the kind of detail that a single bar chart hides and that the raw sweep reveals, which is why we keep the raw numbers.
We can look closer at the KDD win with the ROC curves and the score distribution. The ROC curves show all three methods bending toward the top-left corner, which is good, with the quantum kernel holding a strong true-positive rate at low false-positive rates.

ROC curves on KDD: the quantum kernel versus the two classical baselines (Created by Fareed Khan)
And the score histogram shows why.

The quantum anomaly score cleanly separates normal samples from anomalies on KDD (Created by Fareed Khan)
Here is what the histogram shows:
- Normal samples pile up near zero.
- True anomalies sit far out to the right, with little overlap.
- That clean separation is what a good anomaly detector looks like.
- The two clusters barely touch, so almost any threshold does a good job.
Entanglement matters (the headline)
Now the result I am most confident about. Remember that dense and dense_no_ent are identical except for the entangling gates. So the gap between them is a clean measurement of what entanglement contributes, with everything else held fixed. We computed that gap on all three domains, averaged over seeds.

Entanglement helps in every single domain: the entangled feature map beats the no-entanglement one across KDD, LHC, and fraud (Created by Fareed Khan)
Here is what the bars tell us:
- Entanglement helps in every domain, with no exceptions.
- Averaged over seeds, it adds about +0.151 on KDD, +0.13 on LHC, and +0.157 on fraud.
- The strongest single case: at 8 qubits,
densescores 0.992 versus 0.707 without entanglement. - That is a 0.285 gap from nothing but the CNOT gates.
The per-seed deltas on KDD tell the same story consistently.
#### OUTPUT ####
entanglement ablation (dense - dense_no_ent), positive = entanglement helps:
seed=0: +0.285 (dense=0.992, no_ent=0.707)
seed=1: +0.111 (dense=0.942, no_ent=0.830)
seed=2: +0.265 (dense=0.999, no_ent=0.734)
seed=3: +0.079 (dense=0.944, no_ent=0.865)
seed=4: +0.017 (dense=0.990, no_ent=0.973)
This is the most defensible scientific claim in the whole project, and it is worth being precise about why. We are not claiming a quantum computer beat a classical one.
We are claiming that, inside our simulated kernel, the entangling structure does measurable, reproducible work, in a controlled comparison where it is the only thing that changed. That is a genuine quantum effect cleanly isolated, and it holds across three unrelated domains.
The limit: kernel concentration
So if entanglement helps and the win holds, why not just crank the qubits up to 16 and win bigger? Because of the kernel concentration we built a diagnostic for earlier. As qubits grow, the fidelity kernel concentrates, the off-diagonal similarities collapse toward a constant, and the kernel stops being able to tell samples apart. Here is what that looks like as a curve on KDD.

Quantum AUC on KDD peaks around 8 qubits and then declines as the kernel concentrates (Created by Fareed Khan)
The curve tells a clear story:
- The entangled curve climbs to a peak right around 8 qubits, then falls back.
- The no-entanglement curve is worse everywhere, the entanglement result again.
- More qubits is not better: there is a sweet spot, and past it the kernel degrades.
You can see the concentration directly in the run records too: the off-diagonal variance shrinks as the circuit gets deeper, which is the early warning that the kernel is going flat. This is the documented barren-plateau or kernel-concentration limit of quantum kernels, and we observed it live on our own runs rather than just reading about it.
Reading one decision: the per-sample trace
Aggregate metrics are convincing, but they are abstract. To fully trust an anomaly detector, I like to watch it judge individual samples and check the verdict against the truth.

One sample’s journey: raw features to latent to score to verdict (Created by Fareed Khan)
The system has a demo script that pulls KDD samples, runs the full pipeline on them, and prints exactly what it decided for each one: the raw features it saw, the latent the qubits encoded, the quantum score, the verdict, and whether the verdict was correct. Here is a trimmed view of that trace.
#### OUTPUT ####
Per-sample pipeline trace — kdd (8 qubits, lightning.gpu)
Test set: 300 samples, 30 true anomalies. Quantum AUC=0.997 | classical RBF AUC=0.961.
Verdict rule: flag the top 10% most-anomalous (quantum score >= +0.258) => FLAGGED.
sample TRUE quantum score verdict correct?
#188 ANOMALY +0.258 FLAG anomaly yes
#17 ANOMALY +0.258 FLAG anomaly yes
#59 ANOMALY +0.258 FLAG anomaly yes
#264 ANOMALY +0.258 FLAG anomaly yes
#270 normal -0.037 ok (normal) yes
#67 normal -0.035 ok (normal) yes
#157 normal -0.003 ok (normal) yes
#199 normal +0.014 ok (normal) yes
The detector is flagging the true intrusions at the top of the score range and letting the normal traffic through at the bottom, which is exactly what we want. We can zoom into the single most-anomalous and most-normal samples and see the whole pipeline laid bare for each.
#### OUTPUT ####
Most-anomalous sample: #188 (true: ANOMALY)
raw features (top |value|): src_bytes=1.03e+03, count=510, srv_count=510,
dst_host_count=255, dst_host_srv_count=255, service=9
latent Z (what the 8 qubits encode): [+10.64, -30.21, +13.42, +26.30,
-5.23, +71.21, -9.98, +20.58]
quantum anomaly score: +0.258 => FLAGGED
Most-normal sample: #270 (true: normal)
raw features (top |value|): dst_bytes=2.48e+03, dst_host_srv_count=255,
src_bytes=246, dst_host_count=58, service=14
latent Z (what the 8 qubits encode): [-0.09, -1.06, -0.39, +0.40,
+0.44, -0.75, -0.02, -0.07]
quantum anomaly score: -0.037 => not flagged
Look at the two latent vectors. The anomaly’s latent is full of huge values, plus 71, minus 30, plus 26, while the normal sample’s latent hovers tightly around zero. The encoder has already pushed the intrusion far out into the corners of the latent space, and the quantum kernel then turns that distance into a high anomaly score.
You can see the decision being made, number by number. At a top-10% alert budget, the final tally on this run was clean.
#### OUTPUT ####
At the top-10% alert budget (30 flagged): 298/300 correct;
caught 29/30 anomalies (TP), 1 false alarm, 1 missed. (AUC 0.997 is threshold-independent.)
So out of 300 samples, the detector got 298 right, caught 29 of the 30 intrusions, and raised a single false alarm. That is the headline AUC made concrete at the level of individual decisions, which is the level a security analyst actually works at.
The agent that runs the whole thing
Everything so far has been a pipeline that I drive by hand. The last layer is what makes the system agentic.
We hand the whole pipeline to a locally hosted language model and let it drive: decide what to run, call our pipeline as a tool, read the numbers that come back, and write its own verdict. The model thinks in short bursts, and the system around it does the heavy lifting, which is the same division of labour that makes any good agent work.

The agent loop at a glance, with the human holding the approval gates (Created by Fareed Khan)
Why an agent
The reason to bother is not that it is fancy, it is that it saves us work. The pipeline has a lot of knobs, and a planner-style model can choose them, run the experiment, look at the metrics, and decide whether to accept the result or try a tighter setting.
And because the model writes the final interpretation, the system produces not just numbers but a readable conclusion in plain language. We build this with LangGraph for the control flow and a small set of tools the model is allowed to call.

The model thinks in short bursts while the system does the heavy work (Created by Fareed Khan)
The tools the agent can call
A tool is just a Python function wrapped so a tool-calling model can invoke it by name. The most important one is run_pipeline, which is a thin wrapper over the engine we already built.

The tools the agent can call, with the money or GPU tools gated (Created by Fareed Khan)
@_lc_tool
def run_pipeline(config_path: str) -> dict:
"""Run the full quantum + classical anomaly-detection pipeline for a config.
Loads the YAML at config_path, executes the AnomalyEngine end to end
(ingest -> encode -> quantum-kernel scorer + classical baselines -> metrics),
and returns a JSON-serialisable summary.
"""
result = _run_engine(config_path)
if isinstance(result, dict):
return {"run_dir": result.get("run_dir"), "metrics": result.get("metrics", [])}
return {"run_dir": _result_run_dir(result), "metrics": _metrics_rows(result)}
There are ten tools in total. Alongside run_pipeline there is run_sweep for the grid, estimate_cost for projecting GPU spend, query_papers for grounding answers in a local paper corpus, download_dataset for fetching a domain, and generate_report.
There are also four infrastructure tools for managing a remote GPU box: provision_vm, wait_active, ssh_run, and teardown_vm. We run everything locally on our own H100, so we do not need those last four, but they illustrate a pattern worth keeping. Two of the tools are gated.
#: Tools that move money / launch GPUs and therefore require a human-approval gate.
GATED_TOOL_NAMES: frozenset[str] = frozenset({"provision_vm", "teardown_vm"})
The idea is that anything that spends money or is hard to undo should require an explicit human yes before it runs. The model can ask to provision a machine, but the graph pauses and waits for a person to approve before the side effect actually happens.
Even though we run locally and never touch those tools, I keep the pattern in because the day you point this at a rented GPU, that approval gate is the thing standing between an agent and a surprise bill.
The number the gate shows the human comes from a small, pure cost calculator, with no network and no clock, so it is easy to reason about. It parses the GPU count out of a machine flavor name, multiplies by the per-GPU hourly price, and tracks a running total against a hard cap.
def estimate(self, flavor: str, hours: float) -> float:
"""cost = (base + n_gpu * price_per_gpu) * hours."""
n_gpu = gpu_count_from_flavor(flavor)
gpu_cost = n_gpu * self.infra_cfg.gpu_hourly_usd[gpu_type_from_flavor(flavor)] if n_gpu else 0.0
return (BASE_HOURLY_USD + gpu_cost) * hours
def would_exceed(self, extra_usd: float) -> bool:
"""True iff spending extra_usd more would breach the spend cap."""
return (self.spent + extra_usd) > self.infra_cfg.spend_cap_usd
Before the graph ever fires the approval interrupt, it asks would_exceed whether the projected spend breaks the configured cap, and if it does, it surfaces an even louder warning to the operator.
So there are two layers of protection: a soft one, where every spend has to be approved by a human, and a hard one, where the system refuses to even propose a spend that would blow the budget. Again, we never spend a cent because we run on our own machine, but this is the pattern I would keep the moment money is involved.
The local model client
The model is Qwen3–14B, an open-weights model that fits comfortably on one H100, and it runs locally on the same machine as the quantum simulator, so nothing leaves the box. There are two ways to talk to it, and the system supports both. The first is to run a local server that exposes an OpenAI-compatible API and point a thin client at it. The client is deliberately small.

Two ways to run the local Qwen3–14B model (Created by Fareed Khan)
def make_llm(serving_cfg: Any, **kwargs: Any):
"""Build a deterministic ChatOpenAI pointed at the local vLLM server."""
from langchain_openai import ChatOpenAI
params: dict[str, Any] = {
"base_url": serving_cfg.base_url, # http://localhost:8000/v1
"api_key": _LOCAL_API_KEY, # "EMPTY"; vLLM ignores it
"model": serving_cfg.served_model_name,
"temperature": 0,
}
params.update(kwargs)
return ChatOpenAI(**params)
The API key is the sentinel string EMPTY, because the OpenAI client demands a key but a local server ignores it. The base_url points at localhost, so every call stays on the machine.
The second way, and the one the working agent uses, is to load the model directly in the same Python process with transformers. There is no server and no network hop, so the model and the quantum simulator sit side by side in one environment.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-14B")
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-14B", torch_dtype=torch.bfloat16, device_map={"": 0}
)
model.eval()
Either way the model is local and runs at temperature 0 for reproducibility. With the client in place, we can wire the model into the control flow.
The state machine
The control flow is a LangGraph state machine. A single typed state dictionary flows from node to node, and each node fills in a piece. The pipeline path is planner, then data, encoder, quantum, baseline, evaluator, and finally report.

The agent graph: planner to data to encoder to quantum to baseline to evaluator, which loops back to widen the sweep or moves on to report (Created by Fareed Khan)
The interesting node is the evaluator, because it is where the graph makes a decision. It compares the best quantum AUC to the best classical AUC, and routes accordingly.
If quantum wins, it proceeds to report. If it does not, and there are retries left, it loops back to the quantum stage with a tighter setting, fewer qubits, no entanglement, a smaller training set, which is exactly the narrow setting where any quantum advantage tends to show up.
def evaluator(state: RunState) -> dict:
rows = state.get("metrics", []) or []
q_best, c_best, q_method, c_method = _best_metrics(rows)
iters = int(state.get("_widen_iters", 0))
if q_best is None or c_best is None:
verdict, phase = "inconclusive (missing metrics)", "evaluated"
elif q_best > c_best + 1e-6:
verdict = f"quantum win: {q_method} AUC={q_best:.4f} > best classical {c_method} AUC={c_best:.4f}"
phase = "evaluated"
elif iters < _MAX_WIDEN_ITERS:
verdict = f"no win, widen sweep (retry {iters + 1}/{_MAX_WIDEN_ITERS})"
phase = "widen_sweep"
else:
verdict = f"classical win accepted after {iters} retries"
phase = "evaluated"
...
The four agents: planner, infra, quantum, and evaluator
Behind the graph sit four agent roles, and each one has its own short system prompt that grounds its behaviour.

The four agent roles, each with its own system prompt (Created by Fareed Khan)
The planner agent owns the run order and decides, after the evaluator speaks, whether to finish or loop back and widen the sweep. The infra agent manages the GPU box, provisioning and tearing it down while respecting a spend cap and never surprising the operator, which is the role we leave idle when everything runs on our own machine.
The quantum agent owns the kernel stage, choosing the feature map, the qubit count, and the scorer, and reasoning about why the kernel does or does not separate anomalies. The evaluator agent judges the outcome, comparing the quantum kernel to the best classical baseline and deciding whether the win is genuine or the sweep should widen.
The quantum agent’s prompt is worth reading, because it tells the model exactly how to think about the kernel, including the concentration trap we measured earlier.
#### OUTPUT ####
Watch for kernel concentration: as qubits/reps grow, off-diagonal fidelities
collapse toward a constant and the kernel becomes uninformative. Prefer fewer
qubits, fewer reps, and less entanglement (dense_no_ent) in the tiny-sample regime.
The target regime for any advantage is narrow: unsupervised, low-dim latent
(4 to 16 qubits), tiny n_train. Outside it, expect the classical baselines to win.
Every one of those four prompts also pushes the agent toward rigour. The evaluator’s prompt, for example, tells it that a win has to be a material, consistent improvement, that a margin inside the seed-to-seed noise is a tie, and that it must always compare against the classical baselines on the same data. The agent inherits that discipline directly from its prompt.
Watching it run
Here is the whole thing in motion. The working agent loads Qwen3–14B in-process, offers it the run_pipeline tool, and asks it to benchmark the quantum kernel. The model decides on its own to call the tool, the engine runs the quantum kernel on the GPU, and then the model reads the metrics and writes a verdict.

The agent loop: the model calls run_pipeline, the engine runs on the GPU, and the model reads the metrics and judges (Created by Fareed Khan)
The loop is two rounds of generation around one tool call, and the code is small enough to read in full.
# ---- Round 1: model decides to call the tool ----
task = (
"You are a quantum-ML benchmarking agent. Use the run_pipeline tool with "
f"config_path='{cfg_path}' to run the quantum anomaly-detection benchmark on "
"the GPU. Call the tool now."
)
r1 = gen([{"role": "user", "content": task}], tools=[RUN_TOOL], max_new=400)
# parse the tool call out of the model's output and actually run it
m = re.search(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", r1, re.DOTALL)
if m:
tc = json.loads(m.group(1))
if tc.get("name") == "run_pipeline":
cp = (tc.get("arguments") or {}).get("config_path", cfg_path)
res = _run_pipeline(cp) # the quantum engine runs here
metrics, run_dir = res.get("metrics"), res.get("run_dir")
# ---- Round 2: model analyzes the real metrics ----
final = gen([{"role": "user", "content": analysis_prompt}], max_new=700)
When you run it, the live log shows the model emitting a tool call in round one, the engine running, and the metrics coming back.
#### OUTPUT ####
=== loading Qwen/Qwen3-14B (transformers, bf16, cu128) ===
model loaded on cuda:0
=== ROUND 1: model is offered the run_pipeline tool ===
[assistant] <tool_call>
{"name": "run_pipeline", "arguments": {"config_path": "/ephemeral/data/agent_run.yaml"}}
</tool_call>
[TOOL] model called run_pipeline(config_path=/ephemeral/data/agent_run.yaml)
-> running PennyLane lightning.gpu quantum kernel on the H100...
=== REAL quantum-engine metrics ===
[
{"method": "quantum:dense:qsvm", "roc_auc": 0.9916, "average_precision": 0.9562, ...},
{"method": "rbf_ocsvm", "roc_auc": 0.9580, "average_precision": 0.5726, ...},
{"method": "isolation_forest", "roc_auc": 0.9157, "average_precision": 0.5715, ...}
]
Those are not numbers I typed into the prompt. The model called the tool, and the tool ran the PennyLane kernel on the H100 and handed the metrics back. Then in round two, the model reads those metrics and writes its own conclusion.
#### OUTPUT ####
=== QWEN3-14B FINAL ANALYSIS ===
1. The quantum method AUC: 0.9916
2. The best classical AUC: 0.9580 (from the RBF one-class SVM)
3. Whether the quantum kernel WON or LOST: WON
4. Conclusion: The quantum kernel outperformed the best classical method by a
significant margin on this benchmark, achieving a much higher ROC-AUC.
That last paragraph is the one that ties the project together. The local model drove the quantum computation by calling our tool, read the result, and declared the win in its own words, without being told the answer in advance. The agentic layer, the quantum core, and the local model all came together on one GPU.
Grounding the agent in papers
There is one more small tool worth a mention, query_papers, which builds a local vector index over a folder of reference papers so the agent can ground its feature-map and setting choices in the literature. It uses a small local embedding model, so it needs no cloud key, and it degrades gracefully: if the library or the papers are missing, it returns a clear “RAG unavailable” message instead of crashing.

The optional paper-grounding tool, which degrades gracefully (Created by Fareed Khan)
def query(index: Any, q: str) -> str:
"""Answer q against a prebuilt index (from build_index)."""
if index is None or not _have_llama_index():
return RAG_UNAVAILABLE
try:
engine = index.as_query_engine()
return str(engine.query(q))
except Exception as exc:
return f"{RAG_UNAVAILABLE} (query failed: {type(exc).__name__}: {exc})"
That graceful-degradation pattern runs through the whole agent layer. A missing optional dependency never crashes the loop, it just returns a clear message and the run continues, which is exactly the behaviour you want from a system meant to run unattended.
Recapping our pipeline
We built a full quantum-plus-agent pipeline, and a single engine carried it across three very different domains. Here is the whole thing in one place before we close.

The pipeline at a glance, from data to the agent’s verdict (Created by Fareed Khan)
- Data contracts and interfaces: a few typed records and four swappable parts, so the engine never hard-codes a domain.
- Three domains, one shape: network intrusion, credit-card fraud, and particle physics, all split the unsupervised way and trained on normal data only.
- Encoder: compress each sample down to a tiny latent Z, one value per qubit.
- Quantum core: a feature map encodes the data, a fidelity kernel measures overlap, and a one-class SVM turns it into a score.
- A fair fight: the classical baselines score the same latent Z, and we report whichever wins.
- The agent: a local Qwen3–14B model runs the whole pipeline as a tool, reads the metrics, and writes the verdict.
The results land where it counts: the quantum kernel wins on KDD (0.9916 against 0.9580), entanglement helps in every domain, and the kernel concentrates past 8 qubits, so more qubits is not always better.
Where would I take this next? Onto real qubits, to run the same kernels on hardware. Into concentration-mitigation, to push the qubit sweet spot higher than 8. And onto more domains, because adding one is a single adapter away.
The full code, with every component from the quantum kernel to the agent loop, is on GitHub:
Thank you for reading this far. We took one idea, give an agent a quantum-sized space to work in, and grew it into a system that detects anomalies across three domains and runs itself end to end on a single GPU.
Wanna chat about quantum machine learning or anything else? Reach me on my LinkedIn.
메타데이터
- post_id
- 97997172583d
- slug
- building-an-agentic-quantum-computing-system-97997172583d
- url
- https://levelup.gitconnected.com/building-an-agentic-quantum-computing-system-97997172583d
- canonical_url
- https://levelup.gitconnected.com/building-an-agentic-quantum-computing-system-97997172583d
- author_url
- https://medium.com/@fareedkhandev
- status
- ok
- fetched_at
- 2026-06-23 03:48:11