evo2 SAE: inference engine + steering + server/CLI, tests, Dockerfile#1622
evo2 SAE: inference engine + steering + server/CLI, tests, Dockerfile#1622polinabinder1 wants to merge 20 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR introduces sparse autoencoder (SAE) feature steering capabilities for the Evo2 foundation model, along with a complete inference recipe. It adds a reusable ChangesEvo2 SAE Steering and Inference Recipe
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
Have you tried this? any examples you can share/screenshots? |
…ashboard.py - Remove the committed sample parquets; the dashboard now reads atlas data the user provides (gitignored public/*.parquet). It does NOT generate — generation is a separate offline step. - Add scripts/launch_dashboard.py: validate the 3 atlas parquets in --data-dir (exist + feature_id schema, fail fast) -> stage into feature_explorer/public/ -> start Vite. Mirrors the codonfm/esm2 launch_dashboard convention; engine-free (stdlib + pyarrow), so this PR stays a pure front-end (runtime dep on the #1622 server only). - Fix stale refs (evo2_sae_infer -> evo2_sae, steering_server.py -> server.py, layer 19 -> 26). - tests/test_launch_dashboard.py (CPU): staging copies the parquets; missing file -> FileNotFoundError; wrong schema -> ValueError. 3 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
|
@jwilber This only deals with the steering backend. The visualization is in PR 1623. |
Users pick from a preset library or paste sequences; the backend embeds them live (Evo2 -> layer-L -> SAE, mean/max-pooled per sequence) and the client UMAPs them, recoloring by feature. SequenceUMAPView.jsx (umap-js, already a dep) + the 'sequmap' tab + a small preset sequence_library.json. Needs the /gene_embed endpoint on the server (added in #1622). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
91d1e30 to
de81106
Compare
React/Vite dashboard for the evo2 SAE — three tabs (Feature atlas, Generative steering, Sequence inspector) plus a feature-detail drill-down. Front-end only: the atlas tab reads static parquet (works with no backend); the inspector + steering tabs call the live engine (`launch_inference.sh serve`, #1622) through the Vite /api -> :8001 proxy. Runtime dependency on the server only — no code dependency, so it merges independently of #1622. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
…ashboard.py - Remove the committed sample parquets; the dashboard now reads atlas data the user provides (gitignored public/*.parquet). It does NOT generate — generation is a separate offline step. - Add scripts/launch_dashboard.py: validate the 3 atlas parquets in --data-dir (exist + feature_id schema, fail fast) -> stage into feature_explorer/public/ -> start Vite. Mirrors the codonfm/esm2 launch_dashboard convention; engine-free (stdlib + pyarrow), so this PR stays a pure front-end (runtime dep on the #1622 server only). - Fix stale refs (evo2_sae_infer -> evo2_sae, steering_server.py -> server.py, layer 19 -> 26). - tests/test_launch_dashboard.py (CPU): staging copies the parquets; missing file -> FileNotFoundError; wrong schema -> ValueError. 3 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
Users pick from a preset library or paste sequences; the backend embeds them live (Evo2 -> layer-L -> SAE, mean/max-pooled per sequence) and the client UMAPs them, recoloring by feature. SequenceUMAPView.jsx (umap-js, already a dep) + the 'sequmap' tab + a small preset sequence_library.json. Needs the /gene_embed endpoint on the server (added in #1622). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (8)
bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py (1)
37-50: ⚖️ Poor tradeoffConsider more portable default paths.
Similar to the shell script, the default checkpoint and annotation paths are hardcoded to
/data/interp/evo2/...which won't exist for other users. While these can be overridden via CLI arguments or environment variables (making this less critical than the shell script issue), consider removing these hardcoded defaults or documenting the required setup clearly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py` around lines 37 - 50, Default file paths for CLI args (--sae-ckpt-path, --feature-annotations and EVO2_CKPT_DIR env fallback) are hardcoded to /data/interp/evo2/...; remove or replace these with portable defaults by making the argparse defaults None (or point to a user/home-relative path) and rely on environment variables (SAE_CKPT_PATH, FEATURE_ANNOTATIONS, EVO2_CKPT_DIR) or explicit CLI input, and update the code that consumes these values (where these args are referenced) to validate and raise a clear error if no path is provided; target the add_argument calls for "--sae-ckpt-path", "--feature-annotations" and the EVO2_CKPT_DIR default.bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py (7)
51-55: ⚡ Quick winAdd field docstrings to FeatureClamp.
📝 Example enhancement
class FeatureClamp(BaseModel): """A single SAE-feature steering clamp (feature id + target strength).""" - feature_id: int - strength: float = 1.0 + feature_id: int + """SAE feature ID to clamp during generation.""" + strength: float = 1.0 + """Target activation strength for the feature."""As per coding guidelines, use Google-style docstrings (pydocstyle convention) in Python code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 51 - 55, Add Google-style (pydocstyle) docstrings describing each field on the FeatureClamp Pydantic model: update the class docstring for FeatureClamp (subclassing BaseModel) to include an Args section documenting feature_id (int) and strength (float) with concise descriptions and units/semantics (e.g., feature index and target steering strength, default 1.0). Keep the top-line summary intact and ensure the Args block follows Google style so linters accept it.Source: Coding guidelines
58-68: ⚡ Quick winAdd field docstrings to GenerateRequest.
📝 Example enhancement
class GenerateRequest(BaseModel): """Request body for /generate (autoregressive generation + optional SAE-feature clamps).""" - prompt: str = "" - organism: str = "None (raw DNA)" - tag: Optional[str] = None - features: list[FeatureClamp] = [] - n_tokens: int = 120 - temperature: float = 1.0 - top_k: int = 0 - compare_baseline: bool = False + prompt: str = "" + """Initial DNA sequence to condition generation.""" + organism: str = "None (raw DNA)" + """Organism identifier for phylogenetic tagging.""" + tag: Optional[str] = None + """Custom phylogenetic tag (overrides organism lookup).""" + features: list[FeatureClamp] = [] + """SAE feature clamps for steering generation.""" + n_tokens: int = 120 + """Number of tokens to generate.""" + temperature: float = 1.0 + """Sampling temperature (higher = more random).""" + top_k: int = 0 + """Top-k sampling parameter (0 = disabled).""" + compare_baseline: bool = False + """Whether to generate an unsteered baseline for comparison."""As per coding guidelines, use Google-style docstrings (pydocstyle convention) in Python code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 58 - 68, Add Google-style (pydocstyle) docstrings for the GenerateRequest datamodel: add a class docstring describing the purpose of GenerateRequest and include an Args section that documents each attribute (prompt, organism, tag, features: list[FeatureClamp], n_tokens, temperature, top_k, compare_baseline) with types and brief descriptions (e.g., prompt: input sequence string; organism: organism context or "None (raw DNA)"; tag: optional user tag; features: SAE FeatureClamp list used for clamping; n_tokens: number of tokens to generate; temperature: sampling temperature; top_k: top-k sampling value; compare_baseline: whether to compare to baseline). Ensure the formatting follows Google-style pydocstyle conventions and place the docstring immediately under the class GenerateRequest declaration.Source: Coding guidelines
39-48: ⚡ Quick winAdd field docstrings to AnnotateRequest.
The class is missing Google-style field docstrings. Each field should document its purpose, especially fields like
modethat have specific allowed values ("topk" | "pick").📝 Example enhancement
class AnnotateRequest(BaseModel): """Request body for /annotate (top-k feature scan or an explicit feature pick).""" - sequence: str - organism: str = "None (raw DNA)" - tag: Optional[str] = None - mode: str = "topk" # "topk" | "pick" - k: int = 8 - feature_ids: Optional[list[int]] = None - feature_id: Optional[int] = None + sequence: str + """DNA sequence to annotate.""" + organism: str = "None (raw DNA)" + """Organism identifier for phylogenetic tagging.""" + tag: Optional[str] = None + """Custom phylogenetic tag (overrides organism lookup).""" + mode: str = "topk" + """Feature selection mode: 'topk' (top-k scan) or 'pick' (explicit features).""" + k: int = 8 + """Number of top features to return when mode='topk'.""" + feature_ids: Optional[list[int]] = None + """Explicit feature IDs when mode='pick'.""" + feature_id: Optional[int] = None + """Single feature ID when mode='pick' (alternative to feature_ids)."""As per coding guidelines, use Google-style docstrings (pydocstyle convention) in Python code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 39 - 48, The AnnotateRequest Pydantic model lacks Google-style field docstrings; update the class docstring for AnnotateRequest to include a Google-style "Attributes:" section that documents each field (sequence, organism, tag, mode, k, feature_ids, feature_id), describing purpose, types/constraints and allowed values for mode ("topk" | "pick") and any relationships (e.g., feature_ids vs feature_id) so readers and linters can validate the field meanings. Ensure the docstring follows pydocstyle/Google conventions and mentions defaults where relevant.Source: Coding guidelines
99-107: ⚡ Quick winAdd return type hint to features endpoint.
`@app.get`("/features") - def features(): + def features() -> list[dict]:As per coding guidelines, use Pyright for type checking in Python files following pyproject.toml configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 99 - 107, The endpoint function features lacks a return type hint; update its signature to include a typed return such as def features() -> List[Dict[str, Any]]: and add the necessary imports (from typing import List, Dict, Any) at the top of the module, or alternatively define and use a pydantic model and set response_model on `@app.get`; modify the function signature and imports so Pyright type checking passes while keeping the existing logic in features().Source: Coding guidelines
109-154: ⚡ Quick winAdd return type hint to annotate endpoint.
`@app.post`("/annotate") - def annotate(req: AnnotateRequest): + def annotate(req: AnnotateRequest) -> dict:As per coding guidelines, use Pyright for type checking in Python files following pyproject.toml configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 109 - 154, The annotate endpoint lacks a return type hint which fails Pyright checks; update the annotate function signature (def annotate(req: AnnotateRequest)) to include an explicit return type like -> Dict[str, Any] (or a proper TypedDict/AnnotateResponse if available), and add the corresponding typing import (e.g., from typing import Dict, Any) at the top of the module so Pyright accepts the annotated return for the function annotate and its returned JSON structure.Source: Coding guidelines
86-97: ⚡ Quick winAdd return type hint to health endpoint.
`@app.get`("/health") - def health(): + def health() -> dict:As per coding guidelines, use Pyright for type checking in Python files following pyproject.toml configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 86 - 97, The health endpoint lacks a return type hint; update the health function signature (def health) to declare a typed return such as -> Dict[str, Any] or -> dict[str, Any] and add the corresponding import (from typing import Any, Dict) so Pyright can validate the returned mapping built from engine (engine.ready, engine.layer, engine.n_features, engine.labels, engine.sae_ckpt_path, engine.organism_tags, engine.device); keep the returned structure unchanged and ensure the type hint covers the mixed value types.Source: Coding guidelines
156-172: ⚡ Quick winAdd return type hint to generate endpoint.
`@app.post`("/generate") - def generate(req: GenerateRequest): + def generate(req: GenerateRequest) -> dict:As per coding guidelines, use Pyright for type checking in Python files following pyproject.toml configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py` around lines 156 - 172, Add an explicit return type annotation to the FastAPI endpoint function generate (def generate(req: GenerateRequest) -> Any) and import Any from typing; update the signature so Pyright knows the endpoint's return type (e.g., def generate(req: GenerateRequest) -> Any:), leaving the body and exception handling (engine.generate call and HTTPException raises) unchanged.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_inference.sh`:
- Around line 17-21: The script currently embeds development-only absolute
defaults for VENV, EVO2_CKPT_DIR, SAE_CKPT_PATH, and FEATURE_ANNOTATIONS which
will break elsewhere; remove those hardcoded paths and instead either (a) set
VENV to a relative default like RECIPE_DIR/.venv and leave
EVO2_CKPT_DIR/SAE_CKPT_PATH/FEATURE_ANNOTATIONS unset, or (b) require these env
vars be provided and add an explicit validation block that checks VENV,
EVO2_CKPT_DIR, SAE_CKPT_PATH, and FEATURE_ANNOTATIONS (while allowing
EMBEDDING_LAYER to keep a sane numeric default), and if any are missing print a
clear error naming the missing variable(s) and exit non‑zero; update the code
references to VENV, EVO2_CKPT_DIR, SAE_CKPT_PATH, FEATURE_ANNOTATIONS, and
EMBEDDING_LAYER accordingly.
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py`:
- Around line 57-67: Add a Google-style docstring to the _engine function
describing its purpose, parameters, and return value: explain that _engine
constructs and returns an Evo2SAE instance, document each parameter passed to
Evo2SAE (evo2_ckpt_dir, sae_ckpt_path, layer, device, max_seq_len,
feature_annotations) with types and brief descriptions, and state the return
type (Evo2SAE). Place the docstring immediately below the def _engine(args):
line using the standard Google style (Args:, Returns:) so tools and linters can
pick it up.
- Around line 34-55: The function _add_common is missing a Google-style
docstring; add a concise Google-style docstring immediately below the def
_add_common(p: argparse.ArgumentParser) -> None: line describing the function’s
purpose (registers shared CLI arguments), the parameter p (an
argparse.ArgumentParser), and any side effects/returns (modifies the parser in
place, returns None). Use the Google docstring sections: Args and Returns, and
keep wording aligned with surrounding code style.
- Around line 70-87: Add a Google-style docstring to _read_fasta describing
parameters (path), return values (ids, seqs), behavior (supports gzipped files)
and exceptions; and fix the header-parsing edge case by replacing the brittle
line that does line[1:].split()[0] with logic that strips the leading ">" and
whitespace, uses .split() safely (e.g., parts = line[1:].strip().split(); name =
parts[0] if parts else f"seq_{len(ids)}") so headers like "> " don't raise
IndexError and still produce a generated id when no token is present.
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.py`:
- Around line 168-189: The docstring and logic in the method that reads
self.feature_annotations (variables: labels, peaks, path, path.suffix) claim to
support parquet/tsv/csv/json but only handle parquet; update the code in the
function (the block starting with path = Path(self.feature_annotations)) to
detect .csv/.tsv (use csv or pandas.read_csv), .json (json.load or
pandas.read_json), and parse the same columns ("feature_id", "label" or
"annotation", "max_activation") into labels and peaks just like the parquet
branch, and for any other suffix emit an explicit logger.warning stating the
format is unsupported and return empty labels/peaks; ensure you reuse the same
keys/behavior (casting ids to int, labels to str, peaks to float) as done in the
pq branch so the rest of the code remains compatible.
- Around line 352-366: The code indexes SAE tensors using incoming feature IDs
(see fids, features and usages of self.sae.encoder.weight /
self.sae.decoder.weight) without validation; add explicit bounds and type checks
before any tensor indexing inside the block that builds specs (validate each fid
is an integer >=0 and < self.sae.encoder.weight.size(0) and similarly valid for
decoder indexing), and if invalid raise a ValueError with a clear message so the
/generate handler returns 400; perform these checks at the start of the with
self._lock block (before accessing self.sae.* tensors) or filter/convert
f["feature_id"] to int safely and validate before using it in specs construction
(references: fids, features, self.sae.encoder.weight, self.sae.decoder.weight,
self.layer).
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py`:
- Around line 124-131: The code currently treats any non-"pick" mode as "topk";
update the conditional around req.mode in server.py to explicitly handle "pick"
and "topk" only and raise an HTTPException(400, "invalid mode; allowed values:
'pick', 'topk'") for any other value. Concretely, change the if/else to if
req.mode == "pick": ... elif req.mode == "topk": compute k and call
engine.top_features(...); else: raise the 400 error so typos or unsupported
modes are rejected (refer to req.mode, engine.top_features, chosen).
- Line 84: The CORS middleware is currently set to allow all origins via
app.add_middleware(CORSMiddleware, allow_origins=["*"]) which is too permissive
for production; update the server startup to read an environment variable (e.g.,
CORS_ALLOWED_ORIGINS or CORS_ALLOWED_ORIGIN) and use that to populate
allow_origins (parse a comma-separated list into a list), defaulting to a safe
value like an empty list or localhost for dev, and ensure
allow_methods/allow_headers remain appropriate; locate the use of
app.add_middleware and replace the hardcoded ["*"] with the parsed config so
deployments can restrict origins without code changes.
- Around line 23-34: Add a second blank line after the import block (the line
ending with "from .core import Evo2SAE, clean_dna") so there are two blank lines
before the next top-level statement (e.g., the logger = logging.getLogger(...)
or any subsequent definitions); this aligns with the isort rule and ensures the
import section (including Evo2SAE and clean_dna) is separated from module-level
code.
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py`:
- Around line 99-105: Update the test_endpoints_503_until_ready to also assert
that the /generate endpoint returns 503 when the engine is not ready: in the
existing test that creates FakeEngine (eng.ready = False), using
TestClient(build_app(eng)) add a POST request to "/generate" with a
representative JSON payload (similar shape to other tests, e.g. prompt/sequence
fields) and assert c.post("/generate", json=...).status_code == 503 so /generate
is covered like /features and /annotate.
---
Nitpick comments:
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py`:
- Around line 37-50: Default file paths for CLI args (--sae-ckpt-path,
--feature-annotations and EVO2_CKPT_DIR env fallback) are hardcoded to
/data/interp/evo2/...; remove or replace these with portable defaults by making
the argparse defaults None (or point to a user/home-relative path) and rely on
environment variables (SAE_CKPT_PATH, FEATURE_ANNOTATIONS, EVO2_CKPT_DIR) or
explicit CLI input, and update the code that consumes these values (where these
args are referenced) to validate and raise a clear error if no path is provided;
target the add_argument calls for "--sae-ckpt-path", "--feature-annotations" and
the EVO2_CKPT_DIR default.
In
`@bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py`:
- Around line 51-55: Add Google-style (pydocstyle) docstrings describing each
field on the FeatureClamp Pydantic model: update the class docstring for
FeatureClamp (subclassing BaseModel) to include an Args section documenting
feature_id (int) and strength (float) with concise descriptions and
units/semantics (e.g., feature index and target steering strength, default 1.0).
Keep the top-line summary intact and ensure the Args block follows Google style
so linters accept it.
- Around line 58-68: Add Google-style (pydocstyle) docstrings for the
GenerateRequest datamodel: add a class docstring describing the purpose of
GenerateRequest and include an Args section that documents each attribute
(prompt, organism, tag, features: list[FeatureClamp], n_tokens, temperature,
top_k, compare_baseline) with types and brief descriptions (e.g., prompt: input
sequence string; organism: organism context or "None (raw DNA)"; tag: optional
user tag; features: SAE FeatureClamp list used for clamping; n_tokens: number of
tokens to generate; temperature: sampling temperature; top_k: top-k sampling
value; compare_baseline: whether to compare to baseline). Ensure the formatting
follows Google-style pydocstyle conventions and place the docstring immediately
under the class GenerateRequest declaration.
- Around line 39-48: The AnnotateRequest Pydantic model lacks Google-style field
docstrings; update the class docstring for AnnotateRequest to include a
Google-style "Attributes:" section that documents each field (sequence,
organism, tag, mode, k, feature_ids, feature_id), describing purpose,
types/constraints and allowed values for mode ("topk" | "pick") and any
relationships (e.g., feature_ids vs feature_id) so readers and linters can
validate the field meanings. Ensure the docstring follows pydocstyle/Google
conventions and mentions defaults where relevant.
- Around line 99-107: The endpoint function features lacks a return type hint;
update its signature to include a typed return such as def features() ->
List[Dict[str, Any]]: and add the necessary imports (from typing import List,
Dict, Any) at the top of the module, or alternatively define and use a pydantic
model and set response_model on `@app.get`; modify the function signature and
imports so Pyright type checking passes while keeping the existing logic in
features().
- Around line 109-154: The annotate endpoint lacks a return type hint which
fails Pyright checks; update the annotate function signature (def annotate(req:
AnnotateRequest)) to include an explicit return type like -> Dict[str, Any] (or
a proper TypedDict/AnnotateResponse if available), and add the corresponding
typing import (e.g., from typing import Dict, Any) at the top of the module so
Pyright accepts the annotated return for the function annotate and its returned
JSON structure.
- Around line 86-97: The health endpoint lacks a return type hint; update the
health function signature (def health) to declare a typed return such as ->
Dict[str, Any] or -> dict[str, Any] and add the corresponding import (from
typing import Any, Dict) so Pyright can validate the returned mapping built from
engine (engine.ready, engine.layer, engine.n_features, engine.labels,
engine.sae_ckpt_path, engine.organism_tags, engine.device); keep the returned
structure unchanged and ensure the type hint covers the mixed value types.
- Around line 156-172: Add an explicit return type annotation to the FastAPI
endpoint function generate (def generate(req: GenerateRequest) -> Any) and
import Any from typing; update the signature so Pyright knows the endpoint's
return type (e.g., def generate(req: GenerateRequest) -> Any:), leaving the body
and exception handling (engine.generate call and HTTPException raises)
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 3e499152-13aa-449c-b7ad-6b67a8279836
📒 Files selected for processing (8)
bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/pyproject.tomlbionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_inference.shbionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/__init__.pybionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.pybionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.pybionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.pybionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.pybionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_steering.py
…_sae serve` Shrink the inference PR to the engine + server + their tests. The encode/batch/generate command-line tools (cli.py) and launch_inference.sh move to the stacked CLI PR (#1632); the server stays launchable here via `python -m evo2_sae serve` (__main__.py, env-configured). fasta.py stays (shared by the extraction-side chunk_fasta.py and, via the base, the CLI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
5f4fce4 to
4a0de59
Compare
…1622) Steering's only consumers (the live engine's clamp hook + the steer.py harness) both live in the evo2 serve recipe (#1622), and the harness imports Evo2SAE from it. So the steering primitive + harness move to a dedicated PR stacked on #1622, where the core clamp-hook dedup can happen in-place. This base is now the probing library only. Signed-off-by: Polina Binder <pbinder@nvidia.com>
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
A new ubuntu-latest workflow installs sae + the recipe (CPU torch) and runs the recipe's model-agnostic tests (-m 'not slow') — the label producers (#1630), eval metrics, etc. — so they run cheaply on the probing-stack branches instead of waiting for #1622's megatron GPU lane (which would run them on an L4 after a full build). Registers the 'slow' marker on the recipe pyproject so the GPU tests are excluded without an unknown-marker warning. Validated: pytest tests/ -m 'not slow' -> 16 passed (CPU). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
…se SAE forward - Extract evo2_buffer.forward_codes(engine, id_lists) — the one place that touches the engine internals (locked GPU forward + SAE encode). build_buffer and probe._encode_windows both use it, so the #1622 engine-API coupling lives in a single spot, and the per-token label/buffer work moves out of the GPU lock. Add a CPU unit test (fake engine) for the helper's contract. - Hoist KINGDOM_TAGS to evo2_buffer (was duplicated in probe_loss_recovered). - Remove the `codon-aa` subcommand: it consumed a codon/aa npz no command produces (and was the only raw np.load); drop it and its now-unused decode_eval/fit_softmax imports until a producer exists. - SAEWrap delegates to the SAE's own forward() (top-k + normalize_input denormalization) instead of hand-rolling decoder(codes)+pre_bias and mean/std — the path the steering hook uses, so the loss-recovered recon can't drift from the SAE's actual (de)normalization. - Make evo2_buffer importable without the evo2_sae engine (lazy read_fasta), so the CPU tests exercise forward_codes and the harness imports cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
…ck (#1622) recipes/evo2/ is co-owned with #1622 (Dockerfile/build + src/evo2_sae). Align the shared files so the two stacks merge without conflict, regardless of order: - pyproject.toml: keep `[tool.setuptools] packages = []` (unchanged from main, so #1622's `where = ["src"]` wins cleanly at merge and `pip install -e recipes/evo2` still works here with no src/ dir); make the `[tool.pytest.ini_options]` markers block byte-identical to #1622's so the add/add merges cleanly. The biopython/pyrodigal deps stay a one-sided add. - Drop tests/conftest.py (it add/add-collided with #1622's GPU-fixture conftest) and restore the per-file scripts/ sys.path insert in test_probe_integration.py, matching the sibling tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
…validation test - test_build_buffer_shapes_and_label_alignment_with_fake_engine (CPU): drives build_buffer (forward_codes + labelers + ActivationBuffer) on a fake engine, asserting codes/dense/labels shapes align and base_A fires exactly on DNA 'A' positions with the phylo tag left unlabeled. - test_build_buffer_and_score_real_engine (@pytest.mark.slow): the #1636<->#1622 seam end to end against the real Evo2SAE engine (real model -> codes -> labels -> auroc_all). Skips without CUDA / the engine; uses the recipe conftest's evo2_ckpt_dir/sae_ckpt_path/embedding_layer fixtures, which arrive when the serve + eval stacks share recipes/evo2/ — so it runs in the merged megatron GPU lane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
…d onto migrated #1622 Clamp an SAE feature via the production Evo2SAE.generate path and quantify the causal effect: dose-response (effect vs strength) + selectivity (target vs control features), persisted to a structured steering_results.json. Metric / robustness: * normalized edit (Levenshtein) distance, not positional Hamming. Greedy decode is autoregressive, so one early flipped token shifts every downstream base and pins Hamming at ~1.0 — erasing the dose curve. Edit distance is shift-robust; first_divergence (shared-prefix length) is the complementary monotone signal. Tested with the shift case. * surface the clamp cap: generate() silently caps |strength| to MAX_CLAMP_STRENGTH, so two requests above it produce an identical clamp (a fake plateau). run_steering warns, steers at the effective value, and records max_clamp_strength + capped_strengths. Consolidation: * harness + metrics live in the package (src/evo2_sae/steer_analysis.py), engine injected, so they import as a normal torch-free module like evo2_sae.fasta — dropped all four sys.path inserts. scripts/steer.py is now a thin CLI (matches train.py/extract.py). * pick_target reuses Evo2SAE.top_features (the CLI/server ranking) instead of re-deriving topk. * one CPU test file (metrics + fake-engine harness) instead of two; fake stays local, not in conftest, to avoid colliding with the sibling server PR's engine fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
Relocate the steering dose-response / selectivity metrics from evo2_sae.steer_analysis into the evo2_sae.eval package (src/evo2_sae/steer_analysis.py -> src/evo2_sae/eval/steering.py), alongside the eval/probing harness. Update the importers (scripts/steer.py CLI + tests/test_steer_analysis.py) to evo2_sae.eval.steering. The CI lane is dropped via the rebase onto the updated #1622. Pure-CPU tests, no GPU/model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
## Summary FastAPI **server + CLI** over the Evo2SAE engine (#1622). Thin wrappers — all model work lives in `core.py` — plus the input validation, resource governance, and recovery needed for a shared backend (runs behind NVIDIA SSO on Brev, reachable by many users). API routes live under **`/api`**, and the server can mount a prebuilt front-end at `/`, so the dashboard (#1623) and the API can be served from **one origin / one container**. **Rebased onto the single-engine #1622** (one inference engine serves both encode and generate; new top-level layout `interpretability/sparse_autoencoders/…`). ## Contents (new layout) - `…/src/evo2_sae/server.py` — `/api/health`, `/api/features`, `/api/annotate`, `/api/generate` (+ optional static-frontend mount at `/`) - `…/src/evo2_sae/cli.py` — `serve` / `encode` / `batch` / `generate` - `…/scripts/launch_inference.sh`; CPU contract tests `tests/test_cli.py`, `tests/test_server.py` + the shared `FakeEngine` appended to #1622's `tests/conftest.py` ## Shared logic (CLI ⇄ server live in `core`) - **`core.annotate(engine, …)`** — clean → resolve-tag → encode → tag-len, behind both CLI `encode` and server `/api/annotate`. - **`core.parse_clamp_spec(spec)`** — one parser for clamps as CLI `"ID[:STRENGTH]"` strings or server `FeatureClamp` JSON; fed in front of #1622's `_sanitize_steering` so both surfaces validate identically. ## Single-origin serving (`/api` + optional static mount) - API routes are grouped under **`/api`** (one `APIRouter` + `include_router`). - `build_app(engine, static_dir=None)` mounts a prebuilt front-end at `/` via `StaticFiles(html=True)` when `static_dir` (or the `DASHBOARD_DIST` env) points at a real directory; otherwise the server is **API-only** and `/` 404s (never crashes). The mount is generic — it serves whatever dir it's pointed at and knows nothing about the dashboard; #1623 supplies the dir + the Docker build that produces it. - This is what lets a single container serve UI + API on one port. Dev hits the same `/api/*` paths (the Vite proxy forwards `/api` without rewriting), so there's no dev/prod path drift. ## Reliability & governance - **`/api/health` 503 until ready** so readiness probes don't route to a still-loading pod; a startup load failure is caught and leaves the engine not-ready (503) rather than crashing. - **Length limits** — `/api/annotate` and `/api/generate` reject input longer than `max_seq_len` (**413**) instead of silently truncating (which would misalign the per-base `activations`/`bases` the viz plots). Generation length is otherwise auto-capped to the remaining context (no fixed token cap). - **Pick-id validation** — `/api/annotate` `mode=pick` range-checks user-supplied `feature_ids` → **400** (an out-of-range id would otherwise 500 on `IndexError`, a negative one would silently return the wrong feature). - **Steering sanitation** — out-of-range ids, extreme/non-finite strengths, `temperature<=0`, negative `top_k` are all rejected/coerced before the GPU (`_sanitize_steering`). - **CUDA-wedge recovery** — a device-side assert poisons the process's CUDA context (unrecoverable in-process). Not client-inducible (sanitation covers the reachable triggers — purely defensive), but if it happens `generate()` flips the engine not-ready (→ 503) and, when `EXIT_ON_CUDA_WEDGE=1` (set by `serve`), exits the worker so any restart-on-exit supervisor respawns it — host-independent recovery. - **Signal-safe serve** — `launch_inference.sh serve` runs the worker in the background, forwards `SIGTERM`/`SIGINT` (uvicorn graceful shutdown) before respawning, with a retry cap + backoff, so `docker stop`/k8s shuts down cleanly instead of orphaning the worker. - **Request body-size limit** (`MAX_BODY_BYTES`, default 16 MiB) → 413 — advisory (trusts `Content-Length`). - **Bounded concurrency** — Starlette's sync-endpoint threadpool capped (`MAX_CONCURRENCY`, default 8); the engine lock already serializes the single GPU. ## Architectural decisions - **Two layers: engine vs. surface.** All model work stays in `core.Evo2SAE` (#1622); `server.py`/`cli.py` are thin and share `core.annotate` + `core.parse_clamp_spec`, so the HTTP API and the CLI can't drift and there's one validated path. - **FastAPI, not raw/Flask.** We get pydantic structural validation + an async threadpool we can bound (`MAX_CONCURRENCY`) for almost no code; the domain validation that matters (`_sanitize_steering`, pick-id range) is manual either way. Raw Python would hand-roll routing/validation/concurrency; Flask would add the threadpool governance by hand. - **No app-level auth.** Deployed behind NVIDIA SSO on Brev; auth is the proxy's job, not duplicated here (CORS removed too — calls are same-origin). - **Single GPU, serialized.** The engine lock + bounded threadpool match one GPU; data-parallel replicas behind a balancer are a deferred follow-up (touches no engine code). - **`/api` prefix + generic static mount** (above) so one origin/container can serve both UI and API. ## How to run Run **inside the evo2_megatron venv** (provides `bionemo.evo2` + megatron); in the Docker image it's already active. Full dashboard run modes are in #1623's `feature_explorer/README.md`. ```bash export EVO2_CKPT_DIR=<mbridge> SAE_CKPT_PATH=<sae.pt> export FEATURE_ANNOTATIONS=<feature_metadata.parquet> EMBEDDING_LAYER=26 scripts/launch_inference.sh serve # API on :8001 (+ UI at / if DASHBOARD_DIST set) scripts/launch_inference.sh encode --sequence ATGC... # one sequence -> top features (JSON) scripts/launch_inference.sh batch --fasta in.fa --out out.parquet # many -> parquet scripts/launch_inference.sh generate --prompt ATGC... --clamp 29244:300 # steered generation ``` Tunables (env): `MAX_BODY_BYTES`, `MAX_CONCURRENCY`, `MAX_SEQ_LEN`, `PORT`, `EXIT_ON_CUDA_WEDGE`, `DASHBOARD_DIST`. ## Tests No dedicated CI lane (deferred — see #1622). Run them via the recipe's build script: ```bash cd interpretability/sparse_autoencoders/recipes/evo2 bash .ci_build.sh && source .ci_test_env.sh pytest tests/ ``` - **CPU (no model):** `test_cli.py` + `test_server.py` (FastAPI `TestClient` + `FakeEngine`: response shapes, 400/413/503, pick out-of-range → 400, `/api/generate` too-long → 413, body-size, k-bounds, clamp validation, **static-frontend mount: SPA at `/`, asset served, API reachable under `/api`, unknown `/api/*` → 404, API-only when no frontend**), plus #1622's `test_core.py` + `test_steering.py` sanitize guards. - **GPU:** `test_steering.py` — encode, in-distribution generation, steering changes the continuation, batched/empty encode, max-clamp finite, **highlight↔steer interleaving** (single-engine state-bleed check). Gated by `@pytest.mark.skipif(not torch.cuda.is_available())` — runs on a GPU box, skips otherwise. Validated on the 1B; the single-engine backend also serves the **7B at layer 26** live. ## Deferred follow-up Multi-GPU **data-parallel replicas** (one worker per GPU behind a `least_conn` balancer) for concurrent throughput — touches no engine code; left until concurrency is an observed need. **Stacked on #1622.** The dashboard (#1623) builds on this. --------- Signed-off-by: Polina Binder <pbinder@nvidia.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary Evo2 SAE **eval: label producers + probing harness** — turn DNA into an `ActivationBuffer` (the one model-touching step) and score it through the probing CLI (AUROC / annotate / linear / domain-F1 / loss-recovered). Lives in the **`evo2_sae.eval.probing`** package, alongside the #1629 primitives. **Stacked on #1629** (→ #1622). #1630 supplies the eval labels. ## Contents — `evo2_sae.eval.probing` - `evo2_buffer.py` — DNA → `ActivationBuffer` (the only model-touching code: Evo2 → layer-L residual → `SAE.encode` + per-token labels) - `labelers.py` — per-token biological labelers (genetic code / CDS frame; prokaryotic gene calling via `pyrodigal`) - `annot_tracks.py` — BED/GFF interval-track loader → per-token masks (RefSeq / Rfam / JASPAR / ENCODE) - `euk_windows.py` — eukaryotic gene-structure windows - `probe.py` — the probing CLI (`extract` / `auroc` / `annotate` / `linear` / `euk-f1` / `domain-eval`) - `probe_loss_recovered.py` — SAE fidelity (loss recovered); reuses the shared `sae.eval.loss_recovered` Imports are package-relative; the primitives come from `evo2_sae.eval.probing`. `loss_recovered` stays in the shared `sae` lib (used by esm2/codonfm too). ## How to run ```bash cd interpretability/sparse_autoencoders/recipes/evo2 bash .ci_build.sh && source .ci_test_env.sh # or: PYTHONPATH=src:../../sae/src pytest tests/test_probe_integration.py tests/test_labelers.py tests/test_annot_tracks.py tests/test_euk_windows.py # CLI: python -m evo2_sae.eval.probing.probe extract|auroc|annotate|linear|domain-eval ... ``` No dedicated CI lane (deferred — see #1622; CI should fold into the repo-wide recipe lane later). ## Tests - **CPU (no model):** label producers (`labelers` / `annot_tracks` / `euk_windows`) + the probe-CLI integration (buffer save/load roundtrip incl. the dense twin, AUROC/annotate/linear over a planted feature, `domain_f1` over interval tracks). **35 passed.** - **GPU:** the real-engine buffer/loss-recovered path is gated by `@pytest.mark.skipif(not torch.cuda.is_available())` — runs on a GPU box, skips otherwise. --------- Signed-off-by: Polina Binder <pbinder@nvidia.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: root <root@nvidia-lepton040.cm.cluster>
Re-add .github/workflows/unit-tests-interpretability-recipes.yaml (removed in c31d4e9 "defer CI for now"), recovered verbatim from 9bedf2b. Path-gated GPU lane (L4 + megatron squashed image) that builds the evo2 SAE recipe via .ci_build.sh and runs pytest tests/ — including the GPU steering/encode tests. Rooted on this PR (#1622), so the stacked SAE PRs (#1623/#1629/#1635) inherit it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d86f081 to
a7392d2
Compare
Removes the last hardcoded /data/interp path from the recipe scripts — WORK_ROOT now fails fast with a helpful message instead of defaulting to a machine-specific dir, matching launch_inference.sh's required-env-var style. Addresses the environment-hygiene review feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
Relocates the delta-clamp steering hook out of the shared sae library and into the recipe, so this PR's code is entirely under recipes/evo2/: sae/src/sae/steering.py -> recipes/evo2/src/evo2_sae/steering.py sae/tests/test_steering.py -> recipes/evo2/tests/test_clamp.py core.py + the moved test import from evo2_sae.steering. The recipe still consumes the shared sae lib for TopKSAE + sae.eval (unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
… updated #1667 workflow Re-validates the interp GPU lane after (a) moving the steering hook out of shared sae into recipes/evo2/, and (b) the #1667 trigger/relevance fix (no paths filter; gate diffs vs main, scoped to evo2 + sae + evo2_megatron). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
… workflow Uses the finalized #1667 lane: gate on recipes/evo2/ + recipes/evo2_megatron/ (no sae), tests recipes/evo2/tests/ only. Re-validates the trigger + the steering move on a real L4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
Replaces the single evo2 job with a changed-dirs + matrix pattern (modeled on the repo-wide unit-tests-recipes.yml), scoped to interpretability/sparse_autoencoders/recipes/*: - Eligible = any interp recipe with its own .ci_build.sh. Today that's evo2; codonfm/esm2 auto-join once they add a .ci_build.sh + tests. Empty before #1622 lands -> whole lane is a green no-op (presence guard = has .ci_build.sh). - A change under a recipe runs that recipe; a change to shared sae/, this workflow, or the nightly runs ALL eligible recipes. Each recipe's own .ci_build.sh owns its build, so a codonfm/esm2 change never triggers the Evo2 megatron build (and vice-versa). Matrix-selection logic verified locally across 8 scenarios. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
#1667 matrix Uses the finalized #1667 lane (changed-dirs + per-recipe matrix over interpretability/sparse_autoencoders/recipes/*). On this PR the diff touches recipes/evo2/, so the matrix = [evo2] and the L4 runs its .ci_build.sh + pytest tests/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
… the recipe The steering clamp hook moved sae.steering -> evo2_sae.steering (#1622). This merges that base in and fixes the one remaining reference (a docstring in eval/steering.py). No import change was needed — the harness is engine-injected and never imported sae.steering directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
The eligibility loop used `[ -f .ci_build.sh ] && echo`; when the last recipe dir (evo2, alphabetically) lacks a .ci_build.sh the short-circuit leaves exit 1, and `set -e` turns that into a job failure instead of an empty (green no-op) matrix. This bit the lane on its own branch — before #1622 lands, NO recipe has a .ci_build.sh, so ALL=[] and changed-dirs errored (the "nothing eligible" path was never exercised; #1670 validated it stacked with #1622, where evo2 IS eligible). Use `if`, which returns 0 when the body is skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: polinabinder1 <pbinder@nvidia.com>
…esence-guarded) (NVIDIA-BioNeMo#1667) ## What A GPU CI **matrix lane** for the SAE interpretability recipes under `interpretability/sparse_autoencoders/recipes/*`. Modeled on the repo-wide `unit-tests-recipes.yml`: a cheap ubuntu `changed-dirs` job discovers which interp recipes to test and emits a matrix; a per-recipe `unit-tests` job runs each on an L4 (its own `.ci_build.sh` + `pytest tests/`). ## Which recipes are eligible Any interp recipe that has its own **`.ci_build.sh`**. Today that's **evo2**. `codonfm`/`esm2` have no `.ci_build.sh`/tests yet, so they're **skipped until they add them** — at which point they auto-join this lane, no workflow change needed. (This is also the **presence guard**: before the evo2 SAE recipe lands, NVIDIA-BioNeMo#1622, nothing is eligible → the whole lane is a green no-op, so it can merge first.) ## What runs, when | What the PR changes | Which recipes run | |---|---| | a recipe's own dir — `…/recipes/<X>/**` | just **`<X>`** | | the shared `sae` lib — `…/sparse_autoencoders/sae/**` | **all** eligible recipes (they all depend on sae) | | this workflow file | **all** eligible recipes | | **nightly** `schedule` @ 09:00 UTC | **all** eligible recipes | | anything else (other recipes' dirs, docs, …) | **none** — empty matrix, green no-op | - **Megatron *build* is per-recipe — only evo2 builds it.** Each recipe's own `.ci_build.sh` owns its build: **evo2** compiles/installs the mbridge `bionemo.evo2` (megatron) stack; a future HF-native **esm2** or custom **codonfm** builds its own thing, *no megatron*. So a codonfm/esm2 change never pays for the Evo2 megatron build — it runs only on an evo2 change (or an `sae`/nightly run, which tests every consumer, evo2 included). - **Container *image* is currently shared.** All matrix entries use one base image (`svcbionemo023/…pytorch26.04-py3-squashed`, dockerhub-cached → cheap after first pull) — the megatron-capable base evo2 needs. A future HF-native recipe *runs in* that base but doesn't build megatron on top. If a recipe later wants a lighter image, the matrix can carry a **per-recipe `image`** (like the repo-wide lane's `matrix.recipe.image`) — not needed while evo2 is the only active recipe. Net: **shared base image, per-recipe (megatron-or-not) build.** - Each eligible recipe runs on the **L4** against its **CI-sized model** (evo2 = the auto-built **1B**). The **7B/L26** path is manual (`EVO2_CKPT_DIR`), never in CI. - Not covered: the React dashboard frontend (no JS build step) and offline `extract`/`train` scripts (no unit tests). ## Validation Proven end-to-end on a real L4 via the NVIDIA-BioNeMo#1670 test PR: **https://github.com/NVIDIA-BioNeMo/bionemo-recipes/actions/runs/28535969801** — the L4 built via `.ci_build.sh` and ran the evo2 recipe suite green (**54 recipe tests passed**). The `changed-dirs` matrix logic was verified locally across 8 scenarios (per-recipe / sae-change / nightly / pre-NVIDIA-BioNeMo#1622-empty). Trigger = a maintainer commenting `/ok to test <sha>`. --------- Signed-off-by: polinabinder1 <pbinder@nvidia.com> Co-authored-by: root <root@nvidia-lepton040.cm.cluster> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
/annotate skipped the _engine_busy guard, so a concurrent annotate during a /generate or /gene_embed did not 409 — it silently blocked on the core GPU lock for the whole generation (annotate also takes that lock via encode). Route it through _run_cancellable like the other GPU endpoints so it fast-rejects with 409 "Engine busy" (and gains cancel-on-disconnect). All three GPU endpoints now behave identically. test_server.py 31/31. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CLI twin of POST /api/annotate: each feature's activation at every base (the dashboard
heatmap data), for ONE --sequence (JSON on stdout) or a --fasta (parquet), no server needed.
Reuses core.annotate + top_features (same path as the /annotate route, so no drift).
- single: JSON {sequence, tag_len, bases, layer, features:[{feature_id,label,max_activation,
activations[]}]} — tag applied then stripped via tag_len.
- batch: parquet, default per-feature list-column (sequence_id, bp, feature_id, label,
max_activation, activations[list]); --long = one row per (seq,feature,base). Raw/untagged like `batch`.
- --top-k (16) or --feature-ids to pick features. test_cli.py covers single/batch/long/validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feature_tracks() re-encodes the generated sequence to report per-feature activations, but with
no clamped features (fids empty) it still encoded the whole output only to return {} — a wasted
full forward pass on every unsteered generate(). Gate the encode on `fids`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
The importable Evo2SAE inference engine + feature steering — the base of the serve stack — with tests and a runnable (layer-cached) Docker image. A single Evo2 inference engine is loaded once and serves both paths:
encodereads the residual stream off a layer-Lforward hook;generatedrives the same model's decode with decode-only feature steering. This PR now also includes the FastAPI server + CLI (folded in from #1637, which merged into this branch); the dashboard (#1623) and steering eval (#1635) build on it.Rebased onto the post-#1633 top-level layout (
interpretability/sparse_autoencoders/).Architecture: one model, both paths
Earlier iterations loaded two copies of Evo2 — a truncated
post_process=Falsemodel for encode/highlight and the full inference engine for generate (~1.8× the weights). This collapses to a single engine (infer.setup_inference_engine, run eager withcuda_graph_impl="none"so the steering hook applies):load()builds the one engine and takesself.model = unwrap_model(comp.model)+comp.tokenizerfrom it._forward_hidden) runs a normal full-sequence forward and reads layerLoff a forward hook — the engine model ispost_process=True(it produces logits for generation), sooutput_embeddingscan't be used; the hook captures the same[S, B, H]module output the steeringclamp_hookreads, so encode and steer see identical activations by construction.self.model.decoder.layers[L]— the same module encode reads.Validated end-to-end on the 1B-8k-bf16 (21/21 tests, incl. a highlight↔steer interleaving test proving no state bleed between the shared model's encode forward and decode path). 7B fidelity is the remaining gate.
Note on the encode forward
The single engine model is
post_process=Truewith no embeddings-only mode, soencoderuns the full forward (all layers + the LM head) and reads layerLoff the hook in passing — the trailing layers afterLand the logits are computed and discarded. Cost is ~the layers pastL(e.g. 6 of 32 on the 7B, 6 of 25 on the 1B; the vocab-512 head is cheap). Negligible for interactive encode; a ~20% overhead only for batch encode — and bulk extraction doesn't use this path anyway (scripts/extract.pyreusespredict_evo2 --embedding-layer, which stops atL). An early-exit (raise from the hook to abort the forward) was left out deliberately: aborting mid-forward risks dirtying the engine's inference context (the no-bleed property) and would deadlock under tensor/pipeline parallel.GPU & scaling
The engine is single-GPU. Everything keys off one
self.device;serveis a singlepython -m evo2_sae.cliprocess (nottorchrun), pinned viaDEVICE/CUDA_VISIBLE_DEVICES. The 7B fits on one 80 GB H100, so model-parallel isn't needed for ≤7B.[S,B,H]hidden off the layer-Lhook (input_dim=4096); under tensor-parallel that hidden is sharded across ranks (SAE would see a fragment), and under pipeline-parallel layerLlives on a single rank (the hook fires nowhere else). Supporting it needs anall_gatherof the layer output + rank-aware hook placement — real work, only relevant for a model too big for one GPU (e.g. 40B).serveprocesses, each pinned to its own GPU (CUDA_VISIBLE_DEVICES) +PORT, behind a load balancer. Each is a full, independent engine.scripts/extract.pyundertorchrun --nproc_per_node Nis data-parallel (each rank a full model writing its own shards); this is how the 7B activation set was built.Contents
Engine + steering
src/evo2_sae/core.py—Evo2SAE:load → encode / encode_batch / feature_tracks / generate(decode-only clamp viasae.steering) + input-sanitization guards (_sanitize_steering: feature-id range, clamp-magnitude cap, non-finite/top_k/temperature coercion).encode_batchis length-bucketed (work sorted by token length to minimize padding waste on mixed-length inputs; results un-sorted back to input order).load()verifies the SAE'sinput_dimequals the model's hidden size (_model_hidden_sizevia config, or a 1-token forward) and raises a clear error on a mismatch ("wrong SAE/model pairing"), instead of a cryptic matmul failure on the first encode. Known gap: a wrong layer number with the same hidden size can't be caught here (the SAE checkpoint records no training layer) — it silently yields out-of-distribution features;/healthsurfaces the configured layer, and stamping the layer into the checkpoint at train time is a follow-up.sae/src/sae/steering.py— model-agnostic delta-clamp hook +steer().Server + CLI (folded in from #1637)
src/evo2_sae/server.py— FastAPI app (/health,/features,/annotate,/generate) under an/apiprefix, with an optional static-dashboard mount (used by evo2 SAE recipe: feature-explorer dashboard (viz) #1623).src/evo2_sae/cli.py+scripts/launch_inference.sh—serve/encode/batch/generateentry points (required env vars, no hardcoded paths).Build / run / CI
.ci_build.sh(env|install|all) +.ci_test_env.sh— build the env by delegating toevo2_megatron's own build (no fork of the pinned megatron stack), then installsae+ this recipe into that venv. The phase arg lets the Dockerfile cache the two steps separately.Dockerfile— thin, non-forking, layer-cached: the ~30-min mbridge megatron build is its own layer (depends only onrecipes/evo2_megatron), and the SAE source + editable installs are a separate trailing layer — so editing engine/SAE code rebuilds only the cheap install layer, not megatron. (+ a per-Dockerfile.dockerignore.)tests/conftest.py— 1B-8k-bf16 fixture (bionemo_load→run_nemo2_to_mbridge) + a synthesized tiny SAE, GPU-memory-gated; honorsEVO2_CKPT_DIR/SAE_CKPT_PATHfor manual / 7B runs. The GPU tests are gated by@pytest.mark.skipif(not torch.cuda.is_available()), so they run on a GPU box and skip otherwise.Dependency on
bionemo.evo2The engine reuses
bionemo.evo2's model code (the mbridgerecipes/evo2_megatronrecipe), which isn't pip-installable..ci_build.sh(and the Dockerfile) build it via evo2_megatron's own script; it's intentionally not inpyproject.toml, matching the codonfm/esm2 recipes (base model is environment-provided).Interfaces: CLI vs serve (and why FastAPI)
The same
Evo2SAEis exposed two ways viacli.py:encode/batch/generate) — load → do one thing → exit. Pays the model load per invocation; right for scripted/batch one-offs (batch: FASTA → parquet).serve— load once, serve many HTTP requests; right for interactive use (the dashboard). Carries the production machinery: single-GPU lock + bounded threadpool, 503-until-ready gate, body-size cap, and the CUDA-wedge supervisor.The FastAPI app is built by
build_app(engine)with the engine injected — prod passes a realEvo2SAE, the tests pass a torch-freeFakeEngineand drive the same app viaTestClient(no model, CPU-only). That DI is whytest_server.pylocks the full HTTP contract without a GPU.Why FastAPI (over Flask / gRPC / Gradio-Streamlit / Triton-TorchServe): the need is a thin JSON API over one GPU-bound engine, served from the same origin as a React SPA, testable without a model, in one container. FastAPI gives async + a bounded threadpool (
anyio, so concurrency caps cleanly to the single serialized GPU), Pydantic request validation, auto OpenAPI docs, and aStaticFilesmount for single-origin serving (/api+ dashboard at/, no CORS) — all out of the box. The alternatives are either too thin (Flask/raw — re-roll all of that), UI-coupled (Gradio/Streamlit — can't cleanly serve a separate React SPA + JSON API), browser-hostile (gRPC), or heavyweight model-serving platforms (Triton/TorchServe/Ray Serve) built for autoscale/multi-model/dynamic-batching that this single-model, single-GPU, single-container tool doesn't need. Those become worth it only if deployment grows to many replicas / multi-model — at which point FastAPI sits behind them.Error handling / failure modes
An escalation ladder — nothing leaves the server permanently broken; worst case is a brief 503 during a respawn.
MAX_BODY_BYTES) → 413 in middleware; over-context sequence → 413; non-DNA / unknown organism / out-of-range or negative feature id / bad annotate mode / missing pick ids → 400; wrong JSON shape → 422 (Pydantic). Some of these are safety, not UX: an out-of-range feature id would trip a CUDA device-side assert and a negative id would silently index the wrong feature via torch negative-indexing — so both are rejected before reaching the engine._require_ready()and/healthreturn 503 while the model loads (so k8s/LB readiness probes shed the pod). A startup failure is caught and logged;/healthsimply stays not-ready — process up, no crash loop._is_unrecoverable_cudadetects it → marks the engine not-ready (→ 503) → and ifEXIT_ON_CUDA_WEDGE=1, the worker exits andlaunch_inference.shrespawns it (backoff, ≤10 consecutive fails, SIGTERM/SIGINT forwarded fordocker stop/k8s). Recovery is a clean restart, host-independent.(The dashboard's
/gene_embedadds a partial-failure path — skip + report invalid sequences, all-invalid → 400 — documented in #1623.)How to run
Tests
There's no dedicated CI lane right now (deferred — it should later fold into the repo-wide recipe lane, which already runs
.ci_build.sh+pytest). Run them manually:test_core.py(engine plumbing —top_features,_load_sae,generateguards, the SAE/model dim check, encode_batch length-bucketing order) +test_server.py+test_cli.py(FastAPI app + CLI via a mockedFakeEngine) +test_steering.pysanitize guards +sae/tests/test_steering.py(exact clamp math). Quick CPU-only run without the venv:PYTHONPATH=src:../../sae/src pytest tests/test_core.py.test_steering.py— bf16 encode, generation in-distribution, steering changes the continuation (+compare_baseline), batched/empty-sequence encode, max-clamp stays finite, and highlight↔steer interleaving (encode bit-identical across a steered generate; baseline unaffected by history). Gated by@pytest.mark.skipif(not torch.cuda.is_available())— runs on a GPU box (megatron venv); setEVO2_CKPT_DIR/SAE_CKPT_PATHfor a specific model, else the fixtures build the 1B-8k-bf16 + a synthesized SAE.Base of
#1623 (dashboard) and #1635 (steering eval). (#1637 — server + CLI — merged into this branch and is now part of this PR.)
Note:
recipes/evo2/is co-owned with the eval stack (#1629)This PR owns the recipe's Dockerfile /
.ci_build.sh/src/evo2_sae(engine + server + CLI) +tests/conftest.py; the eval stack (#1629, which absorbed #1636) addseval/probing(labelers, probe harness) and itsbiopython/pyrodigaldeps to the samerecipes/evo2/. The branches are reconciled — thepyproject.tomldep union is already resolved on #1629. Merge order: this PR first, then #1629 / #1635 / #1623 rebase ontomain.