An active learning loop where a surrogate model proposes molecules, a quantum chemistry oracle measures them on a warm CPU pool, and a scientist approves each batch before it runs.
Closed-loop molecular discovery
Code available on GitHub.
AI-for-science labs no longer screen a library once and stop. They run a loop: a model proposes candidates, a simulation or a robotic lab measures the most promising ones, the measurements retrain the model, and the next batch is better. Lila Sciences builds whole “AI Science Factories” around this loop. A-Lab ran it for 17 days on inorganic materials, with robots doing the synthesis. EvolutionaryScale’s esmGFP came out of a model that generated thousands of candidate proteins, of which a small fraction were made and tested in the lab. And for molecules, pool-based active learning finds most of the top hits of a virtual screen after measuring a few percent of the library.
The loop is simple to describe and awkward to run. The measurement is the expensive part, so it has to fan out over many machines and must never be paid for twice. Measurements fail: a calculation does not converge, a robot drops a plate. The loop runs for hours or days, so a crash in round 7 should not throw away rounds 1 to 6. And a person usually signs off on what goes to the lab, because lab time is the scarce resource, which means the loop has to stop and wait for an answer without stalling when nobody is around.
This tutorial runs the loop on Union at a scale that finishes in under an hour. By the end you will have:
- a pool of 20,000 drug-like molecules from ZINC250k, and an oracle that computes each one’s HOMO-LUMO gap with GFN2-xTB, a semi-empirical quantum chemistry method;
- a surrogate model with uncertainty, an ensemble of MLPs on molecular fingerprints, retrained every round, and an acquisition function that picks a diverse batch of 64;
- a human approval step before every batch, with a timeout so an unattended run keeps going;
- a random search with the same oracle budget, run alongside, so you can see what the model is worth;
- the best molecules published as a dataset artifact with a card.
The task
The objective is to find the molecules with the smallest HOMO-LUMO gap, the energy between the highest occupied and lowest unoccupied molecular orbitals. A small gap is a first screen for organic semiconductors and dyes. Small gaps are rare in a drug-like library: the median molecule in the pool has a gap near 3 eV, and fewer than 1 molecule in 100 has a gap below 1 eV.
The oracle is real compute, not a table lookup. For each molecule it embeds 8 3D conformers with RDKit, relaxes them with a force field, optimizes the best 3 with GFN2-xTB, and reads the orbital energies of the lowest-energy structure. That takes a few seconds per molecule and can fail in several ways, which is what makes it a fair stand-in for a lab.
flowchart LR
P["build_pool<br/>20,000 molecules"] --> D["discover<br/>driver"]
D --> R["run_round<br/>cached per round"]
R --> S["propose<br/>MLP ensemble, warm CPU"]
S --> G{"approve-round-N<br/>condition"}
G -- "approved or<br/>timed out" --> M1["measure<br/>GFN2-xTB"]
G --> M2["measure"]
G --> M3["... 128 per round"]
M1 & M2 & M3 --> R
D --> A["artifact:<br/>top candidates"]
classDef human fill:#e8790033,stroke:#e87900,stroke-width:1.5px
classDef cpu fill:#94a3b826,stroke:#94a3b8,stroke-width:1px
class G human
class P,D,R,S,M1,M2,M3,A cpu
What Union adds
| Need | Construct |
|---|---|
| Measure 128 molecules a round without a pod per molecule | A reusable environment, flyte.ReusePolicy(replicas=(2, 12)) |
| Never measure a molecule twice, in this run or any later one | flyte.Cache("auto", salt=ORACLE_VERSION) on the per-molecule measure task |
| Keep the surrogate’s imports and data warm between rounds | A one-replica reusable environment for propose |
| Let a scientist approve each batch | flyte.new_condition, with a timeout that auto-approves |
| Resume a crashed loop from the last finished round | Each round is a cached task whose output is the whole loop state |
| See the loop’s progress while it runs | A live report, with tabs for the surrogate, the batches, and the oracle |
| Keep the result | flyte.artifacts with a dataset card |
Getting started
You need a Union cluster. The pool is downloaded from a public URL inside the run, and every package comes from PyPI.
git clone https://github.com/unionai/unionai-examples
cd unionai-examples/v2/tutorials/closed_loop_discovery
python discovery.py # 10 rounds of 64 over 20,000 molecules, about 45 minutes
python discovery.py --smoke # 2 rounds of 16 over 2,000 molecules, about 6 minutesThe 45 minutes assume someone approves each batch within a few seconds of it being proposed.
Each round waits up to 3 minutes (approval_timeout_s=180) for an answer, so an unattended run
adds about 30 minutes of waiting. To run without the gate, launch with approval_timeout_s=0:
flyte.run(discover, approval_timeout_s=0)To approve a batch from Python, look the condition up by its name:
import flyte
from flyte.remote import Condition
flyte.init_from_config()
Condition.get("approve-round-3", run_name="<run name>").signal(True)From the CLI, a condition is signaled by its action name, a generated ID. flyte get condition
lists the conditions of a run with their action names:
flyte get condition <run-name>
flyte signal condition <run-name> <action-name> trueThe image and environments
One image serves every task: RDKit for molecules, tblite and ASE for GFN2-xTB, scikit-learn
for the surrogate, and unionai-reuse, which reusable environments need. The oracle runs on a
pool of 2-CPU replicas that grows to 12 while a round is in flight. Each call measures one
molecule, so a slow molecule holds up one slot, not a batch. The surrogate gets a single warm
replica with 4 CPUs.
# RDKit for molecules (its drawing code needs the X rendering libraries), tblite and ASE for
# GFN2-xTB, scikit-learn for the surrogate, and unionai-reuse for the reusable environments.
image = (
flyte.Image.from_debian_base(name="closed-loop-discovery", python_version=(3, 12))
.with_apt_packages("libxrender1", "libxext6", "libexpat1")
.with_pip_packages("rdkit==2026.9.1", "tblite==0.8.0", "ase==3.29.0", "scikit-learn==1.9.1", "numpy==2.5.4",
"unionai-reuse==0.2.1")
)
CODE = ["oracle.py", "surrogate.py", "reports.py", "viz.py"]
# The oracle: a warm pool of small CPU pods, one molecule per call. Every call is cached on
# its SMILES string, so a molecule is measured once, ever, whichever arm or run asks for it.
# "auto" caching hashes the task's own source, not the modules it imports, so the tasks that
# use oracle.py and surrogate.py add those modules' versions to their cache keys (see below).
oracle_env = flyte.TaskEnvironment(
name="discovery-oracle",
image=image,
resources=flyte.Resources(cpu=2, memory="4Gi"),
reusable=flyte.ReusePolicy(replicas=(2, 12), concurrency=1, idle_ttl=600, scaledown_ttl=120),
env_vars={"OMP_NUM_THREADS": "2", "OMP_STACKSIZE": "1G"},
include=CODE,
)
# The surrogate retrains every round on a single warm replica, which keeps its imports and
# the downloaded pool between rounds.
model_env = flyte.TaskEnvironment(
name="discovery-model",
image=image,
resources=flyte.Resources(cpu=4, memory="8Gi"),
reusable=flyte.ReusePolicy(replicas=1, concurrency=1, idle_ttl=900),
include=CODE,
)
driver_env = flyte.TaskEnvironment(
name="discovery-driver",
image=image,
resources=flyte.Resources(cpu=2, memory="4Gi"),
include=CODE,
depends_on=[oracle_env, model_env],
)
The pool
The pool is a random sample of ZINC250k. GFN2-xTB in the gas phase gives ions and zwitterions gaps that are far too small, so the pool keeps neutral molecules: it neutralizes what it can, and drops any charged atom that is not part of a nitro group. The fingerprints are computed once here and shipped with the pool, so the surrogate never recomputes them.
@model_env.task(cache="auto")
async def build_pool(pool_size: int = 20_000, seed: int = 0) -> File:
"""A random sample of neutral ZINC250k molecules, with their Morgan fingerprints."""
import csv
import io
import urllib.request
import numpy as np
from rdkit import Chem, RDLogger
from rdkit.Chem.MolStandardize import rdMolStandardize
from surrogate import fingerprints
RDLogger.DisableLog("rdApp.*")
text = urllib.request.urlopen(ZINC_URL, timeout=120).read().decode()
rows = [r["smiles"].strip() for r in csv.DictReader(io.StringIO(text))]
random.Random(seed).shuffle(rows)
# Gas-phase GFN2-xTB gives zwitterions and ions unphysically small gaps, so the pool keeps
# neutral molecules only: neutralize what can be, and drop charged atoms other than nitro groups.
uncharger, nitro = rdMolStandardize.Uncharger(), Chem.MolFromSmarts("[N+](=O)[O-]")
pool, seen = [], set()
for s in rows:
mol = Chem.MolFromSmiles(s)
if mol is None:
continue
mol = uncharger.uncharge(mol)
charged = sum(1 for a in mol.GetAtoms() if a.GetFormalCharge())
if Chem.GetFormalCharge(mol) != 0 or charged > 2 * len(mol.GetSubstructMatches(nitro)):
continue
smiles = Chem.MolToSmiles(mol)
if smiles not in seen:
seen.add(smiles)
pool.append(smiles)
if len(pool) == pool_size:
break
path = f"{tempfile.mkdtemp()}/pool.npz"
np.savez_compressed(path, smiles=np.array(pool), fps=fingerprints(pool))
print(f"pool: {len(pool)} molecules from {len(rows)} in ZINC250k")
return await File.from_local(path)
The oracle
measure_gap is plain Python with no Flyte in it. Each attempt embeds conformers, relaxes
them, optimizes the best ones with GFN2-xTB, checks that no bond broke during the
optimization, and reads the orbital energies. A failed attempt is retried with random-coordinate
embedding and a higher electronic temperature, which helps a stubborn SCF converge.
def _attempt(mol, numbers, strategy: str, temperature: float, n_conf: int, keep: int, seed: int) -> dict:
import numpy as np
from ase import Atoms
from ase.optimize import BFGS
from rdkit.Chem import AllChem
from tblite.ase import TBLite
from tblite.interface import Calculator
conf_ids = _embed(mol, strategy, n_conf, seed)
if not conf_ids:
raise RuntimeError("conformer embedding failed")
if AllChem.MMFFHasAllMoleculeParams(mol):
mm = AllChem.MMFFOptimizeMoleculeConfs(mol, maxIters=2000)
else:
mm = AllChem.UFFOptimizeMoleculeConfs(mol, maxIters=2000)
ranked = sorted(range(len(conf_ids)), key=lambda k: mm[k][1])[:keep]
best = None
for k in ranked:
atoms = Atoms(numbers=numbers, positions=mol.GetConformer(conf_ids[k]).GetPositions())
atoms.calc = TBLite(method="GFN2-xTB", electronic_temperature=temperature, max_iterations=250, verbosity=0)
BFGS(atoms, logfile=None).run(fmax=0.05, steps=300)
energy = atoms.get_potential_energy()
if best is None or energy < best[0]:
best = (energy, atoms.get_positions().copy())
if not _bonds_intact(mol, best[1]):
raise RuntimeError("a bond broke during the xTB optimization")
calc = Calculator("GFN2-xTB", np.array(numbers), best[1] / BOHR)
calc.set("verbosity", 0)
calc.set("temperature", temperature * 3.166811563e-6) # K to Hartree
res = calc.singlepoint()
eps, occ = res.get("orbital-energies"), res.get("orbital-occupations")
n_occ = int(round(float(occ.sum()) / 2))
homo, lumo = float(eps[n_occ - 1]) * HARTREE_TO_EV, float(eps[n_occ]) * HARTREE_TO_EV
return {"gap": lumo - homo, "homo": homo, "lumo": lumo, "energy": float(res.get("energy"))}
def measure_gap(smiles: str, n_conf: int = 8, keep: int = 3, max_atoms: int = 90) -> Measurement:
"""Compute the GFN2-xTB HOMO-LUMO gap of one molecule, retrying failed attempts."""
from rdkit import Chem, RDLogger
RDLogger.DisableLog("rdApp.*")
started = time.time()
out = Measurement(smiles=smiles)
mol = Chem.MolFromSmiles(smiles)
if mol is None:
out.error = "invalid SMILES"
return out
mol = Chem.AddHs(mol)
out.atoms = mol.GetNumAtoms()
if out.atoms > max_atoms:
out.error = f"too large ({out.atoms} atoms)"
return out
numbers = [a.GetAtomicNum() for a in mol.GetAtoms()]
errors = []
for i, (strategy, temperature) in enumerate(ATTEMPTS):
out.attempts = i + 1
try:
result = _attempt(Chem.Mol(mol), numbers, strategy, temperature, n_conf, keep, seed=0xF00D + i)
except Exception as e: # SCF not converged, embedding failed, broken geometry, ...
errors.append(f"attempt {i + 1}: {str(e).strip().splitlines()[0][:120] if str(e).strip() else type(e).__name__}")
continue
out.ok, out.error = True, "; ".join(errors)
out.gap, out.homo, out.lumo, out.energy = result["gap"], result["homo"], result["lumo"], result["energy"]
break
else:
out.error = "; ".join(errors)
out.seconds = round(time.time() - started, 2)
return out
The task around it is short. The calculation runs in a thread so the replica stays responsive.
A molecule that fails every attempt comes back as a Measurement with ok=False and the
reasons, so it counts against the budget like a failed experiment and never fails the round.
Task-level retries=2 are for the infrastructure: a replica that is preempted mid-calculation.
The task’s cache keys every result on the SMILES string, and the result records the run that
computed it, so the report can count how many measurements came from earlier runs. "auto"
caching hashes the task’s own source, not the modules it imports, so a change to oracle.py
would go unnoticed. The cache is salted with ORACLE_VERSION from oracle.py; bump it when you
change the protocol and every molecule is measured again. propose is salted the same way with
SURROGATE_VERSION from surrogate.py.
@oracle_env.task(cache=flyte.Cache("auto", salt=ORACLE_VERSION), retries=2)
async def measure(smiles: str) -> Measurement:
"""Measure one molecule's HOMO-LUMO gap with GFN2-xTB. A failed calculation is a result, not an error."""
from oracle import measure_gap
m = await asyncio.to_thread(measure_gap, smiles)
m.measured_in = flyte.ctx().action.run_name or ""
print(f"{smiles}: {'%.3f eV' % m.gap if m.ok else 'FAILED'} after {m.attempts} attempt(s) in {m.seconds:.1f}s {m.error}")
return m
The surrogate and the acquisition function
The surrogate is a bootstrap ensemble of 8 MLPs on 2048-bit Morgan fingerprints. Each member
sees a resample of the measured molecules, so the members disagree where data is thin, and that
spread is the model’s uncertainty. Each round, every molecule in the pool gets a lower confidence
bound on its gap, mean - beta * std, and the batch is filled greedily from the best bound
down, skipping molecules too similar to one already picked.
def fit_ensemble(X: np.ndarray, y: np.ndarray, members: int = 8, seed: int = 0) -> list:
import numpy as np
from sklearn.neural_network import MLPRegressor
rng = np.random.default_rng(seed)
models = []
for m in range(members):
idx = rng.choice(len(y), len(y), replace=True)
net = MLPRegressor(hidden_layer_sizes=(256, 64), alpha=1e-3, learning_rate_init=1e-3,
max_iter=300, random_state=seed * 100 + m)
models.append(net.fit(X[idx], y[idx]))
return models
def predict(models: list, X: np.ndarray, chunk: int = 4096) -> tuple[np.ndarray, np.ndarray]:
import numpy as np
preds = np.stack([np.concatenate([m.predict(X[i:i + chunk]) for i in range(0, len(X), chunk)]) for m in models])
return preds.mean(0), preds.std(0)
def tanimoto(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Tanimoto similarity of one packed fingerprint ``a`` to each row of ``b``."""
import numpy as np
inter = np.unpackbits(a & b, axis=-1).sum(-1)
union = np.unpackbits(a | b, axis=-1).sum(-1)
return inter / np.maximum(union, 1)
def select_batch(score: np.ndarray, packed: np.ndarray, exclude: set[int], k: int, max_similarity: float) -> list[int]:
"""Greedy: best score first, skipping excluded molecules and near-duplicates of earlier picks."""
import numpy as np
picked: list[int] = []
for i in np.argsort(score): # lowest score (best) first
i = int(i)
if i in exclude:
continue
if picked and tanimoto(packed[i], packed[picked]).max() > max_similarity:
continue
picked.append(i)
if len(picked) == k:
break
return picked
propose retrains the ensemble on every successful measurement so far and scores the whole
pool. It also predicts the random control batch, which gives an unbiased, prospective test of
the model every round.
@model_env.task(cache=flyte.Cache("auto", salt=SURROGATE_VERSION))
async def propose(
round_num: int, pool: File, train_smiles: list[str], train_gaps: list[float], exclude: list[str],
control: list[str], batch_size: int, beta: float = 1.0, max_similarity: float = 0.6,
) -> Proposal:
"""Retrain the ensemble on everything measured so far and pick the next batch by a lower confidence bound."""
import numpy as np
import surrogate
started = time.time()
smiles, packed = await load_pool(pool)
index = {s: i for i, s in enumerate(smiles)}
X = surrogate.unpack(packed)
models = surrogate.fit_ensemble(X[[index[s] for s in train_smiles]], np.array(train_gaps), seed=round_num)
mean, std = surrogate.predict(models, X)
score = mean - beta * std # small predicted gap, or high uncertainty
chosen = surrogate.select_batch(score, packed, {index[s] for s in exclude}, batch_size, max_similarity)
def pick(i: int) -> Pick:
return Pick(smiles[i], float(mean[i]), float(std[i]), float(score[i]))
print(f"round {round_num}: trained on {len(train_smiles)}, batch predicted {mean[chosen].mean():.2f} eV")
return Proposal(round_num, [pick(i) for i in chosen], [pick(index[s]) for s in control], len(train_smiles),
float(np.median(mean)), round_s(time.time() - started))
The approval gate
Before a batch goes to the oracle, the loop asks for approval. flyte.new_condition registers a
condition on the run with a Markdown prompt that summarizes the batch: what the model predicts,
how wrong it was last round, and the top 10 molecules. The run waits until someone signals the
condition, from the UI, the CLI, or the API. A rejected batch is skipped, and its molecules are
never proposed again.
A loop that runs overnight must not stall on a question nobody is awake to answer, so the
condition has a timeout. If it expires, wait() raises ConditionTimedoutError and the batch is
approved automatically. Set approval_timeout_s=0 to turn the gate off.
async def approval(round_num: int, proposal: Proposal, state: LoopState, timeout_s: int) -> tuple[str, float]:
"""Ask a scientist to approve the batch before it goes to the lab. Silence counts as approval."""
if timeout_s <= 0:
return "no gate", 0.0
started = time.time()
condition = await flyte.new_condition.aio(
f"approve-round-{round_num}",
prompt=reports.approval_prompt(proposal, state, timeout_s),
prompt_type="markdown",
data_type=bool,
description=f"Send round {round_num}'s {len(proposal.batch)} molecules to the oracle?",
timeout=timeout_s,
)
try:
approved = await condition.wait.aio()
decision = "approved" if approved else "rejected"
except flyte.errors.ConditionTimedoutError:
decision = "auto-approved" # nobody answered: an unattended run keeps going
return decision, round_s(time.time() - started)
One round, durably
A round picks the random control batch, asks for a proposal, waits for approval, and measures both batches at once on the oracle pool. Round 0 is the seed library, random molecules shared by both arms, because the model needs data before it can propose anything.
The loop state is a few dataclasses: a Pick is one proposed molecule with its prediction, a
Proposal is one round’s two batches, a RoundLog is what happened in a round, and LoopState
is every round so far plus the molecules a scientist vetoed.
@dataclass
class Pick:
smiles: str
mean: float # predicted gap, eV
std: float # ensemble spread, eV
score: float # the acquisition score, mean - beta * std
@dataclass
class Proposal:
round: int
batch: list[Pick] # the active learning batch
control: list[Pick] # the random control batch, with the same model's predictions
train_size: int
pool_median: float # median predicted gap over the whole pool
fit_seconds: float
@dataclass
class RoundLog:
round: int
decision: str # "seed library", "approved", "rejected", "auto-approved", "no gate"
gate_seconds: float = 0.0
proposal: Proposal | None = None
active: list[Measurement] = field(default_factory=list)
control: list[Measurement] = field(default_factory=list)
seconds: float = 0.0
@dataclass
class LoopState:
rounds: list[RoundLog] = field(default_factory=list)
vetoed: list[str] = field(default_factory=list) # molecules a scientist rejected
def measured(self, arm: str) -> list[Measurement]:
"""Every measurement of one arm ("active" or "control") so far."""
return [m for r in self.rounds for m in getattr(r, arm)]
run_round takes the loop state and returns the next one, and it is cached. If the driver
crashes in round 7, its retry calls run_round for rounds 0 to 6 with the same inputs, gets the
cached states back in seconds, and carries on. A finished round, approval included, is never run
or asked about twice.
That cuts both ways. The approval decision is part of the cached output, so a later run with the
same inputs replays every round, and every past approval, without asking anyone. That is what you
want when a driver crashes, and not what you want when you start a new campaign on the same pool.
run_round takes a campaign name as an input for that reason: launch with a new one, for
example flyte.run(discover, campaign="zinc-small-gap-2"), and every round is proposed, approved,
and recorded afresh, while molecules measured before still come from the oracle’s cache.
@driver_env.task(cache=flyte.Cache("auto", salt=f"{ORACLE_VERSION}+{SURROGATE_VERSION}"), retries=1)
async def run_round(
round_num: int, state: LoopState, pool: File, batch_size: int, beta: float, max_similarity: float,
approval_timeout_s: int, seed: int, campaign: str,
) -> LoopState:
"""One turn of the loop. The output is the whole state, so a finished round is never run twice.
The state includes the approval decision, so a cached round is not asked about again. The
campaign name is part of the cache key: a run with a new campaign asks for approval afresh.
"""
started = time.time()
smiles, _ = await load_pool(pool)
rng = random.Random(seed * 1_000 + round_num)
if round_num == 0:
# The seed library: random molecules, measured once and shared by both arms.
seed_batch = rng.sample(smiles, batch_size)
results = list(await asyncio.gather(*[measure(s) for s in seed_batch]))
log = RoundLog(0, "seed library", active=results, control=results)
return replace(state, rounds=[replace(log, seconds=round_s(time.time() - started))])
active, control = state.measured("active"), state.measured("control")
seen_control = {m.smiles for m in control}
control_batch = rng.sample([s for s in smiles if s not in seen_control], batch_size)
train = [m for m in active if m.ok]
proposal = await propose(
round_num, pool, [m.smiles for m in train], [m.gap for m in train],
exclude=[m.smiles for m in active] + state.vetoed, control=control_batch,
batch_size=batch_size, beta=beta, max_similarity=max_similarity,
)
decision, waited = await approval(round_num, proposal, state, approval_timeout_s)
log = RoundLog(round_num, decision, waited, proposal)
if decision == "rejected":
state = replace(state, vetoed=state.vetoed + [p.smiles for p in proposal.batch])
else:
# Both arms go to the oracle at once. A molecule in both is computed once: the second
# call is a cache hit.
batch = [p.smiles for p in proposal.batch]
results = await asyncio.gather(*[measure(s) for s in batch + control_batch])
log.active, log.control = list(results[: len(batch)]), list(results[len(batch):])
log.seconds = round_s(time.time() - started)
return replace(state, rounds=state.rounds + [log])
The driver
The driver builds the pool, runs the rounds, and refreshes the report after each one. At the end it writes the final report and its tabs, and publishes the 50 smallest-gap molecules as a CSV with a card.
@driver_env.task(report=True, produces_artifacts=True, retries=2)
async def discover(
rounds: int = 10,
batch_size: int = 64,
pool_size: int = 20_000,
beta: float = 1.0,
max_similarity: float = 0.6,
approval_timeout_s: int = 180,
hit_gap: float = 1.0,
seed: int = 0,
campaign: str = "zinc-small-gap",
) -> File:
"""Run the closed loop and the random control, report both, and publish the best molecules.
Rounds are cached, approvals included: rerunning with the same inputs replays the campaign
without asking anyone. Pass a new ``campaign`` name to start over and be asked again.
"""
pool = await build_pool(pool_size, seed)
state = LoopState()
for r in range(rounds + 1):
with flyte.group(f"round-{r}"):
state = await run_round(
r, state, pool, batch_size, beta, max_similarity, approval_timeout_s, seed, campaign)
await flyte.report.replace.aio(reports.live(state, rounds, hit_gap, pool_size), do_flush=True)
top = reports.top_molecules(state, 50)
# The rounds' own durations: a resumed driver replays cached rounds in seconds, and this
# still reports how long the loop took when it ran.
loop_seconds = sum(r.seconds for r in state.rounds)
await flyte.report.replace.aio(reports.final(state, hit_gap, pool_size, top, loop_seconds))
flyte.report.get_tab("Surrogate").replace(reports.surrogate_tab(state))
flyte.report.get_tab("Batches").replace(reports.batches_tab(state))
flyte.report.get_tab("Oracle").replace(reports.oracle_tab(state, flyte.ctx().action.run_name or ""))
await flyte.report.flush.aio()
path = f"{tempfile.mkdtemp()}/top_candidates.csv"
reports.write_csv(top, path)
summary = reports.summary(state, hit_gap)
card = await artifacts.Card.create_from.aio(content=reports.card(state, top, hit_gap), format="html", card_type="data")
metadata = artifacts.Metadata(
name="closed-loop-top-candidates",
description=f"The {len(top)} smallest-gap molecules found in {summary['calls']} GFN2-xTB calculations "
f"over {pool_size:,} ZINC molecules; best {top[0]['gap_ev']:.2f} eV",
kind="data",
card=card,
attrs={k: str(v) for k, v in summary.items()} | {"objective": "minimize GFN2-xTB HOMO-LUMO gap"},
)
return artifacts.new(await File.from_local(path), metadata)
Results
A run with the defaults took 48 minutes: 10 rounds after the seed library, 64 molecules per arm per round, 704 oracle calls per arm, and 1,328 distinct molecules in all (16 molecules were picked by both arms in the same round and computed once). Each GFN2-xTB measurement took 10 seconds at the median and 58 seconds at the most. 22 of the measurements were cache hits from an earlier smoke run, which happened to sample the same molecules, and were never recomputed.
| Active learning | Random search | |
|---|---|---|
| Oracle calls | 704 | 704 |
| Molecules found below 1 eV | 31 | 4 |
| Smallest gap found | 0.32 eV | 0.82 eV |
| Mean gap of a batch, rounds 4 to 10 | about 2.0 eV | about 2.9 eV |
Both arms start from the same seed library, which held one molecule below 1 eV. Random search found 3 more in 640 calls, the rate you would expect when fewer than 1 molecule in 100 qualifies. Active learning found 30 more with the same budget. Its first two batches were no better than random, and the second was worse, with a mean gap of 3.5 eV: trained on 128 molecules, the model’s uncertainty dominated the bound and it explored. From round 4 on, every batch averaged about 2 eV, and the count of small-gap molecules climbed in almost every round.
The Surrogate tab is the honest part of the report. On the random control, which the model never chose, its error stayed between 0.7 and 0.9 eV and its rank correlation between 0.2 and 0.45: it is a poor predictor of the gap of an average molecule. On its own batches, its error fell from 1.8 eV to under 0.5 eV, because those batches are where it has learned the most. That is enough to rank the small-gap tail, which is all the loop needs. The ensemble is overconfident: at round 10, 33% of control molecules fell inside one predicted standard deviation and 53% inside two, against 68% and 95% for a calibrated model. Bootstrap ensembles of small networks are known to do this. If you need calibrated uncertainty, rescale the standard deviation with the control’s residuals each round; the control set gives you the data for it for free.
The best molecule, an (E)-3-(4-nitrophenyl)acryloyl thiourea, was found in round 7. The model predicted 1.82 ± 0.16 eV for it, well inside the batch but far from the 0.32 eV it measured. Most of the top 12 are nitroaromatics with an electron donor on the other side of a conjugated path. These are real push-pull chromophores, and GFN2-xTB also underestimates their gaps, so they are what you would send to a DFT calculation next, not a final answer.
No calculation in the run failed, and none needed a retry. Failures are rare for neutral drug-like molecules at this level of theory. In local testing, one small-gap molecule broke a bond during the xTB optimization on its first two attempts and was measured on the third, with random coordinates and a higher electronic temperature. The handling is there for the molecules and oracles where failures are common.
The approval gate in practice
Every round opened a condition named approve-round-N on the run, which pauses the round and
shows the Markdown prompt in the UI’s signal form. Round 1 was approved with the
signal_condition tool of the Flyte MCP server, and the others with Condition.signal(True) from a
script, 20 to 30 seconds after they opened. Round 7 was left alone: its condition timed out after
180 seconds, the driver caught ConditionTimedoutError, and the round went ahead as auto-approved.
The Oracle tab records each decision and how long it waited.
Resuming a finished loop
Launching the same command again produced a new run that finished in 16 seconds. Every
run_round call was a cache hit, so none of the 11 rounds ran again, no condition was opened,
and the driver rebuilt the report and republished the artifact from the cached states. A driver
that crashes partway through resumes the same way, from the last round that finished. Because
the run used the same campaign, it also reused all 10 approvals from the first run.
The 50 smallest-gap molecules are published as the closed-loop-top-candidates artifact, a CSV
with the measured orbital energies, which arm found each molecule, and what the model predicted
before it was measured. Its card has the same charts and the top molecules.
What we changed along the way
- The pool. The first pool kept ZINC’s charged molecules. In the gas phase, GFN2-xTB gives zwitterions such as amino acids gaps near 0.1 eV, and those artifacts filled the top of the ranking. The pool now neutralizes molecules and keeps only nitro groups as charged atoms.
- The oracle. A single-point calculation on a force-field geometry takes 20 milliseconds, which is a table lookup in all but name. Conformer search and an xTB geometry optimization made each measurement 10 seconds of real compute, and made the gaps depend on a converged structure.
- The drawings. RDKit’s drawing code loads
libXrenderandlibexpat, which the slim Debian base image leaves out. Without them the report fell back to SMILES strings. The image now installs them withwith_apt_packages.
At real scale
- A real oracle. Swap
measure_gapfor a DFT calculation, a docking run, or a call to a lab scheduler. A DFT oracle needs bigger replicas and longer timeouts; nothing else in the loop changes. - A bigger library. Enamine REAL has billions of molecules. Score the pool in shards with a
fan-out of
proposetasks, and keep the fingerprints in aflyte.io.DataFrame. - A better surrogate. Replace the MLP ensemble with a graph neural network ensemble on a GPU.
proposeis the only task that changes. - Multi-fidelity. Send cheap GFN2-xTB results through the loop, and let the approval gate forward the best few to DFT, the way a lab triages before synthesis.