Skip to content

evo2 SAE: inference engine + steering + server/CLI, tests, Dockerfile#1622

Open
polinabinder1 wants to merge 20 commits into
mainfrom
pbinder/evo2-sae-serve
Open

evo2 SAE: inference engine + steering + server/CLI, tests, Dockerfile#1622
polinabinder1 wants to merge 20 commits into
mainfrom
pbinder/evo2-sae-serve

Conversation

@polinabinder1

@polinabinder1 polinabinder1 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

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: encode reads the residual stream off a layer-L forward hook; generate drives 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/).

Stack update (2026-06-29): #1637 (FastAPI server + CLI) was merged into this branch — its code (server.py, cli.py, their tests, launch_inference.sh) is now part of this PR. This PR's scope is therefore engine + steering + server + CLI.

Architecture: one model, both paths

Earlier iterations loaded two copies of Evo2 — a truncated post_process=False model 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 with cuda_graph_impl="none" so the steering hook applies):

  • load() builds the one engine and takes self.model = unwrap_model(comp.model) + comp.tokenizer from it.
  • encode/highlight (_forward_hidden) runs a normal full-sequence forward and reads layer L off a forward hook — the engine model is post_process=True (it produces logits for generation), so output_embeddings can't be used; the hook captures the same [S, B, H] module output the steering clamp_hook reads, so encode and steer see identical activations by construction.
  • generate steers on 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=True with no embeddings-only mode, so encode runs the full forward (all layers + the LM head) and reads layer L off the hook in passing — the trailing layers after L and the logits are computed and discarded. Cost is ~the layers past L (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.py reuses predict_evo2 --embedding-layer, which stops at L). 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; serve is a single python -m evo2_sae.cli process (not torchrun), pinned via DEVICE / CUDA_VISIBLE_DEVICES. The 7B fits on one 80 GB H100, so model-parallel isn't needed for ≤7B.

  • Model-parallel (TP/PP) is not supported as written. The SAE consumes the full [S,B,H] hidden off the layer-L hook (input_dim=4096); under tensor-parallel that hidden is sharded across ranks (SAE would see a fragment), and under pipeline-parallel layer L lives on a single rank (the hook fires nowhere else). Supporting it needs an all_gather of the layer output + rank-aware hook placement — real work, only relevant for a model too big for one GPU (e.g. 40B).
  • Data-parallel serving scales trivially, no code change — run N independent serve processes, each pinned to its own GPU (CUDA_VISIBLE_DEVICES) + PORT, behind a load balancer. Each is a full, independent engine.
  • Offline bulk extraction already runs multi-GPUscripts/extract.py under torchrun --nproc_per_node N is 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.pyEvo2SAE: load → encode / encode_batch / feature_tracks / generate (decode-only clamp via sae.steering) + input-sanitization guards (_sanitize_steering: feature-id range, clamp-magnitude cap, non-finite/top_k/temperature coercion). encode_batch is length-bucketed (work sorted by token length to minimize padding waste on mixed-length inputs; results un-sorted back to input order).
  • Load-time SAE/model fit checkload() verifies the SAE's input_dim equals the model's hidden size (_model_hidden_size via 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; /health surfaces 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 /api prefix, with an optional static-dashboard mount (used by evo2 SAE recipe: feature-explorer dashboard (viz) #1623).
  • src/evo2_sae/cli.py + scripts/launch_inference.shserve / encode / batch / generate entry 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 to evo2_megatron's own build (no fork of the pinned megatron stack), then install sae + 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 on recipes/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_loadrun_nemo2_to_mbridge) + a synthesized tiny SAE, GPU-memory-gated; honors EVO2_CKPT_DIR / SAE_CKPT_PATH for 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.evo2

The engine reuses bionemo.evo2's model code (the mbridge recipes/evo2_megatron recipe), which isn't pip-installable. .ci_build.sh (and the Dockerfile) build it via evo2_megatron's own script; it's intentionally not in pyproject.toml, matching the codonfm/esm2 recipes (base model is environment-provided).

Interfaces: CLI vs serve (and why FastAPI)

The same Evo2SAE is exposed two ways via cli.py:

  • CLI one-shot (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 real Evo2SAE, the tests pass a torch-free FakeEngine and drive the same app via TestClient (no model, CPU-only). That DI is why test_server.py locks 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 a StaticFiles mount 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.

  • Bad input → 4xx, the engine never sees it. Oversized body (>16 MB, 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.
  • Not ready → 503. _require_ready() and /health return 503 while the model loads (so k8s/LB readiness probes shed the pod). A startup failure is caught and logged; /health simply stays not-ready — process up, no crash loop.
  • Unrecoverable CUDA wedge → self-heal. A device-side assert poisons the CUDA context process-wide (uncleanable in-process). _is_unrecoverable_cuda detects it → marks the engine not-ready (→ 503) → and if EXIT_ON_CUDA_WEDGE=1, the worker exits and launch_inference.sh respawns it (backoff, ≤10 consecutive fails, SIGTERM/SIGINT forwarded for docker stop/k8s). Recovery is a clean restart, host-independent.
  • Other uncaught exception → 500, server continues. Model calls are serialized behind a lock, so one failed request can't corrupt shared state for the others.

(The dashboard's /gene_embed adds a partial-failure path — skip + report invalid sequences, all-invalid → 400 — documented in #1623.)

How to run

# Build once from the repo root, then run with a GPU:
docker build -f interpretability/sparse_autoencoders/recipes/evo2/Dockerfile -t evo2-sae .
docker run --gpus all -it evo2-sae bash -lc "source .ci_test_env.sh && pytest tests/"

On build time / making it easier. The engine needs bionemo.evo2 (the mbridge
evo2_megatron recipe), which isn't pip-installable — so the first docker build
compiles the full megatron stack (megatron-bridge, causal-conv1d, …) and takes ~30 min.
After that, the build is layer-cached: editing engine/SAE code re-runs only the two
editable pip installs (seconds), not the megatron compile. Other shortcuts:

  • Build once, reuse: push the built image to a registry; coworkers docker run it and never rebuild.
  • Skip the compile: the Dockerfile's ARG BASE_IMAGE can point at a prebuilt evo2_megatron / bionemo image once one exists — the build then collapses to just the two pip installs.
  • No container at all (dev): inside an existing megatron env, pip install -e sae/ && pip install -e recipes/evo2/ (what local validation does).
from evo2_sae import Evo2SAE
eng   = Evo2SAE(evo2_ckpt_dir, sae_ckpt_path, layer=19).load()    # 1B layer 19 (7B: 26)
codes = eng.encode("ATGGCC...")                                    # [S, n_features], sparse (TopK)
out   = eng.generate(prompt="ATGGCC...", features=[{"feature_id": 123, "strength": 200}])

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:

cd interpretability/sparse_autoencoders/recipes/evo2
bash .ci_build.sh && source .ci_test_env.sh   # build + activate the megatron venv
pytest tests/
  • CPU (no model): test_core.py (engine plumbing — top_features, _load_sae, generate guards, the SAE/model dim check, encode_batch length-bucketing order) + test_server.py + test_cli.py (FastAPI app + CLI via a mocked FakeEngine) + test_steering.py sanitize 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.
  • GPU: 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); set EVO2_CKPT_DIR/SAE_CKPT_PATH for 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) adds eval/probing (labelers, probe harness) and its biopython/pyrodigal deps to the same recipes/evo2/. The branches are reconciled — the pyproject.toml dep union is already resolved on #1629. Merge order: this PR first, then #1629 / #1635 / #1623 rebase onto main.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 7c2e469a-96df-4765-ba83-fda964a7c92b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces sparse autoencoder (SAE) feature steering capabilities for the Evo2 foundation model, along with a complete inference recipe. It adds a reusable clamp_hook steering primitive that injects only the delta between clamped and original SAE reconstructions, applies it to a new Evo2SAE inference engine supporting encoding and steered generation, and refactors FASTA parsing into a shared utility.

Changes

Evo2 SAE Steering and Inference Recipe

Layer / File(s) Summary
Build configuration and runtime dependencies
bionemo-recipes/.../evo2/pyproject.toml
Added fastapi>=0.110, uvicorn>=0.29, pandas>=1.5 to project dependencies and enabled setuptools package discovery under src/.
SAE feature steering primitives
bionemo-recipes/.../sae/src/sae/steering.py
New clamp_hook forward hook re-encodes activations through the SAE, clamps specified feature codes, and injects the delta between clamped and original decoded outputs. steer context manager registers/removes the hook reliably.
SAE steering unit tests
bionemo-recipes/.../sae/tests/test_steering.py
Validates delta-clamp correctness (no-op leaves unchanged, real clamp matches analytic delta), tuple output isolation (only hidden state modified), and decode-only mode (skips prefill).
Evo2SAE package API and lazy loading
bionemo-recipes/.../evo2/src/evo2_sae/__init__.py
Public API via __all__ constraint on Evo2SAE, clean_dna, DEFAULT_ORGANISM_TAGS with module-level __getattr__ for lazy core module loading.
Evo2SAE core inference engine
bionemo-recipes/.../evo2/src/evo2_sae/core.py
Main Evo2SAE class loads truncated Evo2 model + SAE checkpoint, supports tokenization, single/batch encoding to SAE codes, feature extraction, top-k feature selection, and generation with optional decode-time SAE steering via delta injection.
Shared FASTA parsing utility
bionemo-recipes/.../evo2/src/evo2_sae/fasta.py
Streaming read_fasta() reader transparently supports plain and gzip-compressed FASTA, yields (seq_id, sequence) tuples, and auto-generates sequential IDs for headerless records.
FASTA integration in chunk script
bionemo-recipes/.../evo2/scripts/chunk_fasta.py
Updated to use shared read_fasta() from evo2_sae.fasta instead of local parse_fasta().
Evo2 SAE recipe integration tests
bionemo-recipes/.../evo2/tests/test_steering.py
CPU test validates clamp-hook arithmetic. GPU tests verify encode produces finite positive codes, unsteered generation produces valid DNA (ACGTN), and steering changes continuation deterministically.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • NVIDIA-BioNeMo/bionemo-framework#1621: Both PRs modify the Evo2 recipe's FASTA chunking pipeline by refactoring parse_fasta logic in scripts/chunk_fasta.py; this PR removes the local parser in favor of a shared evo2_sae.fasta.read_fasta utility.

Suggested labels

ciflow:all

Suggested reviewers

  • jstjohn
  • pstjohn
  • jwilber
  • trvachov

🐰 A SAE hook so clever, it clamps with a delta,
Evo2 now steers genes with precision so bright—
Encoding and clamping, a feature to frame,
Generation refined: DNA shaped just right! 🧬

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title captures the main engine and steering work, though it is broader than the actual diff.
Description check ✅ Passed The PR description is detailed and covers scope, architecture, usage, and testing, though it does not fully follow the repository template sections.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pbinder/evo2-sae-serve

Comment @coderabbitai help to get the list of available commands.

@jwilber

jwilber commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Have you tried this? any examples you can share/screenshots?

polinabinder1 added a commit that referenced this pull request Jun 10, 2026
…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>
@polinabinder1

polinabinder1 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

@jwilber This only deals with the steering backend. The visualization is in PR 1623.

polinabinder1 added a commit that referenced this pull request Jun 10, 2026
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>
@polinabinder1 polinabinder1 force-pushed the pbinder/evo2-sae-serve branch from 91d1e30 to de81106 Compare June 11, 2026 00:28
polinabinder1 added a commit that referenced this pull request Jun 11, 2026
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>
polinabinder1 added a commit that referenced this pull request Jun 11, 2026
…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>
polinabinder1 added a commit that referenced this pull request Jun 11, 2026
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>
@polinabinder1

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (8)
bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py (1)

37-50: ⚖️ Poor tradeoff

Consider 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 win

Add 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 win

Add 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 win

Add field docstrings to AnnotateRequest.

The class is missing Google-style field docstrings. Each field should document its purpose, especially fields like mode that 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 win

Add 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 win

Add 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 win

Add 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 win

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between e407165 and de81106.

📒 Files selected for processing (8)
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/pyproject.toml
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_inference.sh
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/__init__.py
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/cli.py
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.py
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py
  • bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_steering.py

polinabinder1 added a commit that referenced this pull request Jun 11, 2026
…_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>
@polinabinder1 polinabinder1 force-pushed the pbinder/evo2-sae-serve branch from 5f4fce4 to 4a0de59 Compare June 11, 2026 20:18
polinabinder1 added a commit that referenced this pull request Jun 11, 2026
…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>
@polinabinder1 polinabinder1 marked this pull request as draft June 11, 2026 21:18
@copy-pr-bot

copy-pr-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

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.

polinabinder1 added a commit that referenced this pull request Jun 24, 2026
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>
polinabinder1 pushed a commit that referenced this pull request Jun 24, 2026
…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>
polinabinder1 pushed a commit that referenced this pull request Jun 24, 2026
…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>
polinabinder1 pushed a commit that referenced this pull request Jun 24, 2026
…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>
polinabinder1 added a commit that referenced this pull request Jun 24, 2026
…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>
polinabinder1 added a commit that referenced this pull request Jun 24, 2026
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>
jwilber pushed a commit that referenced this pull request Jun 29, 2026
## 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>
polinabinder1 pushed a commit that referenced this pull request Jun 29, 2026
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>
@copy-pr-bot

copy-pr-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

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>
@polinabinder1 polinabinder1 requested a review from trvachov June 30, 2026 16:46
@polinabinder1 polinabinder1 changed the title evo2 SAE: inference engine + steering, CI lane, tests, Dockerfile evo2 SAE: inference engine + steering + server/CLI, tests, Dockerfile Jun 30, 2026
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>
polinabinder1 added a commit that referenced this pull request Jul 1, 2026
… 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>
polinabinder1 added a commit that referenced this pull request Jul 1, 2026
… 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>
polinabinder1 added a commit that referenced this pull request Jul 1, 2026
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>
polinabinder1 added a commit that referenced this pull request Jul 1, 2026
#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>
polinabinder1 added a commit that referenced this pull request Jul 1, 2026
… 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>
polinabinder1 added a commit that referenced this pull request Jul 3, 2026
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>
pstjohn pushed a commit to pstjohn/bionemo-framework that referenced this pull request Jul 7, 2026
…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>
polinabinder1 and others added 3 commits July 9, 2026 17:06
/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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants