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 minutes

The 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> true

The 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.

discovery.py
# 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.

discovery.py
@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.

oracle.py
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.

discovery.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.

surrogate.py
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.

discovery.py
@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.

discovery.py
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.

discovery.py
@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.

discovery.py
@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.

discovery.py
@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 discovery report: molecules below 1 eV for each arm, the best gap found against oracle calls, the mean gap of each batch, and the top molecules

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 Surrogate tab: error per round on the model’s own batch and on the random control, the calibration curve, predicted against measured, and the per-round table

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 libXrender and libexpat, which the slim Debian base image leaves out. Without them the report fell back to SMILES strings. The image now installs them with with_apt_packages.

At real scale

  • A real oracle. Swap measure_gap for 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 propose tasks, and keep the fingerprints in a flyte.io.DataFrame.
  • A better surrogate. Replace the MLP ensemble with a graph neural network ensemble on a GPU. propose is 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.