Designing a glucagon mRNA for HepG2 with Proto
Here at Therna we build programmable RNA therapeutics. We treat RNA as a language: something you can read, score, and rewrite. This post walks through designing a real mRNA end to end, and about the layer that makes composing models this way easy.
Disclaimer: This walkthrough is provided for illustrative and educational purposes. Everything below deliberately runs on publicly available models, including Evo 2, PARADE, EnCodon, and ViennaRNA, so the entire workflow is reproducible by anyone who wants to run it. The construct is a computational example based on model predictions and has not been experimentally validated or developed as a therapeutic candidate.
At Therna, we apply the same primitives against our proprietary models trained on our own experimental data. Those models and data are not public. What we are sharing is the approach; the models, sequences, and results shown here do not reflect Therna’s production design platform.
An mRNA is not one design problem
Take glucagon. The GCG gene encodes a 180-amino-acid preproglucagon, and if you want to express it well in a particular tissue, say hepatocytes, you are really answering three questions at once. What 5’UTR drives strong translation in that cell type? What coding sequence does the ribosome prefer? What 3’UTR keeps the message stable? Each question is answered best by a different model, and the design you want is the one that satisfies all of them together.
There are already publicly available models that can do a good job of answering these questions. Evo 2, from Arc Institute, reads DNA at genome scale and can generate regulatory sequences conditioned on real context. PARADE, also from Arc, scores UTR activity across a panel of human cell lines. EnCodon, NVIDIA’s codon language model, has a learned, zero-shot opinion about what a well-formed coding sequence looks like. ViennaRNA computes folding energy from nearest-neighbor thermodynamics. Each is strong on its own axis. The question is how to bring them together under one design language when each has its own dependencies, interfaces, and conventions.
The usual ways, and where they stop
Classic codon optimization maximizes a single number, the codon adaptation index, against a fixed usage table. It is context-free, it says nothing about the UTRs, and it certainly cannot tune a sequence for one cell type over another. Vendor black boxes hand you a sequence and no readout of why. And the moment you try to combine several real models, each ships its own repository, CUDA pin, checkpoint format, and input convention. Getting a 7B genome model and a codon language model and a UTR activity model into one notebook is a week of dependency archaeology before any biology happens.
Proto provides the orchestration layer
Enter Proto. Proto is a programming language for generative biology. It is an open-source project from Brian Hie’s lab at Arc Institute and Stanford, and it makes one bet: biological design has the same shape as a program. You have objects, functions that score them, functions that propose them, and a search that ties the two together. Proto names those four things as primitives and lets you compose them, so designing a protein, a genome, or an mRNA becomes writing a short program rather than wiring bespoke scripts around each model. We were early adopters of the concepts behind Proto at Therna, and now that it is publicly released, I wanted to share some of my thoughts about the importance of adopting this design language.

The four primitives mirror how nature and the lab already work. A Sequence (x) is the object you design: DNA, RNA, or protein. A Constraint p(y|x) is a scoring function, a property you want the sequence to have; in nature it is resource competition, in the lab an assay, in Proto a model such as AlphaFold, Prodigal, a GC-content rule, or a binding or activity predictor. A Generator p(x) proposes sequences: nature’s random mutation, the lab’s random libraries, and in Proto a generative model such as Evo 2, ProteinMPNN, or uniform sampling. An Optimizer p(x|y) searches toward the constraints: nature’s evolution by selection, the lab’s directed evolution, and in Proto MCMC or gradient descent.
You write a design by connecting generators to the sequences they sample and constraints to the sequences they score, then letting an optimizer iterate: the generator produces a sequence, the constraint scores it, and the optimizer guides the next proposal until the energy settles. The same program is reachable from a graphical interface, a software API, or an AI agent, so the loop is identical whether a person or a model is driving it. It is the shift from traditional biological programming, rule-based heuristics and trial-and-error over parts pulled from nature, to generative biological programming, where you state high-level constraints like symmetry, globularity, or stability and let generative models compile them into sequence.
Proto Tools
proto_tools is the open-source implementation of these primitives, and every model wired into it is public: Evo 2, ProteinMPNN, AlphaFold, ViennaRNA, and NVIDIA’s EnCodon among them. At Therna, we use the same primitives with our proprietary models. For this demonstration, I will use only publicly available models.
Every model is a tool with the same shape: an Input, a Config, an Output, and a run_ function. Each runs in its own isolated environment, so the dependency conflicts that used to make this painful simply do not occur. Checkpoints resolve on first use, and a persistent worker keeps a model warm across a loop so you are not reloading it every iteration.
In practice the whole stack is one import:
from proto_tools import (
run_evo2_sample,
run_evo2_score, # Evo 2, genome-scale DNA model
run_parade_activity, # PARADE, UTR activity
run_codonfm_score,
run_codonfm_fitness, # EnCodon, codon language model
run_viennarna, # ViennaRNA, RNA folding
)
The recipe is the same at every stage: Evo2 proposes, PARADE (or EnCodon) selects. We carry 20 candidate constructs the whole way through and keep the best.
Setting up the environment:
import logging
import os
os.environ["PROTO_NO_SPINNER"] = "1"
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True" # ease Evo2 long-context prefill
logging.getLogger("proto_tools.utils.progress").setLevel(logging.WARNING)
DEVICE = "cuda"
PARADE_DEVICE = "cpu" # small LegNet; matches GPU exactly and frees the GPU for Evo2
EnCodon = "encodon_80m"
EVO2 = "evo2_7b"
HEPG2 = "c2" # PARADE cell code for HepG2
N_CAND = 20 # candidate lineages
N5, UTR5_LEN = 50, 50 # 5'UTR: tries per candidate, length
T5, K5, BS5 = 1.0, 4, 100 # 5'UTR Evo2 sampling (short context -> naturally diverse)
BLOCKS = [50, 50, 50, 50, 40] # 3'UTR grown in 50 nt blocks to 240 nt
N3 = 50 # 3'UTR tries per candidate per block
T3, K3, BS3 = 1.5, 32, 8 # 3'UTR Evo2 sampling (long context -> raise temp/top_k for diversity)
CELL_NAMES = {"c2": "HepG2"} # HepG2 is c2; add the rest here to relabel the panels
import numpy as np
from proto_tools import (
CodonFMFitnessInput, CodonFMFitnessConfig, run_codonfm_fitness,
CodonFMScoreInput, CodonFMScoreConfig, CodonFMMutation, run_codonfm_score,
Evo2SampleInput, Evo2SampleConfig, run_evo2_sample,
Evo2ScoringInput, Evo2ScoringConfig, run_evo2_score,
ParadeActivityInput, ParadeActivityConfig, run_parade_activity,
ParadeStabilityInput, ParadeStabilityConfig, run_parade_stability,
ViennaRNAInput, ViennaRNAConfig, run_viennarna,
)
from proto_tools.utils.tool_instance import ToolInstance
print("proto tools ready: Evo2 (sample + score), EnCodon, PARADE (activity + stability), ViennaRNA")
The target — human glucagon, RefSeq NM_002054
The reference GCG sequence is split into its components below. For a length-matched comparison, we use the final 50 nt of the native 99-nt 5′UTR. The 50 bp genomic sequence immediately upstream of the transcript, which contains the TATA box, is used only as context for Evo 2 and is not part of the resulting mRNA.
from itertools import product
_BASES = "TCAG"
_AAS = "FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG"
CODE = {a + b + c: aa for (a, b, c), aa in zip(product(_BASES, repeat=3), _AAS)}
SYNONYMS = {}
for _c, _aa in CODE.items():
SYNONYMS.setdefault(_aa, []).append(_c)
def translate(cds): return "".join(CODE[cds[i : i + 3]] for i in range(0, len(cds), 3))
PROMOTER_50 = "CTAAACAGAGCTGGAGAGTATATAAAAGCAGTGCGCCTTGGTGCAGAAGT"
NAT5 = "ACAGAGCTTAGGACACAGAGCACATCAAAAGTTCCCAAAGAGGGCTTGCTCTCTCTTCACCTGCTCTGTTCTACAGCACACTACCAGAAGACAGCAGAA"
NAT5_50 = NAT5[-50:]
CDS = "ATGAAAAGCATTTACTTTGTGGCTGGATTATTTGTAATGCTGGTACAAGGCAGCTGGCAACGTTCCCTTCAAGACACAGAGGAGAAATCCAGATCATTCTCAGCTTCCCAGGCAGACCCACTCAGTGATCCTGATCAGATGAACGAGGACAAGCGCCATTCACAGGGCACATTCACCAGTGACTACAGCAAGTATCTGGACTCCAGGCGTGCCCAAGATTTTGTGCAGTGGTTGATGAATACCAAGAGGAACAGGAATAACATTGCCAAACGTCACGATGAATTTGAGAGACATGCTGAAGGGACCTTTACCAGTGATGTAAGTTCTTATTTGGAAGGCCAAGCTGCCAAGGAATTCATTGCTTGGCTGGTGAAAGGCCGAGGAAGGCGAGATTTCCCAGAAGAGGTCGCCATTGTTGAAGAACTTGGCCGCAGACATGCTGATGGTTCTTTCTCTGATGAGATGAACACCATTCTTGATAATCTTGCCGCCAGGGACTTTATAAACTGGTTGATTCAGACCAAAATCACTGACAGGAAATAA"
UTR3 = "CTATATCACTATTCAAGATCATCTTCACAACATCACCTGCTAGCCACGTGGGATGTTTGAAATGTTAAGTCCTGTAAATTTAAGAGGTGTATTCTGAGGCCACATTGCTTTGCATGCCAATAAATAAATTTTCTTTTAGTGTTGTGTAGCCAAAAATTACAAATGGAATAAAGTTTTATCAAAATATTGCTAAAATATCAGCTTTAAAATATGAAAGTGCTAGATTCTGTTATTTTCTTC"
PROTEIN = translate(CDS)
print(f"promoter {len(PROMOTER_50)} bp | natural 5'UTR {len(NAT5)} nt (use {len(NAT5_50)}) | "
f"CDS {len(CDS)} nt = {len(CDS)//3} codons | natural 3'UTR {len(UTR3)} nt")
print(f"preproglucagon ({len(PROTEIN)} aa): {PROTEIN}")
Every model, one interface
Each tool available and returning its primary metric on the real GCG parts. The first Evo2 call loads the 7B checkpoint; later calls reuse the warm worker.
rows = []
e = run_evo2_score(Evo2ScoringInput(sequences=[CDS[:60]]), Evo2ScoringConfig(model_checkpoint=EVO2, device=DEVICE))
rows.append(("Evo2", EVO2, "perplexity", round(e.scores[0]["perplexity"], 3)))
f = run_codonfm_fitness(CodonFMFitnessInput(sequences=[CDS]), CodonFMFitnessConfig(model_checkpoint=ENCODON, device=DEVICE))
rows.append(("EnCodon", EnCodon, "fitness", round(f.results[0].fitness, 3)))
a = run_parade_activity(ParadeActivityInput(sequences=[NAT5_50]), ParadeActivityConfig(construct_type="utr5", device=PARADE_DEVICE))
rows.append(("PARADE activity", "utr5/c2", "HepG2 activity", round(a.results[0].scores[HEPG2], 3)))
s = run_parade_stability(ParadeStabilityInput(sequences=[UTR3]), ParadeStabilityConfig(device=PARADE_DEVICE))
rows.append(("PARADE stability", "stability", "log_ratio", round(s.results[0].log_ratio, 3)))
v = run_viennarna(ViennaRNAInput(sequences=[CDS]), ViennaRNAConfig(temperature=37.0))
rows.append(("ViennaRNA", "-", "MFE (kcal/mol)", round(v.results[0].mfe, 3)))
print(f"{'tool':<18}{'checkpoint':<12}{'metric':<18}{'value':>10}")
for n, c, m, val in rows:
print(f"{n:<18}{c:<12}{m:<18}{val:>10}")
Generation and scoring helpers
Thin wrappers: Evo 2 samples fixed-length continuations, while PARADE scores predicted HepG2 activity for either 5′UTRs or 3′UTRs.
def valid(seq, n):
# Evo2's byte vocab can emit IUPAC ambiguity codes (M, R, N, ...) especially at higher top_k;
# PARADE and the codon logic need strict A/C/G/T at the exact length.
return len(seq) == n and set(seq) <= set("ACGT")
def evo2_generate(prompts, n_new, temperature, top_k, batch_size):
out = run_evo2_sample(
Evo2SampleInput(prompts=list(prompts)),
Evo2SampleConfig(model_checkpoint=EVO2, device=DEVICE, max_new_tokens=n_new,
temperature=temperature, top_k=top_k, prepend_prompt=False,
stop_at_eos=False, batch_size=batch_size))
return out.sequences
def parade_activity_panel(utr5s):
a = run_parade_activity(ParadeActivityInput(sequences=list(utr5s)),
ParadeActivityConfig(construct_type="utr5", device=PARADE_DEVICE, batch_size=128))
cells = a.cell_types
mat = np.array([[r.scores[c] for c in cells] for r in a.results])
return mat, cells
def parade_utr3_panel(utr3s):
# 3'UTR measured exactly like the 5'UTR: PARADE activity, read c2 (HepG2). The activity tool
# returns the mass-center column; construct_type="utr3" selects the 3'UTR model and cell panel.
a = run_parade_activity(ParadeActivityInput(sequences=list(utr3s)),
ParadeActivityConfig(construct_type="utr3", device=PARADE_DEVICE, batch_size=128))
cells = a.cell_types
mat = np.array([[r.scores[c] for c in cells] for r in a.results])
return mat, cells
Step 1: design the 5’UTR by generate-and-select
To optimize GCG to be highly expressed in the liver, we start from the 5’ UTR. We also will start with 20 candidates to increase the chance of success. We use Evo2 as the generator in Proto. Since Evo2 is an autoregressive model, we need to have a sequence already present on the 5’ side to start the generation. Therefore, each of the 20 candidates is seeded with the real 50 base pairs of genomic sequence immediately upstream of the GCG transcript, which carries the promoter’s TATA box, so Evo2 generates in a realistic context. For every candidate, Evo2 generates 50 full 50-nucleotide UTRs, and PARADE scores all of them for HepG2 activity. Each candidate keeps its best.
pool_c2 = []
with ToolInstance.persist():
NAT_PANEL, cells = parade_activity_panel([NAT5_50])
c2_idx = cells.index(HEPG2)
DESIGNED_UTR5, UTR5_PANEL = [], []
for c in range(N_CAND):
seqs = []
while not seqs: # keep only strict-ACGT 50-mers
seqs = [s for s in evo2_generate([PROMOTER_50] * N5, UTR5_LEN, T5, K5, BS5) if valid(s, UTR5_LEN)]
mat, _ = parade_activity_panel(seqs)
c2 = mat[:, c2_idx]; pool_c2.extend(c2.tolist())
j = int(np.argmax(c2))
DESIGNED_UTR5.append(seqs[j]); UTR5_PANEL.append(mat[j])
UTR5_PANEL = np.array(UTR5_PANEL)
pool_c2 = np.array(pool_c2)
sel_c2 = UTR5_PANEL[:, c2_idx]
print(f"generated ~{N_CAND * N5} 5'UTRs, kept the best per candidate ({N_CAND})")
print(f"HepG2 c2: selected best {sel_c2.max():.3f} median {np.median(sel_c2):.3f} "
f"pool median {np.median(pool_c2):.3f} natural {NAT_PANEL[0, c2_idx]:.3f}")
best5 = int(np.argmax(sel_c2))
print(f"top 5'UTR (candidate {best5}): {DESIGNED_UTR5[best5]}")

Step 2: decode the coding sequence by iterative unmasking with EnCodon
For the coding sequence I let EnCodon decode it, once, shared across all candidates. Every one of the 180 codons and the stope codon start provisional, and the sequence is filled in confidence order. Each round, EnCodon scores every synonymous option at every open position in the current context, I commit the most confident quarter to EnCodon’s preferred codon, and then re-score the rest. Only synonymous codons are ever chosen, so the protein is fixed while the codons are rewritten. Because EnCodon is bidirectional, committing one codon changes the scores of its neighbors, which is the point of decoding in rounds rather than one pass.
import math
HUMAN_W = {"AAA": 0.378, "AAC": 1.0, "AAG": 1.0, "AAT": 0.5128, "ACA": 0.3571, "ACC": 1.0, "ACG": 0.1071, "ACT": 0.5714, "AGA": 0.3571, "AGC": 0.8409, "AGG": 0.4524, "AGT": 0.2727, "ATA": 0.0246, "ATC": 1.0, "ATG": 1.0, "ATT": 0.4672, "CAA": 0.0732, "CAC": 1.0, "CAG": 1.0, "CAT": 0.7, "CCA": 0.5185, "CCC": 1.0, "CCG": 0.0741, "CCT": 0.7778, "CGA": 0.4524, "CGC": 1.0, "CGG": 0.5952, "CGT": 0.881, "CTA": 0.1028, "CTC": 0.5607, "CTG": 1.0, "CTT": 0.1308, "GAA": 0.4154, "GAC": 1.0, "GAG": 1.0, "GAT": 0.7447, "GCA": 0.2051, "GCC": 1.0, "GCG": 0.1282, "GCT": 0.8034, "GGA": 0.2857, "GGC": 1.0, "GGG": 0.4107, "GGT": 0.4821, "GTA": 0.1171, "GTC": 0.7207, "GTG": 1.0, "GTT": 0.3333, "TAC": 1.0, "TAT": 0.431, "TCA": 0.2727, "TCC": 1.0, "TCG": 0.1818, "TCT": 0.9545, "TGC": 1.0, "TGG": 1.0, "TGT": 0.4286, "TTA": 0.0187, "TTC": 1.0, "TTG": 0.1963, "TTT": 0.5}
def cai(cds):
lp = []
for i in range(0, len(cds) - 2, 3):
c = cds[i:i+3]; aa = CODE.get(c, "*")
if aa in ("*", "M", "W"): continue
lp.append(math.log(max(HUMAN_W.get(c, 1e-3), 1e-3)))
return math.exp(sum(lp) / len(lp)) if lp else float("nan")
def mfe(s): return run_viennarna(ViennaRNAInput(sequences=[s]), ViennaRNAConfig(temperature=37.0)).results[0].mfe
def encodon_fitness(cds): return run_codonfm_fitness(CodonFMFitnessInput(sequences=[cds]), CodonFMFitnessConfig(model_checkpoint=ENCODON, device=DEVICE)).results[0].fitness
def evo2_ppl(seq): return run_evo2_score(Evo2ScoringInput(sequences=[seq]), Evo2ScoringConfig(model_checkpoint=EVO2, device=DEVICE)).scores[0]["perplexity"]
def measure_cds(cds): return {"EnCodon fitness": encodon_fitness(cds), "CAI": cai(cds), "MFE": mfe(cds)}
def best_synonymous(cds, positions):
cur = [cds[i:i+3] for i in range(0, len(cds), 3)]
muts, meta = [], []
for pos in positions:
c = cur[pos-1]
for alt in SYNONYMS[CODE[c]]:
if alt != c:
muts.append(CodonFMMutation(sequence=cds, codon_position=pos, ref_codon=c, alt_codon=alt)); meta.append((pos, alt))
res = run_codonfm_score(CodonFMScoreInput(mutations=muts),
CodonFMScoreConfig(model_checkpoint=ENCODON, device=DEVICE, batch_size=64)).results
agg = {}
for (pos, alt), r in zip(meta, res):
d = agg.setdefault(pos, {"opts": [(cur[pos-1], r.ref_log_likelihood)]})
d["opts"].append((alt, r.alt_log_likelihood))
out = {}
for pos, d in agg.items():
opts = sorted(set(d["opts"]), key=lambda t: -t[1])
best, second = opts[0][1], (opts[1][1] if len(opts) > 1 else opts[0][1] - 10)
out[pos] = {"best": opts[0][0], "conf": best - second}
return out
codons0 = [CDS[i:i+3] for i in range(0, len(CDS), 3)]
openable = [p for p, c in enumerate(codons0, 1) if CODE[c] != "*" and len(SYNONYMS[CODE[c]]) > 1]
working, locked, cds_history = list(codons0), set(), []
with ToolInstance.persist():
row = measure_cds(CDS); row.update(round=0, locked=0); cds_history.append(row)
print(f"round 0 (natural): fitness {row['EnCodon fitness']:.4f} CAI {row['CAI']:.3f} MFE {row['MFE']:.1f}")
rnd = 0
while len(locked) < len(openable):
rnd += 1
remaining = [p for p in openable if p not in locked]
info = best_synonymous("".join(working), remaining)
for p in sorted(remaining, key=lambda p: -info[p]["conf"])[:max(1, math.ceil(len(remaining) * 0.25))]:
working[p-1] = info[p]["best"]; locked.add(p)
row = measure_cds("".join(working)); row.update(round=rnd, locked=len(locked)); cds_history.append(row)
print(f"round {rnd:>2}: locked {len(locked):>3}/{len(openable)} fitness {row['EnCodon fitness']:.4f} CAI {row['CAI']:.3f} MFE {row['MFE']:.1f}")
DESIGNED_CDS = "".join(working)
assert translate(DESIGNED_CDS) == PROTEIN
with ToolInstance.persist():
EVO2_PPL_NAT, EVO2_PPL_OPT = evo2_ppl(CDS), evo2_ppl(DESIGNED_CDS)
n_changed = sum(a != b for a, b in zip(codons0, working))
print(f"\nEvo2 perplexity natural {EVO2_PPL_NAT:.3f} -> designed {EVO2_PPL_OPT:.3f}")
print(f"{n_changed}/{len(codons0)} codons rewritten over {rnd} rounds; protein identical: {translate(DESIGNED_CDS) == PROTEIN}")

Evo2, though, went the other way. Its perplexity on the coding sequence rose from 1.55 to 1.71, meaning the codon-optimized sequence looks less like natural genomic DNA to the genome-scale model even as it looks better to the codon model. That is not a bug, it is the point. Evo 2 was trained on natural genomic sequences, so the native human CDS is closer to its training distribution than a heavily rewritten synonymous sequence. In this example, improving the EnCodon objective made the CDS less probable under Evo 2. Composing the two models exposes that trade-off instead of optimizing one proxy in isolation.
Step 3: grow the 3’UTR in blocks
The 3’UTR is built 50 nucleotides at a time, out to 240, and scored exactly like the 5’UTR: PARADE activity for HepG2. At each block, for each candidate, Evo2 extends the real promoter + 5’UTR + CDS + 3’-so-far context by 50 nucleotides, 50 tries, and PARADE scores each partial 3’UTR by its HepG2 activity. Each candidate keeps its best extension, then moves to the next block. Nothing is ever padded with filler; the model always continues the complete synthetic construct assembled so far.
cand3, act_trace = ["" for _ in range(N_CAND)], []
# One persist() PER block: the warm Evo2 worker accumulates GPU memory across many long-context
# calls, so we reset it between blocks (a fresh worker each block) to avoid an out-of-memory at
# the longest context. The per-block reload is a few seconds.
for blk in BLOCKS:
with ToolInstance.persist():
best_row = []
for c in range(N_CAND):
ctx = PROMOTER_50 + DESIGNED_UTR5[c] + DESIGNED_CDS + cand3[c]
exts = []
while not exts: # keep only strict-ACGT extensions
exts = [e for e in evo2_generate([ctx] * N3, blk, T3, K3, BS3) if valid(e, blk)]
opts = [cand3[c] + e for e in exts]
mat, cells3 = parade_utr3_panel(opts)
sc = mat[:, cells3.index(HEPG2)] # HepG2 (c2) activity, exactly like the 5'UTR
j = int(np.argmax(sc))
cand3[c] = opts[j]; best_row.append(float(sc[j]))
act_trace.append(best_row)
print(f"3'UTR {len(cand3[0]):>3} nt: best HepG2 {max(best_row):.4f} median {np.median(best_row):.4f}")
with ToolInstance.persist():
m3, cells3 = parade_utr3_panel(cand3); c2i3 = cells3.index(HEPG2)
UTR3_C2 = m3[:, c2i3]
mN, _ = parade_utr3_panel([UTR3]); UTR3_C2_NAT = float(mN[0, c2i3])
DESIGNED_UTR3 = cand3
best3 = int(np.argmax(UTR3_C2))
print(f"\n{N_CAND} 3'UTRs at {len(DESIGNED_UTR3[0])} nt | HepG2 best {UTR3_C2.max():.3f} median {np.median(UTR3_C2):.3f} natural {UTR3_C2_NAT:.3f}")
Figure 3. HepG2 activity climbs block by block, the 20 designed 3’UTRs reaching a median of 3.12 and a best of 3.35, against 2.87 for the natural GCG 3’UTR. All 20 beat it, and the gain here, about 15%, is larger than at the 5’ end.

The finished construct
Each candidate is a full construct, its own 5’UTR joined to the shared coding sequence and its own 3’UTR. Because we tracked HepG2 activity at both ends, we can rank the twenty by a combined score and take the winner, then compare it with a length-matched GCG reference construct on every axis at once. Both constructs encode the same 180-amino-acid protein and contain 833 nucleotides across the 5′UTR, CDS, and 3′UTR, and every regulatory and coding choice rewritten by a different model. The table and the final figure are just those numbers side by side; what matters is that they came out of one loop, not four.

Why this is the easier way
None of these models are new. What was missing was a way to hold them in one hand. The old path to this notebook was four environments, four checkpoint formats, four input conventions, and a lot of careful reloading. The proto path is one import, one call shape, and warm workers that let a 7B genome model generate both UTRs and a codon language model decode a coding sequence in the same session, without any glue between them. That is the point. When composing models is cheap, you can stop designing against a single proxy and begin evaluating each sequence against multiple relevant objectives for the cellular context you care about.
That is the point. When composing models is cheap, you stop designing against a single proxy and start designing against everything you know at once, for the cell you actually care about.
Where this goes
This is the design half of our lab in the loop. The models propose, the bench decides. A sequence that scores well across several computational objectives is a hypothesis, not a drug. The interesting next step, as always, is the wet lab.



So cool! Thanks for sharing, Amir! Can’t wait to read more about the cool work at Therna 🤓