Skip to content

Commit 6352d27

Browse files
polinabinder1claude
andcommitted
evo2 infer: add generate CLI mode — steer from the command line
Fourth CLI mode alongside serve/encode/batch: `launch_inference.sh generate --prompt ATGC... --clamp FEATURE_ID[:STRENGTH]` runs steered generation from the command line (repeat --clamp for several features). Reuses the verified engine.generate; `_parse_clamps` turns the repeatable --clamp args into feature specs. Usage docs the encode->generate steering loop. Stacked on #1622 (the engine + server), so the verified core stays frozen. test_cli.py covers the clamp parsing (CPU); the generation path itself is the engine's (GPU-verified in #1622's test_steering). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
1 parent 4a0de59 commit 6352d27

3 files changed

Lines changed: 92 additions & 4 deletions

File tree

bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_inference.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
#!/bin/bash
2-
# Launch the Evo2 SAE inference engine. One engine, three modes:
2+
# Launch the Evo2 SAE inference engine. One engine, four modes:
33
#
44
# ./launch_inference.sh serve # live HTTP server on :8001 (viz backend)
55
# ./launch_inference.sh encode --sequence ATGC... # annotate ONE sequence -> top features
66
# ./launch_inference.sh batch --fasta in.fa --out out.parquet # MANY sequences -> parquet
7+
# ./launch_inference.sh generate --prompt ATGC... --clamp 29244:300 # steer + generate DNA
8+
#
9+
# Steering loop: `encode` a sequence to find an active feature id, then
10+
# `generate --clamp ID:STRENGTH` (strength ~2-3x the feature's max_activation; repeat --clamp).
711
#
812
# Config via env. Required: EVO2_CKPT_DIR, SAE_CKPT_PATH. Optional (have defaults):
913
# FEATURE_ANNOTATIONS, EMBEDDING_LAYER (26), DEVICE, PORT, CUDA_VISIBLE_DEVICES.

bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16-
"""Evo2 SAE inference CLI — one engine, three modes.
16+
"""Evo2 SAE inference CLI — one engine, four modes.
1717
1818
serve : start the FastAPI server (one sequence at a time, interactive)
1919
encode : annotate ONE sequence -> top features (stdout JSON)
2020
batch : run a FASTA of MANY sequences -> parquet of per-sequence top features
21+
generate: generate DNA, optionally steering SAE features (stdout JSON)
2122
22-
All three build the same `Evo2SAE` engine; config comes from flags or env
23+
They all build the same `Evo2SAE` engine; config comes from flags or env
2324
(EVO2_CKPT_DIR / SAE_CKPT_PATH / FEATURE_ANNOTATIONS / EMBEDDING_LAYER).
2425
"""
2526

@@ -73,9 +74,21 @@ def _engine(args):
7374
)
7475

7576

77+
def _parse_clamps(clamps: list[str]) -> list[dict]:
78+
"""Parse repeated ``--clamp FEATURE_ID[:STRENGTH]`` args into [{feature_id, strength}].
79+
80+
Strength defaults to 1.0 if omitted (e.g. ``--clamp 29244:300`` or ``--clamp 29244``).
81+
"""
82+
specs = []
83+
for c in clamps:
84+
fid, sep, strength = c.partition(":")
85+
specs.append({"feature_id": int(fid), "strength": float(strength) if (sep and strength) else 1.0})
86+
return specs
87+
88+
7689
def main():
7790
"""Parse args and dispatch to the serve / encode / batch subcommand."""
78-
ap = argparse.ArgumentParser(description="Evo2 SAE inference (serve | encode | batch)")
91+
ap = argparse.ArgumentParser(description="Evo2 SAE inference (serve | encode | batch | generate)")
7992
sub = ap.add_subparsers(dest="cmd", required=True)
8093

8194
ps = sub.add_parser("serve", help="start the FastAPI inference server")
@@ -96,6 +109,23 @@ def main():
96109
pb.add_argument("--top-k", type=int, default=16)
97110
pb.add_argument("--batch-size", type=int, default=8)
98111

112+
pg = sub.add_parser("generate", help="generate DNA, optionally steering SAE features")
113+
_add_common(pg)
114+
pg.add_argument("--prompt", default="", help="DNA to seed; steering applies to the continuation")
115+
pg.add_argument("--organism", default="None (raw DNA)")
116+
pg.add_argument(
117+
"--clamp",
118+
action="append",
119+
default=[],
120+
metavar="FEATURE_ID[:STRENGTH]",
121+
help="clamp a feature on the continuation; repeatable (e.g. --clamp 29244:300). "
122+
"Find feature ids with `encode`.",
123+
)
124+
pg.add_argument("--n-tokens", type=int, default=120)
125+
pg.add_argument("--temperature", type=float, default=1.0)
126+
pg.add_argument("--top-k", type=int, default=0)
127+
pg.add_argument("--compare-baseline", action="store_true", help="also generate unsteered, for comparison")
128+
99129
args = ap.parse_args()
100130

101131
if args.cmd == "serve":
@@ -141,6 +171,27 @@ def main():
141171
df.to_parquet(args.out, index=False)
142172
print(f"[batch] wrote {len(df)} rows for {len(seqs)} sequences -> {args.out}")
143173

174+
elif args.cmd == "generate":
175+
out = eng.generate(
176+
prompt=args.prompt,
177+
organism=args.organism,
178+
features=_parse_clamps(args.clamp),
179+
n_tokens=args.n_tokens,
180+
temperature=args.temperature,
181+
top_k=args.top_k,
182+
compare_baseline=args.compare_baseline,
183+
)
184+
result = {
185+
"prompt": out["prompt"],
186+
"organism": out["organism"],
187+
"steered": out["steered"],
188+
"features": out["features"],
189+
"sequence": out["generation"]["sequence"],
190+
}
191+
if out.get("baseline"):
192+
result["baseline_sequence"] = out["baseline"]["sequence"]
193+
print(json.dumps(result, indent=2))
194+
144195

145196
if __name__ == "__main__":
146197
main()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: LicenseRef-Apache2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""CPU test for the generate CLI's --clamp parsing (no model)."""
17+
18+
from evo2_sae.cli import _parse_clamps
19+
20+
21+
def test_parse_clamps_id_and_strength():
22+
assert _parse_clamps(["29244:300", "88:1.5"]) == [
23+
{"feature_id": 29244, "strength": 300.0},
24+
{"feature_id": 88, "strength": 1.5},
25+
]
26+
27+
28+
def test_parse_clamps_default_strength():
29+
assert _parse_clamps(["29244"]) == [{"feature_id": 29244, "strength": 1.0}]
30+
31+
32+
def test_parse_clamps_empty():
33+
assert _parse_clamps([]) == []

0 commit comments

Comments
 (0)