diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/.gitignore b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/.gitignore
new file mode 100644
index 0000000000..a78b142383
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+package-lock.json
+dist/
+.vite/
+
+# generated dashboard data (build it from your SAE/annotations, do not commit)
+public/*.parquet
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/README.md b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/README.md
new file mode 100644
index 0000000000..fe70b3a9a4
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/README.md
@@ -0,0 +1,27 @@
+# Evo2 SAE Feature Explorer (front-end)
+
+Interactive dashboard for Evo2 SAE features — feature atlas, sequence inspector, and
+generative steering.
+
+This directory is the **front-end only**. Its backend is the standalone
+[`evo2_sae`](../src/evo2_sae) engine — the viz is just a UI over its
+`serve` mode, so there is no model code here.
+
+```bash
+# 1. Backend: loads Evo2 + the SAE and serves the HTTP API on :8001
+../scripts/launch_inference.sh serve # or: python -m evo2_sae.cli serve
+
+# 2. Dashboard (from recipes/evo2): stages data (if any) + starts Vite
+python ../scripts/launch_dashboard.py # inspector + steering tabs
+python ../scripts/launch_dashboard.py --data-dir /path/to/data # + Feature-atlas tab
+```
+
+`launch_dashboard.py` is the entry point — it validates/stages the atlas parquets into
+`public/` (when `--data-dir` is given) and runs Vite. The **inspector** and **steering** tabs
+work with no atlas data (they call the backend); the **Feature-atlas** tab needs the three
+parquets (`features_atlas`, `feature_metadata`, `feature_examples`) via `--data-dir` —
+producing them is a separate offline step. (`npm install && npm run dev` also works for raw
+front-end dev, but skips data staging.)
+
+The Vite dev server proxies `/api` → `http://localhost:8001` (see `vite.config.js`); point it
+elsewhere with `VITE_BACKEND`. Configure the backend via the env vars in `launch_inference.sh`.
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/index.html b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/index.html
new file mode 100644
index 0000000000..3002f48025
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/index.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ Evo 2 SAE Feature Explorer
+
+
+
+
+
+
+
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/package.json b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/package.json
new file mode 100644
index 0000000000..eecfcaa1c8
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "evo2-sae-dashboard",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@uwdata/mosaic-core": "^0.21.1",
+ "@uwdata/mosaic-sql": "^0.21.1",
+ "@uwdata/vgplot": "^0.21.1",
+ "embedding-atlas": "^0.16.1",
+ "lucide-react": "^0.577.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "umap-js": "^1.4.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.2.0",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/public/sequence_library.json b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/public/sequence_library.json
new file mode 100644
index 0000000000..b5ee2357d7
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/public/sequence_library.json
@@ -0,0 +1 @@
+[{"symbol": "transcript:ENST00000454482_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "CCCGGAAGAGCCCAGGATATTCGGGGACCCGCACCGGACAGGCGGCCCAGGCCCCCTGGCGCCCAGCTGGCCGCCCTGTTGCAGCTCTCATGCCAAGACCTGCGCCTAACGGCAGGGATTCCGGCGGTCATCATTTATTCTTGGCGCTAAGCTGCCGCTTCTCTTTTTTATCACCACAGAGTAACGCAGACCCCACCGTTTCCTTCCATTCTGAGCCAACCACTCCAGCAGCCACCGGGGCGCCAGGTTATGCTGGGGCTGCACCACCCGGCCCGAGGCTTTTGAAGCCACGCCTGAGCGCGGAGGATGGGCAGGATTTGCCCCGTGAACTCCCGCGCACGCAGGCTCCGCGCGAGGCCCGGGCGCCCGAGCGGCGATTCCCTCCCCTATCACCAGCTCC"}, {"symbol": "transcript:ENST00000658179_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "GAGTCCTTGCCATGGGGTCTCTCCTTCAGCAGGTCTGGGTGGGGCCGGAGACTGTACCCCACAAAAGGTCCCAGGTGAGGCGGATGTGGCCTGGCGCTGTGTGGCTCTGGACCTAGTCCTTGGGCTTGGGCTGGCACCCAGGGCCTGGGCTTGAGACAGCTGTGACGCAGGCAAGCCATTTACCCCGTTTGTGGGGACATTACATCTTCCTAGCTTGGAACACACAGGCAGCCAGGGTTGTTATCCACATTCCTCCTCCATGTTCTTCTCTTGAGAACTTTTACCAGGTATGTCAGGAGCTGGGCTCCACCAGGGAGACTCAAGTGGAAAGCCCTCATCCTTGTCCTCCAGGAGACAGGAAAACCTATGGTTACAATTCCAGGGACAAGAGCGATGCATG"}, {"symbol": "ADARB1_exon", "label": "exon", "species": "Homo sapiens", "sequence": "CCCAGACTGGGCCCGTGCACGCGCCTTTGTTTGTCATGTCTGTGGAGGTGAATGGCCAGGTTTTTGAGGGCTCTGGTCCCACAAAGAAAAAGGCAAAACTCCATGCTGCTGAGAAGGCCTTGAGGTCTTTCGTTCAGTTTCCTAATGCCTCTGAGGCCCACCTGGCCATGGGGAGGACCCTGTCTGTCAACACGGACTTCACATCTGACCAGGCCGACTTCCCTGACACGCTCTTCAATGGTTTTGAAACTCCTGACAAGGCGGAGCCTCCCTTTTACGTGGGCTCCAATGGGGATGACTCCTTCAGTTCCAGCGGGGACCTCAGCTTGTCTGCTTCCCCGGTGCCTGCCAGCCTAGCCCAGCCTCCTCTCCCTGTCTTACCACCATTCCCACCCCCGAG"}, {"symbol": "PDE9A_intron", "label": "intron", "species": "Homo sapiens", "sequence": "CACACATACACTTACACTCACAGTCACATACACCCACATTCACACACATACACTCACATTCACACGCACATACACTCTGACACACATATACATACATTCACTCACACATACATTCACACACACACGGATGCATTAGTTTTCAAGTCAGGATCCTAGCACAGCCCACACCCTGCATTTGCTTGGTGTCTCCTGAGGTCTCTAGTTATACTTCCCATCCCTTTTCTTGCCATGTATTTCCTGAACACATGTTATCTTTGATCCTTAAACCATGACTTAGAGATACAGATGTATGACAAGCTGAGAGATGATGTGAGACTGTTGTTAATTTTTTCTCCATGTACTAATTACATTGTGGTTATCTTAACCACAAAGGAAGCGTTCTACTCCAGAGAACCGCGTG"}, {"symbol": "ETS2_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "GTACTAGCCACTGGTTGAGAGCTCTGTAGCCACACATGGCAAGTGGGTACCATATTGCTTAGCCCAGGCCTAGTAGAAGATTAAGACTCAGTCAGAGGAAGTAAGGCAGATCCGTGCTTGGAATCAGTTCCTCTTGAAGAAGTGCACAGCCAAGCAACACACAAAGCAGAAATATTTTGAGCTCTGTATTCAAAGCAGGAAGATCTGGGACCATGTAAGGGGGTTTGGTCTTCAGTTCTCCCTTTGATCCCTTCCCTGGGTGTGTGCTTTCTTACAGCCTGGAGAGTGGAAGGGAAGAGAGGGGGCTTCCTGGACTCCTTTCCTGTCTCTAGAAATGACCCCTGCAGCTGTCCTGAAGCTCTCAGGTTAAGCTGATTGTGTCCAGAGCAGACAGGAGGCG"}, {"symbol": "GET1_intron", "label": "intron", "species": "Homo sapiens", "sequence": "ACAGGGAAAGGACCCAGTTCGGAATCATGCATTGCATTTCCCGGCCTCTAGCCTCCTGTCTGGAGGAGTTTCCCAGTCTTCCCTTGACTTTCATGGCTGTCATTTGTGGGTGTCAGAGGCCAGTGATTTCGCAGAAGGGACGCCTACTGGCATGTCTGATACTTGGTAGGGATTTCTGAGAGGCAGTGCTGTGTTCTCATTGTGTCTCATCCGCTGGTGTGGGACTGGGATTTGGGGATGTTCGCTGATCCCTTGGGGTAGGTGCCAGAGGCGCCACTGAGGGATCTGGTCTTGGGGCGCCCGCGCAGTCCTTGGACTAGGCCTTCCCGGCAGATCTGCGGGCGCCACCTGGCGGTCACTGCCGACTCCTCCGTTGCGCTCCAGTCACCTCTCCCATCCC"}, {"symbol": "ZBTB21_exon", "label": "exon", "species": "Homo sapiens", "sequence": "ACTCCTGTCAATGCTTTGGTTTCCAGAACCAGATCCACTGGATGGGATCACTAAGCCTAACTTTGAATAGTACAACAAGTTTCTATCTTCACCTTGACCATTTCCTTTGTTAGTTTCTTTTAATAGATAGGGAGTCTCTGATGAGCTACAAACAGACAAAACAGGTGGCCGTGGTCTCTTCAAAGCCAGCTCTAGAGCTTTTCCTTTTGGAAGCTGACCACTCACACCTGGTTTATCATCCATAGCTTCTCTGTCTTGCAGAGGCTTTGAAGGCAACACTGCATTTCTTTTCACCAAACTGATTCTATTAGGATCATCCAAAGATCCAGAATGCTCAAGAGACTTTGCATATACCACAGAACTATCTTTCGGCCAACTCTTTTCAGTTAATGACAAATTA"}, {"symbol": "B3GALT5_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "TCAAAGAACCGGCCTCAGGGGATCTGAGCAAATTCCACCGTCGTCAATTCCTGGTGCTCCGTGAAAGATGCTCTTTCCCTCGGGGCTTCGGAGCCGGAGCATTTGTAGACAGTTCCTGCCTCTTCACTCGGCTGCCGGCCTGGGGGTCCCGTCGCCTCCCGGAGCCGCCCGCCGCGTCCCCCAGCCCGCGCCGCCCTGCGCGGCCCGCCGGCCTCCTCACCTATCAGATTTCCTGTGCGCATCGCCGCGCGCTCCTCCCACCTCCTAAATGCGGAGCGGAGCGGGGAGCAAAACCATCCTCGGCCTGGACCCAGCGCCTCCGGGGACCGGCCGCGCGCCCCCTGCGTCCGCGGGCCGGGATGCGGCTCTGAGCCAGCGGCGGCTTCCAGCCCGACTGGGC"}, {"symbol": "ERG_intron", "label": "intron", "species": "Homo sapiens", "sequence": "CACTTTCTCTAGAAGCCAGGGGTGGTGCAGAAGATCCCGTGTGCATGGCCTGGACCCTCTGCAAGGCAGCAGGTGTGTCCAGCAGGTGTGTCCTGAATGGAAGTCTCTGCCCCGAGGCTCAGTGAGGCTCAGGCACCACTTACTGGAGGGGCAGGAAAGCATGGTGGTAGGGAGCACCAACTTTGGAGTTGCCACTGCTTCAGCTGAATCCTGCCTCTGCCCTTTGTGGGACGTTTGGCAACTCTCGCAGCTCTCTCTGCCTCAGTATCGTCATCTGTAAAGTGGGGGTGATGGTAATAGTACTATCTTGCAAGATCATGGTAAGGATGAACTCAGTACTTGAATGGTCTTGTCAGAATTTCTTTGGGCTGAAAATCAGAGTTTAAGGAAAGATAACACT"}, {"symbol": "MORC3_exon", "label": "exon", "species": "Homo sapiens", "sequence": "ACTGGTTCAACAAGCACCTCATCATCCCGATGCGACCAGGGAAATACTGCAGCTACCCAGACTGAAGTACCAAGTTTAGTTGTTAAAAAAGAAGAAACTGTTGAAGACGAGATAGACGTAAGAAATGATGCAGTGATTCTGCCCTCCTGTGTAGAAGCTGAAGCAAAGATACATGAAACCCAGGAAACCACCGATAAATCTGCAGATGATGCAGGCTGCCAATTACAAGAACTGAGAAACCAGCTACTCCTTGTCACTGAGGAAAAAGAGAATTATAAAAGACAGTGTCATATGTTTACTGATCAAATCAAAGTGTTACAACAGAGGATACTAGAAATGAATGACAAGTATGTTAAGAAAGAAACTTGCCATCAGTCCACTGAAACCGATGCTGTATTTT"}, {"symbol": "B3GALT5_intron", "label": "intron", "species": "Homo sapiens", "sequence": "CTCCACATCTTCCTCATTTACTATTTTTGTCTTTTTCAAAGAGATGGAGATGGAGGAGGAGTAGAATTTAATGAAGATACAGATCTGGGGAAGTGTCATGTTTCGTTGGGTGCTTGGGGTAGGCAGTGGCTGCTGTTGAGAGTATTTTACAGATGTAGGAAGTCTTCGGAGGGGCATTTCTAGGTAAATGCATGCTGATTCTGCCCATCTTTACATGTACCTTTTGTGGTGTTTCCTCTTTAAAACAGGTGCGTGTAATATGCAAATTTGGTTTCAAAGGGAGAATTTTATATCTTTTAAGGAATGATTGCATGTGTATTAAGTCTTGTTTCCTATCTCCTTTCATTTTTTCCTTCTCTTTTAAAGTCAGTTCGTCTTAAGTAGTTTCTTTGTTAGGTAA"}, {"symbol": "SETD4_exon", "label": "exon", "species": "Homo sapiens", "sequence": "TATGATTCAGCAGGTCCAGGTACGGAGCGAGTGCACAGGTGTCCGGCTCTGCAGAAAGGCATTCCCGCTGCCTGGGCCTCAGGTACACGGCTCTGGTGTTGACGGTGCACCAAGCCCACAGCAGGGCACTGTAGCTGAAGATGCTGTCAACAGCCTCCGCAAACAGAGGCTGCAGAGAAGAGAAAAAGTCTCTGGAGGAAGCAAAGAACTCCTGCACGTGGGCTCTCTGCTCTTCAGCCTTTGCTTTTAAAGATTTGGGAAGAAGGTTCACCACTTCCGGCTCCAAACAAACAGGGCAGGTATACGCCTTGGGTAAAATCTCCAGGTAAGGCTTCCAAAGAGATCGGTGCCCAGCATGCTTTTCTGAAACTAAAAAGGTGCACAGCGCCAGCAGAGGAGA"}, {"symbol": "KCNE1_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "GTTGTTCCCTGGAGAGGAAGGCTTATTTCCAGAAAGTACTCTGACGTTTTGTAATTTGTGATTTCTATCACTGGCTCAGTGGCCTACCCACTGCTCAGCTCATGTGGCATGACCAGGCTGGGGCGTCGGGTCATCTTGTTCTCTGGGTGTGTGGCAACACTGGTTTATGGCGTAATTTCCTTCCTGAGAAGAAATTCAGTAATTTCTAATATTCAGGGCTTTAGGTAGAGGCCTGAAAATCACTCTCCATGCCTCTTTCAGTATTTTATATATAGTTGTCTCTTGGTATCCATGGGGTTTGGTTCCAGAATCCCCTGCAGATACCAAAATTTGCGAATGTTCAAGTCCTTTATATATAGTGGCATAGTATTTGCATATAACCTATGCATATCAGCCAATC"}, {"symbol": "CHODL_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "ACTCAACAAGGGACAGTGTAAAGGCTAATTTTAGGTGTACACTTGACTGCATTAAGTAATATCCAGAGAGCCGGTAAAGCATTATTTCTGGGTCTATGAGGGCGATTTTGGAAAAGATTGACATTTGAGCCAGCAGACTACGTAAGGAAGATCCACTCTCACTGAATGTGAGCGAGCACCATCCAATCAGCTGAGGGCCCAGATAGAACAAAAAGGCAGAAGAAAATCAGATTCACTCTCTCTCTTCTGGAGCTGAAACAACTTTCTTTTCCTGCCATAGGACATCAGGACCCCAGGTTCTCTGGTCTTGGGACTCTGAGACTTGCACCAGGAATCCTGCCCAGGCTCTCAGGCCTTTGGACTCAGACTGAGCTACTTCACTGGCTTTCCTGGTTCTCCA"}, {"symbol": "CHODL_intron", "label": "intron", "species": "Homo sapiens", "sequence": "CAGCAAAGTCTCAGGATACAAAATCAATGTGCAAAAATCACAAGCATTCCTATACACCAATAATAGACAAACTGAGAGCCAAATCATGAGTGAACTCCCATTCACAATTGCTACAAAGAGGATAAAACACCTAGGAATCCGACTTACAAGGGATGTGAAGGACCTCTTCAAGGAGAACTACAAACCACTGCTCAAGGAAATAACAGAGGTCACAAACAAATGGAAAAACATTTCATGCTCGTGGATAGGAAGAATCAATATCGTGAAAATGTCCATACTACCCAAAGTAATTTATAGATTCAATGCCATCCCCATCAAGCTACCATTGGCTGTTTTCACAGAATTAGAAAAAACTACTTCAAATTTCATATGGAACCAAAAAAGAGCCCATATAGCCAAG"}, {"symbol": "LTN1_exon", "label": "exon", "species": "Homo sapiens", "sequence": "CTGTTTTTCATCACCAAGTAGCATTTTAAATACTCGGCTTGAAGAAAAGGAGTCAAGCAGAGTAGAAAGAAACCTTAGATGTTGCTCTGACTTTCGTTCATTGACATAATTAATACTTATATCTGCGAGTTTACAGACTAAGTCTTCCAAAGGTTTTTTCCTTAGAGGAGACAAAAGGCCTGAAGAATTATGAGTGAGAGAAGGTTCAGTTGTTAATTCCCAGCCTTCAATCTTCTCTCCTTCTGAAGATACACATTTTTCATTCTCTTTATTGCTTTCAAGTATCTCATCAGCAAATCTAACCTTACCATTTTTTTTTTTACTTGACTTCAATGAGCTCTTCGGCTTCTGAAGCACCTGTAATAGGTTAGATACACCCAAAACGGACTCAACATCAGCT"}, {"symbol": "intergenic_11914583", "label": "intergenic", "species": "Homo sapiens", "sequence": "TGATGTCTGCATTCAAGTCACAGAGTTGAACATTGCCTTTCATAGAGCAGGTTTGAAACGCTCTTTTTGTAGTATATGGAAGTAGACGTTTGGAGTGCATTGACGCCTACGGTGAAAAGGGAAATATCTTCCCATAAAAACTAGACAGAAGCATTCTGTGAAACTTGTTTGTGATGTGTGTACTCAACTAACAGAGTTGAACCTTTCTTTTTACAGAGCAGTTTTGAAACACTCTTTTTGTAGAATCTGCGATGGGATATTTGGATACATTTCAGCATTTCGTTGGAAACGGGAATATCTTCATATAAAATACTCGACAGAAGCATTCTCAGAAACTTCTTTGTGATATGTGCATTCAAGTCACAGAGTTGAATATTCCCTTTCACAGAGTAGGTTTGAA"}, {"symbol": "transcript:ENST00000615324_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "GAGCCTTTGCCAAAGTAAATCAAAGCACTCCAGCAGCCAGCTTATTAGGTTTAATCATCAACTGTGCTTATCAGATCCTTCATTTGTAAGTCCCTTGAAGAAAAAAAGTGCAAAATGATTCCTTTTGTGCTTTGGAGATTGATATCAAAGCAGGCCTTGAGGTCAACTACAGAGGTGCTTTAAGTGGAAATTATAAGCAGGAAACAAAATGCACTCATTCAATTTCTTCAAGTTGAGTGGAGACCTCCTGGATGCCACGTGTCAGCTTTACCTGCTAGAAACACACGCTCACCTCGCTGTCCAAGACCCTGGAAAGGGGAACCCCTTGAATTGATTGGATTTTGCTCCCCTTGGCCAATTCTTCACTATGGTTTAGAGATTTGTGGTCTTCCAGTGGCAT"}, {"symbol": "MCM3AP_exon", "label": "exon", "species": "Homo sapiens", "sequence": "GAAGCTACTGAAGCTATTATTAGAACTTCCAAATATTGACTTAGGTCCTCTCTTCTCTTCCTCTACATTTTGGTTTGACAAAGCAGGGGTAAAGGCAGATAATGAATTATTACTACTAACAGGTTTTGAAAAGGTAAAATTTGAAGTGGTAGCTGAACTACTTGTTACTTGAGGAAAAGAGAAAGGGGCCAGGCCTCCAGGTGCACTACTAATTGGGTGGGAAAATGTAAAAAACCCAGAAGCAATTTGGCTCTGGGTTTTCTCTGGCTCAGATTCAGCCCCCAGTATTGGTTTGAACACTGCATTTTCCAGAGGTTTAAAGCTGAATTCTGTTTTCCCAAAACCAGAGTTCACTATTTCTCCAGCTTCTTGTCCAAAAGCAGAAGTGCTTGGGAAAGCC"}, {"symbol": "intergenic_34184372", "label": "intergenic", "species": "Homo sapiens", "sequence": "ACCGAGGTCAGGATTCTCCTCTGTACTTGGCAAGACAGGAAAGACAGCAGACCTATGTGGAGCACGTGAGCTGGGAGCTGCTGTTCTGGAAACCTTCCCTCCCACTGTATTTGCCTGCCCTTGTCCCTTACACAGTTCCATTTCATGTCACCCGACTTCTTTCCTTTCCCTGGTTACTGATAGGGCAAAAGAAGATGAGCTGGACCAACTCCCTTTTTCTTCCTAGAGATTTGAACTAAGAAACAACCAGATACATTTCAGTCCGTGATGGGAAGAGGTTTGCGGCTGGCTGTGAGCACTTCACACATTCACAAATCACGCACTTCTAGTTGGCTTTCTTGTGATGGCGAAAATACTTTCTTTTTGGTTTCATGATGCATTTATTAACCATTCTATTTTT"}, {"symbol": "transcript:ENST00000715450_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "TAGTTCCATTTAGGTTTTAGCTTAGAAGGCCAAGGACATTGCCACTTAGGGAGAATCTCTGCCTCTCAACAAATCAAAGTGTATTTGCTTTGATTAGTTTCTTAACTGAAATGAAAAGGTAATTTGGATCATTTGTTGAACATACCAAACCCACTGGTAAATTCAGCCAATCAGCTATAAGGAGTAAAACAGGCAGTTAATATAATTTGTATTAATCATAACCACTGCAATTATGTAAATGTAGCTTGAAATGTTATGTGTTAATTTAGTATGCTACTGTTGCTTCTAGAGAACCTACGAATTTCTAAACTCAAAGGCACCTCCAGAATTCCTGAAAGGTGTTGACACTGTCATCAGTTATGTTCCCAGATGACTTGGGTATTACGGGCTAAAAGCTACA"}, {"symbol": "transcript:ENST00000608591_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "CCCCAGTTAAATTCAAAGGCAGGGCAGATTTGAGAGACTGTCTCTCCTGCCCTCTTGCTTTGGTCAAATTGAATAAACCTTTCTCTGTTCCTGAGTGCTGATGTGTCAGTGTTTGGCTTACCATGCATCAGGTGCACAGACCTAAATTTGGAGATTCTGTAACATCAAGACCCATATGACGTTCACATACTGGATTAGGCTGATGACTCATATATCTCTCTTTTAATGTGTGACCATTTACATCTCTTTCTCTCTCTCCTCCCTTTTTTTTTTTTTTTTGGCAATTTCTTTGTTATTAATGGTCTTGTTTTAAAGTGAAGTATTGTCATTTAAGTTTATATATCAATGTTATCATTATTTGTTTATTTACAACAAAGTTTTATGCCAAGGCAGTCATGCC"}, {"symbol": "intergenic_13922813", "label": "intergenic", "species": "Homo sapiens", "sequence": "GTATCACTTCACACCACAGGATAAAATCTTTGTTCAATAAAAAAGAGAAAATAAGTGTTAGGAAAAATGTAAAGAAATCAAAACCCTTATCCAATGCTGCTGGGGATGTAAAGTGATGCAGCCACTTTGGAAAACAAACTGGCAGCTCCTCAAAAGGTTAAGCATGAAGTTACCCTACGACCCAGAAATTCCAGTCATAAGTATATACTCCAGAAAAATAAAAATCATGCAAGCACAAAAACTCATACATAAATGTTTACAGCAGCATTACTAATATGAGTCAAAAAGTGGAAAGAACCAGAACGTCCATCACCTTTGGGTGGGAAAGAACCCAAAAGTCCATCACCTGGTGAATGGATAAATAAACTGTTTGATGTATCCATACAATGGAATATCACTC"}, {"symbol": "ETS2_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "ACCCAGCCGAGTGACAGCAGGAGGCGGAGGGAAGGTTGGGCCGGAAGGTGTCAGCCCCGCCCCGCGCTCCCTCGCCGCCTCCGCCCTCCTCTTCTCTCCCCTCGTGCGTTCCCTCTCCTCTCCCTCCGCTCCCCCAACCCTCCTGCTGCCCCCTTCCCTCTCCTCCCGCTTCTCCCCATCCTGCCTACCTCCCTTCCCCTCCTCTTCTCTCTCCTCCCCTTCCCTCCCCTCTCTCTTCTCTCCTCCCTCGTTTCCTCCCCTCCCCTCCACTCGGCCGTCCCTCCTTCCTCCTCCCTCCTCCCTCCTCCTCCCGCTCCTGAAGAGCGCGCCGCGTGGGGGACGGCCCGGTTACTTCCTCCAGAGACTGACGAGTGCGGTGTCGCTCCAGCTCAGAGCTCCC"}, {"symbol": "ETS2_intron", "label": "intron", "species": "Homo sapiens", "sequence": "GGGCACTGACACGCAGATCTCGGGGCGCTGCCGGGGGTGCAGGTGGGGGTGGCGGCTGCTGCGAGGACTCTAGGGGCGCGCGTCTGAGTTCCGCGCCGGCTCGTTTTCCGGTTATGGAGTGGCCTCCGGGGCTGGCGGGGTCGGCCGGGGGGTTCCTGCGTGCTAGGGCCGCTGTCTTCGGGGTCGCCTAGCGGCGGGCGCGGCCAGGGCGCGCTGGCTTGTTTCGCTCGCTTTTGTTTTTAAAAGGAAACGCAGGCCTGGTAGGGGGTCCTGCCCAGTGGATGTCCCGGCGAACATGATTTCGCGAACGGGAGTGGGGGCACAGGAGAGCGTGTCCGAGGTGGCCTGGCGCCCCGGCTTTGAGGGTGACTTCCTGGAGCGGCGCCGGGCCCGGAGGATC"}, {"symbol": "BRWD1_exon", "label": "exon", "species": "Homo sapiens", "sequence": "CATAGGTCCTGAGTCTTCTGCCATTCCACCTGCGCAGCCCATAATTCAGTTCCACTTCAAAATTATTCTCTGCAGTAGCTTTTTGTGCCAAGGTTGACTCTGAAGAAAATGTCCTATCAGTCTCACTCCCTGGAATATGACTTTTTGAATCTTCTCCTGAAGATGTAACAGAGGAACTTTTTCTTTTAGAAGGTGCATCTATTTTGTGAATATTGGTATGCCTGTGCTTGTGTTCTGAATTCAACATATCTTCAGAGTCTGAGTCTCCATTTAGAGCCTGACTAAGCACTTTTGTACTACCTTCGGAGTCAGGATCAGGTGGCTTGCCTTCACAGGCATACTGTTCACTTGGTACTTCACAATGTACACTTCCTTCACAATCACTCAATTTCTTCTTAGC"}, {"symbol": "ERG_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "CCGTCACGACCGTCCCGGAGCGATCTCTAGGAACCTGTCCCGCAGCGATCTCTTGCACACTCTCCCCATTCCGACCAAGTGAAAACTACCCATGTCCCTCCCTCTCCAAACTTTAACAGCCAAGAATTTCAGCAAACAGCCAGAAAGAAAGCTAAGTCTCCCCCCGCCCCCCACCCCAAGGGAAGGGAGGGAATAAGCACTCCTTTAATTTGAAAAATAATAATTAAAACTCCCTCAACTTTTAAGGCCGAGCAACATAATCTATTAATTGGTCGCTATTAACATGCAGTTTTATTGACCATAGCACACAGAAGTCTGATTGTGAGGGAGGAGTGTTTTCAATGAAAGTGGAGTTATTTTAGCTGCAGACGTGTTTACATTTCTGTGGTTTAGTCTATAA"}, {"symbol": "ITGB2_intron", "label": "intron", "species": "Homo sapiens", "sequence": "TGCCCCCTCCTGCCATCCTGACCTGCACTCTCACTCCCCGTGCCCCCTCCTGCCATCCTGCCTTTCCTGGGGGACCTGCCTTCCTTCACCTGTCCTCAAAGGTGAAGTGACTTGGCCAGGATTGCACAAACCAGACGTGACCCAAGTCTGTCCCTCAAAGTGGCCCGCAGGGGTTCCTGCCCTGGCAGGTGGGGGTCTGCAGCCCCAGGCCCACATGTGGGTTGTGAGCTCCAGGCATGTCACCTACAGGCTCAGAGACCCCCGCCGCCTGCCTCGGGTCCCTTCCTGGTGAGGGAGGAGCTCCCAGGTCTCAGTTCTGCAAGAGTCCTGGCCGGCTGCTGGCCATGCCTGATGGAGTCCGCCAGCGGCCCCCCAGCCCCAGCCCCTCTGCCTCTCAGGA"}, {"symbol": "ITGB2_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "GCCTGTGGGCAGCCTTCCTCACCCTGGATGCCTGTGGGCAGCCTTCCTCACCCTGGATGCCTGTGGGCTGCCTTCCTCACCCTGGATGCCTGTGGGCAGCCTTCCTCACCCTGGATGCCTGTGGGCTGCCTTCCTCACCCTGGATGCCTGTGGGCAGCCTTCCTCACCCTGGATGCCTGTGGGCAGCCTTCCTCACCCTGGATGCCTGTGACTTGGTTTCCATGGGGGCTTCCTAAGCTCAGGGACCCCAAGCCTCCATCAAATCTAATGACCCGCCTTGTCTCTGAACACACCTGTGTTGTCCCCGAGCCCCTCACCAACCCCCTCTGCAACCCGGCCCACGCATTTCCCATCCTGAAGGGTCCAGCTCATAGGCCGGCCCATGTGTTCCCTTTACCCC"}, {"symbol": "intergenic_10443373", "label": "intergenic", "species": "Homo sapiens", "sequence": "ATTTATTTAGCAATGTTTATTGAACAGCAGAGGGTTTCATTTTGATGACATTTGTCATTTGTTAATTTTTTGAAGTTGAAAAAATTATGCTCTTTATGCTTTATTAAAAAATCTTTGCTTGCCGCAGTGTTACTTGTAGGTTTAAAAAATCCATTTATGGTTAAATTACACATATGATAAGAGAATACGAGATAAGATTCATGTTTCTTTGTTTTCCATATGAATTATTTGGCAGTTCCAGCATCATTCTAAAAGATTATTTATTGCTCTCTTGAATTACCTTGACCCCTTGCTCAAAAATTAATTGAATATGGGAAAGTTTGCCTATTTCTGTATTTTCTGTTCTTCTGACTTATTTATCTCTCCTTTTACCAAAACCAAACATAAAAAAGATTTTTTT"}, {"symbol": "COL18A1_exon", "label": "exon", "species": "Homo sapiens", "sequence": "ATGACCCCGACGTCGGGCTGGCCTACGTCTTTGGGCCAGATGCCAACAGTGGCCAAGTGGCCCGGTACCACTTCCCCAGCCTCTTCTTCCGTGACTTCTCACTGCTGTTCCACATCCGGCCAGCCACAGAGGGCCCAGGGGTGCTGTTCGCCATCACGGACTCGGCGCAGGCCATGGTCTTGCTGGGCGTGAAGCTCTCTGGGGTGCAGGACGGGCACCAGGACATCTCCCTGCTCTACACAGAACCAGGTGCAGGCCAGACCCACACAGCCGCCAGCTTCCGGCTCCCCGCCTTCGTCGGCCAGTGGACACACTTAGCCCTCAGTGTGGCAGGTGGCTTTGTGGCCCTCTACGTGGACTGTGAGGAGTTCCAGAGAATGCCGCTTGCTCGGTCCTCACG"}, {"symbol": "intergenic_20761403", "label": "intergenic", "species": "Homo sapiens", "sequence": "CGCTTTCTTTGAACCCATAAGTCAATTTCTACTATTCCACATATAATATTTAACATTCAATAACAAAATAAGCAATAATTAAAACACAAAAAAAGGAAGAAAAAAGGATTACTAATAGACAAAGCAATCACTGGAACCAGACTCAGGAATGACCCCATGTTGGAATTCACAGAGAGACAATTTATAATAACTATAATTAATATGGTAAACTACCAAGTGCAGCATGGAAAAATATATGGGGAATTTCAAAAGATACAGAAAAGATATTTAAAAAGGTGAAATGGGGATGCTAGGCAAAACACAGACACTAGACACTGACAAACACACACACACACATCAGCACTACTGAGAATAGAATTACTTAATTGGAAGCCAAAGCAATATACGTTACACAAACAAA"}, {"symbol": "intergenic_43393806", "label": "intergenic", "species": "Homo sapiens", "sequence": "GCAGTGAGCTGAGATTGCACCACTGCACTCCAGTCTGGCAACAGAGTGAGACTCCATCTCAAAAAAAAAAGGAAGGAAGGAAGGAAAGATGGAAGAGAGAGAGAGAAAGAAAGAGAAGGAAGGAAGGAAGAAGGAAGAAAGGAAGGAAAGAAAGAAAGAGAGAGGGGGAAGGAAGGAAAGAAGAAAGAAAAAAAGAAAGAAAGAAAGAAAGAAAGAAAGAAAGAGAAAGAAAGAAAAGAAAGAATGAAAGACAGACAGACATGAGACTGGGTAATTTGTAAGGAAAGAGGTTGAATTGGCTCATGGTTCTGCTGGCTGTACAGGAAGCATAGCAGCATCAGCTTCTGAGGAGGCCTCAGGAAGCTTCCAATCATGGCAGGAGGCAACAGGGGAGCAGGTG"}, {"symbol": "SPATC1L_intron", "label": "intron", "species": "Homo sapiens", "sequence": "ACCTCTAGCCAGACTTAGGGGAAAAAAGAAAGAAGACAAAAATTACCACTAGCAGAAATTAGAGATGCCACTACCACAGATTCTACAGACATAAAAAGGATAATAAGGAAGTATTAGGAATGACTTTATGCCAATAAATTTGACAACCTAGATGAAATTGACCAATTCCTTAAAAGACACAAACTACCAAAGTTCACTCAAGAAGAAATAGATACTCTGAATAGCCTTATATCTGATAACCTTACAGGTCACATTTGACAGTTTAAAAGTAAACAGACTTCCAAACTGGCCTGCTTGGGAAGGTCTTATGATTAATGGTCCCTGAGTAAAGAATCTTATCATGATTTCCTCAGATTGCTGATGTGCTGATTAATGGACTGAAAAGATGCTGATTTATTCC"}, {"symbol": "SLC19A1_exon", "label": "exon", "species": "Homo sapiens", "sequence": "TCCCGCAGCATCCGCGCCAGCACTGAGTCCCCACAGGCCACCCGCAGGGCGTGTCCCAGCTTCCCGCCTGGGCCAGGATTCATGCGCTCCAGCTCCGAAGCCGAGGTTTCGCACCGCCCCCGGTCGTCGCGGTTGAAGAAGAGGCTGCGCTTGGGGCGCTTCAGGAAGAGGGCGAGGACCACGCTGAAGGTGAGGAAGGCCAGCGAGATGTAGTTGAGCGTGGAGAAGGAGACTCGGCCCACAGTGACCAGCAGCTGGCCCAGCACGGAGCTGGTGAACACGCCCAGCAGCACCGCAGCGCGCGAGTAGCCGGCCACACGCTGGTAGCGCGCGGGCCGCACGAGAGAGAAGATGTAGGAGGAATAGGCGATGCGCGCGGCCATGGTGACGCTGTAGAAGA"}, {"symbol": "GET1_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "GCAGGCGTCGCAGGACTGGCCCGGGCTCAAGGTCCGTGCAAACTGCATGCACTGCGCAGGTGCTCCAGGACTCGCGCGGGGTCAAAGTCCATGCAAGCCGCACGCACTGCACAGGTGCCCCAGGACTGGCACGGGCTCAGAGTCCACGCATACCGCACACACTGCGCAGGCGCTCCAGGACTGGCGCGGGCTCAGAGTCTATGCAAACCGCATACACTGTGCAGGCGCCCTAGGACTGGCGCTGGCTCAGGGTCCCTGCGAGCTGCACGCACTGCGCAGGCGCCCCAGAGCTGGTGTGGGCTGAGTGGCCACACGAGCCACATGCACTGCGCAGGCGCCAGGAGGCCCTTCCTCTCAATCCGAGCTTTGCAGATGAAGGACTCTGCGTGTGTTGCCTTCT"}, {"symbol": "intergenic_22114423", "label": "intergenic", "species": "Homo sapiens", "sequence": "ATTAATAATTATGTTTTTCTATATGGGATAAGAGGCAGATTTTAAATGAGAAGGACTGGGTCTGAGTCTTGGGTATGTTTTGATTAGTTGTATCATTTCCATTTCCTCATTAGCAAAATATAAACTATAATAATGTCTGGCAAACCCATGTTGCATTATTGCTGCCAGGATCATATTGCTGTGGGGTTGAGGTACAACGTACATCTCCTCCTCTTTGGTGATTTCACTGATAATGTGGATTTAAACATCTTTAACAAACTGGGAAAGGTAGTAAAGCTTTATATAGATGGTACTTCTCCCCGTACCTCCTAAAATGTTTTCTTGATACTTTTTGGAGAGAGAAATTGGGCCTGATGCTGAAGGAGAACTCTTCTGTCCACTGCAAGTCCACTTACATGAG"}, {"symbol": "SPATC1L_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "ACACCTCCAGCCCTGTGACGGGCACCCACCACCACCAACCCAGTGGGCCGGCCACACCTCCAGCCCTGTGACGGGCACCCACCACCACCAACCCAGTGGGCCGGCCACACCTCCAGCCCTGTGACGGGCACCCACCACCACCAACCCAGTGGGCCGGCCACACCTCCAGCCCTGTGACGGGCACCCACCACCACCAACCCAGTGGGCCGGCCACACCTCCAGCCCTGTGATGGGCACCCACCACCACCACCATCCCAGTGGGCCGGCCACACCTCCAGCCCTGTGACGGGCACTCAGGACCACCAAGGGCACTCAGGTGCAGATGCCCCTGGGAGAACACCACCTTCATTCAGCCCGGGCGGCCAGATTAGGGGAAGGTTGCTTCTAGGAGAGAAAGTCT"}, {"symbol": "transcript:ENST00000662827_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "ATACTGTAGGTATTTTTCTTTTCTATAAATGTTTTATATTCCAGTCTATTCAAAACCTTACGAACCAAATCCTTTAAAGGACGTAAGTCACACATATTTTCAAACCAACAAAATAGTAAAATTCAGGGAACATTAGATGGAATAGGGAAGATGAAATAAAGAAGCAACTAAGAGATTTACAAGAATGGAGTTCTTCTGCCACCTTGTGGTTGTTCTGTGTTATAACCTAGAGCTACATTAAAATATTCAGGGAATTCTTCAGCCAGAGTCTCTTTAGCCAGGTGTGGCGCGTGGCTGAGCTCTGGTCAATGGAATGTGGCCTTGGTCTTTATTGACTGTGGTTATCGTGTTTCCATTTTTTTCTTCCTGAATATCATAGCTAATTTTGGTATTGGCGACG"}, {"symbol": "transcript:ENST00000671437_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "GAATCAGCCCTAGACAACACATAAACAAATGGGTGTGGCTGTGTTCCAATAAAATGTTATTTATAAAACTAGGAAATCAGCAGATTTAGTCCACAGGCTGTAGTTTGCTGACCCTTGAATTAGAGAGTCTTCCCCAAATCCTCTTCCCAAGTTATTGAAAGAATGCCCATAGGACCATAGCTCCCTCACCTGGTTCTTTCAAGAAATGAGACAAATTCAGGGATGGTTGGAGGGTGGAACGCCTACCTGTGACATGACTATTGAGCCTGATTTCTTTCCTTTTATCTCTTTAAAGTGAGTCAGGTCTTAACTTTAAACTGTCTCTAAAGTGGCAATAACTGGGCCATGTCAAATACTCCCAATGACAAAATTTCTTCTTGGAGAATGAATGAGGGTTGCA"}, {"symbol": "transcript:ENST00000434859_lncexon", "label": "lncRNA_exon", "species": "Homo sapiens", "sequence": "TAGTAAAGTAGTTTTGCTGTGCATCCAGGAAGAGGAGCAGAAATGATTTTGTGCTACAGTCATCTACATGTAATACACATCAACAAACAAAAACATGTATGTTTCCAACATATATGTTTGGAGACAGAGTCTCGCTCTGTCCCCCAGGCTGGAGTGCACTGGCACAATCTCAGCTCACTGCAACCTTCCCCTCCCGAGTTCAAGTGATTCTCATGCCTCAGCCTCCCAAGTGGCTGGGACTGCAGGCACGCGCCACCACACCTGGCTAATTTTTGTATTTTTAGTAAAGATAGGGTTTCGCCATGTTGGCCAGGCTGATCTTGAACTCCTGACCTCAAAAAACAAAACAAAGCAAAACAAAACAAAACAAAACCTCAGAACTAATTGGATTAGGATCCCA"}, {"symbol": "KCNE1_intron", "label": "intron", "species": "Homo sapiens", "sequence": "CGGGCCTGGGAATCTGCATTCCTAACACATTCCCACGTGGTGCTAATACTGCTGGTCTGGAGGCCCTGCTTGGTGATCTATTGGAATCACCGGGGGAGCTTTTAGAAAATAATGGTTCCTGGATCTCACCCCTAGAGATTTTAATGTCTTTGGTCTGGGTTCCTGCCTGACTCAGAGACTTTTTAGAAACCTCCCAAATGATCCTAATTTGTAGCCAAGATTGAGAACCACTGGGCTGTGGTGTGGGACCCTAGGAAAATGACCAATGGCCTTTTGTGCTGCAGGGTACCTGGAAGAATTTTGCAAAAATATAGAAATATGATCTCACTGACTGTTTTTCAAATCTTGTTTGTTTTTTACATTTTCTTTTTTGGCCTTGTTTGCCTCTGATACAGTCTGA"}, {"symbol": "TTC3_exon", "label": "exon", "species": "Homo sapiens", "sequence": "TGTCAGATTCATCTTCAGCACCAGCTTTTGAAAATGTGAAACCCAAACCTGTGTCTGCAAATTCTCCCAAGCCAGCTTGTGAAGATGTGAAGGCCAAACCAGTATCCGACAATTCTTCTAGACAAGTTTCTGAGGATGGGCAACCCAAAGGGGTCTCTTCTAATTCTCCTAAACCAGGCTCTGAGGATGCAAATTACAAGCGAGTCTCCTGTAATTCCCCCAAACCGGTTCTTGAGGATGTGAAACCAACTTATTGGGCTCAATCCCATTTGGTCACAGGATACTGTACGTATCTTCCTTTCCAGAGATTTGATATCACCCAGACACCGCCAGCATACATAAACGTGTTACCAGGTTTGCCCCAGTACACCAGCATATATACACCCTTGGCCAGCCTTTC"}, {"symbol": "ETS2_intron", "label": "intron", "species": "Homo sapiens", "sequence": "CCTCGTAGATCTCAGGCAGTTGGTCATCATTGACTTGGATTTTTTTGGGCTGGGCTCCAGCCTCTGCCTGCCCTCTTTTGTAATGCGTCATTCATTGTTCTGCATTTCTAAAAAGAAAGCGGGTAACCGATTTTATTTGTAAATGTGTTACACATTTTCTTATTGATGACTCAACAGATGGGTCATTGTATTACACATAACATGGGATTTCCAGCTTATGTTGGAAAAATATAGTCTTTCTTCACGTGTTCCTCAACAATTTAGGCGATGATGAATCCAGATATTATCGGTTACTTTCAAATGAGTCTTACATTAAAAGGTATTTCTTTCATAAATGTTATGTAAGATGCTCAGAAATCGAGTTAGTGTAAGGTTTTATCTTTCTGTGTGTCTCTTCTTT"}, {"symbol": "intergenic_19691918", "label": "intergenic", "species": "Homo sapiens", "sequence": "CCAGCAACTAACTGTTATAAAGATTCTATCCTATTTTTGCTGCTACAATATTAACCACAACGTGGAGAATGTTTATAGCATGTCCTGTGCACACTTGTCTCTTATCACTCAATCAAGGACCACTTTTCTCAAATTAAATTTTTATTCTTCATAACCGTCCTTATGCATAAAAATAGAATATCAATAGAAAAACTTAAAGTATGCATAATCTCTATGCTTATTCCTCATGTAACTAAATTGTTCTTCATAGATCTACATAGTGTTAATGACATGAGAAAATTAAAAAAATACACATGTAATTATTGCCATTTTGATTATTTTGGTATGACAATTCAAGTTTATAGGCTAACTTTACTTTTTCTAAAAAACACCTTCTTACAGACATTAACTAAATCCCAGT"}, {"symbol": "PDE9A_promoter", "label": "promoter", "species": "Homo sapiens", "sequence": "GGGCCGAAGGGGTCGCTCAGGGCTCTGCACAGCTGTCCAGGGGGCATCGGGAGATAGGCAGCCGACCGGGGGCTGGAGTCAAGGGAAGAAGAAAGAGGGGGAAGGGAGGTGCAGGGAGTGAAGAGGAGGGGAGAGAGGAGAGACGGGGGAGGGAGGGGGGAGGGGGGAGGGGGAGGGGCGGGCGGGCGGCCGGGAGGAGGAGCGCGCGAGCCGGAGTCGGAGCCCGAGCCCGAGCGCGAGCCGAGCGGAGGAGACCCTGCGGCGCGCGGCGGCGGCTCCCGGGCGTCCCGGGCCCGGTGGCGGCGCGGCTGTGGTTGGCTGAGCGCCGCGGGCCGCCCCCCGCCCGCCCCCTCCCCTGCTCCCCTCCCCCGCCTCCCGCGGCGGCTGGCGTCGGGAAAGT"}]
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/App.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/App.jsx
new file mode 100644
index 0000000000..7c4cbd601e
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/App.jsx
@@ -0,0 +1,1365 @@
+import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
+import * as vg from '@uwdata/vgplot'
+import { wasmConnector, MosaicClient } from '@uwdata/mosaic-core'
+import { Query, sql, literal } from '@uwdata/mosaic-sql'
+import FeatureCard from './FeatureCard'
+import FeatureList from './FeatureList'
+import EmbeddingView from './EmbeddingView'
+import Histogram from './Histogram'
+import InfoButton from './InfoButton'
+import { Sun, Moon } from 'lucide-react'
+import { styles } from './styles'
+
+export default function App({ title = "Evo 2 SAE Feature Explorer", subtitle = "Real SAE features — Evo 2, layer 26" }) {
+ const [darkMode, setDarkMode] = useState(true)
+
+ // Toggle dark class on document root
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', darkMode)
+ }, [darkMode])
+
+ const [features, setFeatures] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [loadingProgress, setLoadingProgress] = useState({ step: 0, total: 4, message: 'Starting up...' })
+ const [error, setError] = useState(null)
+ const [sortBy, setSortBy] = useState('frequency')
+ const [selectedFeatureIds, setSelectedFeatureIds] = useState(null) // null = all selected
+ const [mosaicReady, setMosaicReady] = useState(false)
+ const [categoryColumns, setCategoryColumns] = useState([])
+ const [selectedCategory, setSelectedCategory] = useState('cluster_id')
+ const [hiddenCategories, setHiddenCategories] = useState(new Set())
+ const [clickedFeatureId, setClickedFeatureId] = useState(null)
+ const [clusterLabels, setClusterLabels] = useState(null)
+ const [vocabLogits, setVocabLogits] = useState(null)
+ const [featureAnalysis, setFeatureAnalysis] = useState(null)
+
+ const brushRef = useRef(null)
+ const [showGuideModal, setShowGuideModal] = useState(false)
+ const [showMetricsModal, setShowMetricsModal] = useState(false)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [cardResetKey, setCardResetKey] = useState(0)
+ const [plotResetKey, setPlotResetKey] = useState(0)
+ const [viewportState, setViewportState] = useState(null) // null = let embedding-atlas auto-fit on first load
+ const [displayedCardCount, setDisplayedCardCount] = useState(20) // Pagination: start with 20 cards
+ const [showEditedOnly, setShowEditedOnly] = useState(false) // Filter for edited features only
+ const [histMetric1, setHistMetric1] = useState('log_frequency')
+ const [histMetric2, setHistMetric2] = useState('max_activation')
+ const [histMetric3, setHistMetric3] = useState('cluster_id') // tracks color-by selection
+ const featureRefs = useRef({})
+ const featureListRef = useRef(null)
+ const endOfListRef = useRef(null)
+ const searchSource = useRef({ source: 'search' })
+ const editedSource = useRef({ source: 'edited' })
+ const legendSource = useRef({ source: 'legend' })
+ const loadingMoreRef = useRef(false)
+
+ // Lazy-load examples for a single feature from DuckDB (feature_examples VIEW)
+ const loadExamplesForFeature = useCallback(async (featureId) => {
+ const result = await vg.coordinator().query(
+ `SELECT * FROM feature_examples WHERE feature_id = ${featureId} ORDER BY example_rank`
+ )
+ return result.toArray().map(row => ({
+ sequence_id: row.sequence_id,
+ start: row.start,
+ end: row.end,
+ sequence: row.sequence,
+ activations: Array.from(row.activations),
+ max_activation: row.max_activation,
+ best_annotation: row.best_annotation,
+ }))
+ }, [])
+
+ // Intersection Observer for infinite scroll pagination
+ useEffect(() => {
+ const sentinel = endOfListRef.current
+ const scrollContainer = featureListRef.current
+ if (!sentinel || !scrollContainer) return
+
+ const observer = new IntersectionObserver(
+ entries => {
+ console.log('[scroll] sentinel intersecting:', entries[0].isIntersecting, 'loadingMore:', loadingMoreRef.current)
+ if (entries[0].isIntersecting && !loadingMoreRef.current) {
+ loadingMoreRef.current = true
+ setDisplayedCardCount(prev => prev + 20)
+ // Reset flag after a delay to allow next batch
+ setTimeout(() => {
+ loadingMoreRef.current = false
+ }, 300)
+ }
+ },
+ { root: scrollContainer, threshold: 0.1, rootMargin: '200px' }
+ )
+
+ observer.observe(sentinel)
+
+ return () => {
+ observer.disconnect()
+ }
+ }, [mosaicReady])
+
+ // Handle click on a feature in the UMAP (or null for empty canvas click)
+ const animationRef = useRef(null)
+ const currentViewportRef = useRef(null)
+ const initialViewportRef = useRef(null)
+
+ // Handle viewport changes from the UMAP component
+ const handleViewportChange = useCallback((vp) => {
+ // Capture initial viewport on first report, slightly zoomed out so all points fit
+ if (!initialViewportRef.current && vp) {
+ initialViewportRef.current = { ...vp, scale: vp.scale * 0.5 }
+ setViewportState(initialViewportRef.current)
+ currentViewportRef.current = { ...initialViewportRef.current }
+ }
+ // Clamp zoom to max scale of 5
+ if (vp && vp.scale > 5) {
+ const clamped = { ...vp, scale: 5 }
+ setViewportState(clamped)
+ currentViewportRef.current = clamped
+ return
+ }
+ // Always track current viewport (but not during our own animations)
+ if (!animationRef.current) {
+ currentViewportRef.current = vp
+ }
+ }, [])
+
+ // Handle click on a feature in the UMAP (with coordinates for zooming)
+ const handleFeatureClick = useCallback((featureId, x, y) => {
+
+ setClickedFeatureId(featureId)
+
+ if (featureId == null) return
+
+ // Scroll to the feature card
+ setTimeout(() => {
+ const ref = featureRefs.current[featureId]
+ if (ref) {
+ ref.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ }, 50)
+ }, [])
+
+ // Handle click on a feature card (highlights point in UMAP, no zoom)
+ const handleCardClick = useCallback(async (featureId, isExpanding) => {
+
+ if (!isExpanding) {
+ setClickedFeatureId(null)
+ return
+ }
+
+ setClickedFeatureId(featureId)
+ }, [])
+
+ // Initialize Mosaic and load data
+ useEffect(() => {
+ async function init() {
+ try {
+ // Step 1: Initialize DuckDB-WASM
+ setLoadingProgress({ step: 1, total: 4, message: 'Initializing database engine...' })
+ const wasm = wasmConnector()
+ vg.coordinator().databaseConnector(wasm)
+
+ // Step 2: Load parquet data
+ setLoadingProgress({ step: 2, total: 4, message: 'Loading embedding data...' })
+ const urlParams = new URLSearchParams(window.location.search)
+ const dataPath = urlParams.get('data') || '/features_atlas.parquet'
+ const parquetUrl = dataPath.startsWith('http')
+ ? dataPath
+ : new URL(dataPath, window.location.origin).href
+
+
+ const safeUrl = parquetUrl.replace(/'/g, "''") // escape quotes for the SQL string literal
+ await vg.coordinator().exec(`
+ CREATE TABLE features AS
+ SELECT * FROM read_parquet('${safeUrl}')
+ `)
+
+ // HDBSCAN assigns -1 to noise points; embedding-atlas casts category
+ // columns to UTINYINT which can't hold negatives. Remap to NULL.
+ try {
+ await vg.coordinator().exec(`
+ UPDATE features SET cluster_id = NULL WHERE cluster_id < 0
+ `)
+ } catch (e) {
+ // cluster_id column may not exist — that's fine
+ }
+
+ // Step 3: Process columns and categories
+ setLoadingProgress({ step: 3, total: 4, message: 'Processing columns...' })
+ const schemaResult = await vg.coordinator().query(`
+ SELECT column_name, column_type
+ FROM (DESCRIBE features)
+ `)
+
+ const columns = schemaResult.toArray().map(row => ({
+ name: row.column_name,
+ type: row.column_type
+ }))
+
+ const detectedCategories = []
+ const sequentialColumns = []
+
+ for (const col of columns) {
+ if (['x', 'y', 'feature_id', 'top_example_idx', 'logo_path'].includes(col.name)) continue
+
+ if (col.type === 'VARCHAR') {
+ const isGsea = col.name.startsWith('gsea_')
+ const maxUnique = isGsea ? Infinity : 50
+ const cardinalityResult = await vg.coordinator().query(`
+ SELECT COUNT(DISTINCT "${col.name}") as n_unique FROM features WHERE "${col.name}" IS NOT NULL AND "${col.name}" != 'unlabeled'
+ `)
+ const nUnique = cardinalityResult.toArray()[0]?.n_unique ?? 0
+ if (nUnique > 0 && nUnique <= maxUnique) {
+ // For high-cardinality GSEA columns, collapse to top 20 + "other"
+ if (isGsea && nUnique > 20) {
+ await vg.coordinator().exec(`
+ CREATE OR REPLACE TABLE features AS
+ SELECT * REPLACE (
+ CASE
+ WHEN "${col.name}" IS NULL OR "${col.name}" = 'unlabeled' THEN 'unlabeled'
+ WHEN "${col.name}" IN (
+ SELECT "${col.name}" FROM features
+ WHERE "${col.name}" IS NOT NULL AND "${col.name}" != 'unlabeled'
+ GROUP BY "${col.name}" ORDER BY COUNT(*) DESC LIMIT 20
+ ) THEN "${col.name}"
+ ELSE 'other'
+ END AS "${col.name}"
+ ) FROM features
+ `)
+ detectedCategories.push({ name: col.name, type: 'string', nUnique: 22 })
+ } else {
+ detectedCategories.push({ name: col.name, type: 'string', nUnique })
+ }
+ }
+ } else if (col.type === 'BIGINT' || col.type === 'INTEGER') {
+ if (col.name.includes('cluster') || col.name.includes('category') || col.name.includes('group')) {
+ const cardinalityResult = await vg.coordinator().query(`
+ SELECT COUNT(DISTINCT "${col.name}") as n_unique FROM features WHERE "${col.name}" IS NOT NULL
+ `)
+ const nUnique = cardinalityResult.toArray()[0]?.n_unique ?? 0
+ if (nUnique > 0 && nUnique <= 50) {
+ detectedCategories.push({ name: col.name, type: 'integer', nUnique })
+ }
+ }
+ } else if (col.type === 'DOUBLE' || col.type === 'FLOAT') {
+ // Numeric columns for sequential coloring
+ if (['log_frequency', 'max_activation', 'activation_freq', 'frequency',
+ 'mean_variant_1bcdwt',
+ 'high_score_fraction', 'clinvar_fraction',
+ 'mean_phylop', 'mean_variant_delta', 'mean_site_delta', 'mean_local_delta',
+ 'high_score_delta', 'low_score_delta',
+ 'gc_mean', 'gc_std',
+ 'trinuc_entropy', 'trinuc_dominant_frac',
+ 'pli_mean_pli', 'pli_frac_constrained', 'pli_max_pli',
+ 'codon_cai', 'codon_tai', 'codon_rscu',
+ 'gene_entropy', 'gene_n_unique', 'gene_dominant_frac',
+ ].includes(col.name)) {
+ sequentialColumns.push({ name: col.name, type: 'sequential' })
+ }
+ }
+ }
+
+ // Create integer-encoded versions of string category columns
+ for (const col of detectedCategories) {
+ if (col.type === 'string') {
+ await vg.coordinator().exec(`
+ CREATE OR REPLACE TABLE features AS
+ SELECT *,
+ CASE WHEN "${col.name}" IS NULL THEN NULL
+ ELSE DENSE_RANK() OVER (ORDER BY "${col.name}") - 1
+ END AS "${col.name}_cat"
+ FROM features
+ `)
+ }
+ }
+
+ // Create binned versions of sequential columns (10 bins)
+ const NUM_BINS = 10
+ for (const col of sequentialColumns) {
+ await vg.coordinator().exec(`
+ CREATE OR REPLACE TABLE features AS
+ SELECT *,
+ CASE WHEN "${col.name}" IS NULL THEN NULL
+ ELSE LEAST(${NUM_BINS - 1}, CAST(
+ (("${col.name}" - (SELECT MIN("${col.name}") FROM features)) /
+ NULLIF((SELECT MAX("${col.name}") - MIN("${col.name}") FROM features), 0)) * ${NUM_BINS}
+ AS INTEGER))
+ END AS "${col.name}_bin"
+ FROM features
+ `)
+ detectedCategories.push({ name: col.name, type: 'sequential', nUnique: NUM_BINS })
+ }
+
+ setCategoryColumns(detectedCategories)
+
+ // Default color-by so the atlas is colored on load (not one flat color):
+ // cluster_id if present, else a frequency/activation metric, else the first column.
+ const _names = detectedCategories.map(c => c.name)
+ const _defaultColor =
+ ['cluster_id', 'log_frequency', 'activation_freq', 'max_activation'].find(n => _names.includes(n)) || _names[0]
+ if (_defaultColor) {
+ setSelectedCategory(_defaultColor)
+ setHistMetric3(_defaultColor)
+ }
+
+ // Create crossfilter selection
+ brushRef.current = vg.Selection.crossfilter()
+
+
+ // Step 4: Load feature metadata from parquet via DuckDB
+ setLoadingProgress({ step: 4, total: 4, message: 'Loading feature metadata...' })
+ const metaUrl = new URL('/feature_metadata.parquet', window.location.origin).href
+ const examplesUrl = new URL('/feature_examples.parquet', window.location.origin).href
+
+ await vg.coordinator().exec(`
+ CREATE TABLE IF NOT EXISTS feature_metadata AS
+ SELECT * FROM read_parquet('${metaUrl}')
+ `)
+ await vg.coordinator().exec(`
+ CREATE VIEW IF NOT EXISTS feature_examples AS
+ SELECT * FROM read_parquet('${examplesUrl}')
+ `)
+
+ // Load features from the features table (which has labels + category columns)
+ const categorySelectCols = detectedCategories
+ .filter(c => c.type === 'string' || c.type === 'integer')
+ .map(c => `"${c.name}"`)
+ .join(', ')
+ const extraSelect = categorySelectCols ? `, ${categorySelectCols}` : ''
+ // logo_path is optional — older parquets won't have it, so detect and
+ // include it only if the column exists.
+ const hasLogoPath = columns.some(c => c.name === 'logo_path')
+ const logoSelect = hasLogoPath ? ', logo_path' : ''
+ const featuresResult = await vg.coordinator().query(`
+ SELECT
+ feature_id,
+ label,
+ activation_freq,
+ max_activation,
+ x,
+ y
+ ${logoSelect}
+ ${extraSelect}
+ FROM features
+ ORDER BY feature_id
+ `)
+ const loadedFeatures = featuresResult.toArray().map(row => {
+ const f = {
+ feature_id: row.feature_id,
+ label: row.label,
+ description: row.label,
+ activation_freq: row.activation_freq,
+ max_activation: row.max_activation,
+ x: row.x,
+ y: row.y,
+ logo_path: row.logo_path,
+ }
+ for (const col of detectedCategories) {
+ if (col.type === 'string' || col.type === 'integer') {
+ f[col.name] = row[col.name]
+ }
+ }
+ return f
+ })
+ setFeatures(loadedFeatures)
+
+ // Generate cluster labels from DuckDB (non-fatal if cluster_id doesn't exist)
+ try {
+ const clusterResult = await vg.coordinator().query(`
+ SELECT
+ cluster_id,
+ AVG(x) as cx,
+ AVG(y) as cy,
+ MODE(label) as top_label,
+ COUNT(*) as n
+ FROM features
+ WHERE cluster_id IS NOT NULL
+ GROUP BY cluster_id
+ ORDER BY n DESC
+ `)
+ const labels = clusterResult.toArray()
+ .filter(row => row.top_label && !row.top_label.startsWith('Feature '))
+ .map((row, i) => ({
+ x: Number(row.cx),
+ y: Number(row.cy),
+ text: row.top_label.length > 40 ? row.top_label.slice(0, 40) + '...' : row.top_label,
+ priority: row.n,
+ level: 0,
+ }))
+ console.log('[cluster labels] generated:', labels.length, labels.slice(0, 5))
+ if (labels.length > 0) {
+ setClusterLabels(labels)
+ }
+ } catch (e) {
+ console.log('[cluster labels] query failed:', e.message)
+ }
+
+ // Load cluster labels from file (overrides computed ones if present)
+ try {
+ const labelsRes = await fetch('./cluster_labels.json')
+ if (labelsRes.ok) {
+ const labelsData = await labelsRes.json()
+ setClusterLabels(labelsData)
+ }
+ } catch (labelErr) {
+ }
+
+ // Load vocab logits (non-fatal if missing)
+ try {
+ const logitsRes = await fetch('./vocab_logits.json')
+ if (logitsRes.ok) {
+ const logitsData = await logitsRes.json()
+ setVocabLogits(logitsData)
+ }
+ } catch (e) {
+ }
+
+ // Load feature analysis (non-fatal if missing)
+ try {
+ const analysisRes = await fetch('./feature_analysis.json')
+ if (analysisRes.ok) {
+ const analysisData = await analysisRes.json()
+ setFeatureAnalysis(analysisData)
+ }
+ } catch (e) {
+ }
+
+ setMosaicReady(true)
+ setLoading(false)
+
+ } catch (err) {
+ console.error('Init error:', err)
+ setError(err.message)
+ setLoading(false)
+ }
+ }
+
+ init()
+ }, [])
+
+ // Create a Mosaic client that receives filtered feature IDs
+ useEffect(() => {
+ if (!mosaicReady || !brushRef.current) return
+
+ const coordinator = vg.coordinator()
+ const selection = brushRef.current
+ const totalFeatures = features.length
+
+ // Create a class that extends MosaicClient
+ class FeatureFilterClient extends MosaicClient {
+ constructor(filterBy) {
+ super(filterBy)
+ this._isConnected = true
+ }
+
+ query(filter = []) {
+ // Use Mosaic's Query builder
+ const q = Query
+ .select({ feature_id: 'feature_id' })
+ .distinct()
+ .from('features')
+
+ // Apply filter if present
+ if (filter.length > 0) {
+ q.where(filter)
+ }
+
+ return q
+ }
+
+ queryResult(data) {
+ if (!this._isConnected) return
+
+ try {
+ let ids = new Set()
+ if (data && typeof data.getChild === 'function') {
+ const col = data.getChild('feature_id')
+ if (col) {
+ for (let i = 0; i < col.length; i++) {
+ ids.add(col.get(i))
+ }
+ }
+ } else if (data && data.toArray) {
+ ids = new Set(data.toArray().map(r => r.feature_id))
+ }
+ setSelectedFeatureIds(ids.size > 0 && ids.size < totalFeatures ? ids : null)
+ } catch (err) {
+ console.error('Error processing result:', err)
+ }
+ }
+
+ // Required by Mosaic for selection updates
+ update() {
+ return this
+ }
+
+ queryError(err) {
+ if (this._isConnected) {
+ console.error('FeatureFilterClient error:', err)
+ }
+ }
+
+ disconnect() {
+ this._isConnected = false
+ }
+ }
+
+ const client = new FeatureFilterClient(selection)
+
+ // Delay connection slightly to ensure Mosaic is fully ready
+ const timeoutId = setTimeout(() => {
+ try {
+ coordinator.connect(client)
+ } catch (err) {
+ console.warn('Error connecting FeatureFilterClient:', err)
+ }
+ }, 0)
+
+ return () => {
+ clearTimeout(timeoutId)
+ try {
+ client.disconnect()
+ coordinator.disconnect(client)
+ } catch (err) {
+ // Ignore disconnect errors
+ }
+ }
+ }, [mosaicReady, features.length])
+
+ // Clear ALL selections (search, histograms, UMAP, clicked feature)
+ const handleClearSelection = useCallback(() => {
+ if (brushRef.current) {
+ const selection = brushRef.current
+ // Clear each clause by updating with null predicate for each source
+ const clauses = selection.clauses || []
+ for (const clause of clauses) {
+ if (clause.source) {
+ try {
+ selection.update({ source: clause.source, predicate: null, value: null })
+ } catch (e) {
+ // Ignore errors from clearing
+ }
+ }
+ }
+ // Also clear the search clause specifically
+ if (searchSource.current) {
+ try {
+ selection.update({ source: searchSource.current, predicate: null, value: null })
+ } catch (e) {
+ // Ignore
+ }
+ }
+ }
+ setSelectedFeatureIds(null)
+ setSearchTerm('')
+ setClickedFeatureId(null)
+ setHiddenCategories(new Set())
+ // Reset viewport to the auto-fit view captured on first load
+ if (initialViewportRef.current) {
+ setViewportState({ ...initialViewportRef.current })
+ currentViewportRef.current = { ...initialViewportRef.current }
+ } else {
+ setViewportState(null)
+ currentViewportRef.current = null
+ }
+ // Reset all cards to collapsed state
+ setCardResetKey(k => k + 1)
+ // Reset histograms and UMAP to clear brush visuals
+ setPlotResetKey(k => k + 1)
+ }, [])
+
+ // Export all edited features to CSV with full data
+ const handleExportEdited = useCallback(() => {
+ // Get all edited features
+ const editedFeatures = features.filter(f => localStorage.getItem(`featureTitle_${f.feature_id}`) !== null)
+
+ if (editedFeatures.length === 0) {
+ alert('No edited features to export')
+ return
+ }
+
+ const lines = []
+ const escapeCsv = (str) => `"${(str || '').toString().replace(/"/g, '""')}"`
+
+ // Codon mapping for amino acids
+ const CODON_AA = {
+ 'TTT':'F','TTC':'F','TTA':'L','TTG':'L','TCT':'S','TCC':'S','TCA':'S','TCG':'S',
+ 'TAT':'Y','TAC':'Y','TAA':'*','TAG':'*','TGT':'C','TGC':'C','TGA':'*','TGG':'W',
+ 'CTT':'L','CTC':'L','CTA':'L','CTG':'L','CCT':'P','CCC':'P','CCA':'P','CCG':'P',
+ 'CAT':'H','CAC':'H','CAA':'Q','CAG':'Q','CGT':'R','CGC':'R','CGA':'R','CGG':'R',
+ 'ATT':'I','ATC':'I','ATA':'I','ATG':'M','ACT':'T','ACC':'T','ACA':'T','ACG':'T',
+ 'AAT':'N','AAC':'N','AAA':'K','AAG':'K','AGT':'S','AGC':'S','AGA':'R','AGG':'R',
+ 'GTT':'V','GTC':'V','GTA':'V','GTG':'V','GCT':'A','GCC':'A','GCA':'A','GCG':'A',
+ 'GAT':'D','GAC':'D','GAA':'E','GAG':'E','GGT':'G','GGC':'G','GGA':'G','GGG':'G',
+ }
+
+ editedFeatures.forEach((f, idx) => {
+ const userTitle = localStorage.getItem(`featureTitle_${f.feature_id}`)
+ const label = f.label || `Feature ${f.feature_id}`
+
+ // Add separator for readability
+ if (idx > 0) lines.push('')
+
+ // Feature metadata
+ lines.push(`=== FEATURE ${f.feature_id} ===`)
+ lines.push(`Feature ID,${f.feature_id}`)
+ lines.push(`Original Label,${escapeCsv(label)}`)
+ lines.push(`Your Title,${escapeCsv(userTitle)}`)
+ lines.push(`Activation Frequency,${(f.activation_freq || 0).toFixed(6)}`)
+ lines.push(`Max Activation,${(f.max_activation || 0).toFixed(4)}`)
+ lines.push('')
+
+ // Vocab logits
+ const logits = vocabLogits?.[String(f.feature_id)]
+ if (logits) {
+ lines.push('TOP PROMOTED CODONS')
+ lines.push('Codon,Amino Acid,Logit Value')
+ ;(logits.top_positive || []).forEach(([codon, val]) => {
+ lines.push(`${codon},${CODON_AA[codon] || '?'},${val.toFixed(4)}`)
+ })
+ lines.push('')
+
+ lines.push('TOP SUPPRESSED CODONS')
+ lines.push('Codon,Amino Acid,Logit Value')
+ ;(logits.top_negative || []).forEach(([codon, val]) => {
+ lines.push(`${codon},${CODON_AA[codon] || '?'},${val.toFixed(4)}`)
+ })
+ lines.push('')
+ }
+
+ // Feature analysis
+ const analysis = featureAnalysis?.[String(f.feature_id)]
+ if (analysis?.codon_annotations) {
+ lines.push('CODON ANNOTATIONS')
+ const ann = analysis.codon_annotations
+ if (ann.amino_acid) {
+ lines.push(`Amino Acid,${ann.amino_acid.aa}`)
+ lines.push(`AA Frequency,${(ann.amino_acid.fraction * 100).toFixed(1)}%`)
+ }
+ if (ann.codon_usage) {
+ lines.push(`Codon Usage,${ann.codon_usage.bias}`)
+ }
+ if (ann.wobble) {
+ lines.push(`Wobble Position,${ann.wobble.preference}`)
+ }
+ if (ann.cpg) {
+ lines.push(`CpG Context,${ann.cpg.fraction}`)
+ }
+ lines.push('')
+ }
+ })
+
+ // Create and download file
+ const csv = lines.join('\n')
+ const blob = new Blob([csv], { type: 'text/csv' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `edited_features_${new Date().toISOString().split('T')[0]}.csv`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }, [features, vocabLogits, featureAnalysis])
+
+ // Update Mosaic crossfilter when "Edited Only" toggle changes
+ useEffect(() => {
+ if (!brushRef.current || !mosaicReady) return
+
+ const selection = brushRef.current
+
+ if (showEditedOnly) {
+ // Get all edited feature IDs from localStorage
+ const editedIds = features
+ .filter(f => localStorage.getItem(`featureTitle_${f.feature_id}`) !== null)
+ .map(f => f.feature_id)
+
+ if (editedIds.length > 0) {
+ // Create predicate: feature_id IN (id1, id2, id3, ...)
+ const idsStr = editedIds.join(',')
+ // Use raw SQL string, not literal() which would quote it as a string
+ const predicateSql = `feature_id IN (${idsStr})`
+
+ try {
+ selection.update({
+ source: editedSource.current,
+ predicate: predicateSql,
+ value: 'edited'
+ })
+ } catch (err) {
+ console.warn('Error updating edited filter:', err)
+ }
+ }
+ } else {
+ // Clear the edited filter
+ try {
+ selection.update({
+ source: editedSource.current,
+ predicate: null,
+ value: null
+ })
+ } catch (err) {
+ console.warn('Error clearing edited filter:', err)
+ }
+ }
+ }, [showEditedOnly, mosaicReady, features])
+
+ // Update Mosaic crossfilter when legend selection changes
+ useEffect(() => {
+ if (!brushRef.current || !mosaicReady) return
+
+ const selection = brushRef.current
+
+ if (hiddenCategories.size > 0 && selectedCategory && selectedCategory !== 'none') {
+ const colInfo = categoryColumns.find(c => c.name === selectedCategory)
+ if (colInfo && (colInfo.type === 'string' || colInfo.type === 'integer')) {
+ const values = Array.from(hiddenCategories).map(v => `'${v.replace(/'/g, "''")}'`).join(',')
+ const predicateSql = `"${selectedCategory}" IN (${values})`
+
+ try {
+ selection.update({
+ source: legendSource.current,
+ predicate: predicateSql,
+ value: Array.from(hiddenCategories).join(',')
+ })
+ } catch (err) {
+ console.warn('Legend filter update failed:', err)
+ }
+ }
+ } else {
+ try {
+ selection.update({
+ source: legendSource.current,
+ predicate: null,
+ value: null
+ })
+ } catch (err) {
+ // Ignore
+ }
+ }
+ }, [hiddenCategories, selectedCategory, mosaicReady, categoryColumns])
+
+ // Handle search - updates both Mosaic crossfilter (for UMAP/histograms) and local state (for cards)
+ const handleSearchChange = useCallback((e) => {
+ const term = e.target.value
+ setSearchTerm(term)
+
+ // Also update Mosaic crossfilter so UMAP and histograms filter
+ if (brushRef.current) {
+ const selection = brushRef.current
+
+ try {
+ if (term.trim()) {
+ // Build predicate using sql template - ILIKE for case-insensitive search
+ const pattern = literal('%' + term.trim() + '%')
+ const predicate = sql`label ILIKE ${pattern}`
+
+ selection.update({
+ source: searchSource.current,
+ predicate: predicate,
+ value: term.trim()
+ })
+ } else {
+ // Clear search by removing the clause
+ selection.update({
+ source: searchSource.current,
+ predicate: null,
+ value: null
+ })
+ }
+ } catch (err) {
+ console.warn('Search update error:', err)
+ }
+ }
+ }, [])
+
+ // Filter and sort features
+ const filteredFeatures = useMemo(() => {
+ let result = features
+
+ // Filter by Mosaic selection (includes UMAP brush)
+ if (selectedFeatureIds !== null) {
+ result = result.filter(f => selectedFeatureIds.has(f.feature_id))
+ }
+
+ // Also filter by search term client-side (searches metadata fields)
+ if (searchTerm.trim()) {
+ const q = searchTerm.toLowerCase()
+ result = result.filter(f =>
+ f.description?.toLowerCase().includes(q) ||
+ f.feature_id.toString().includes(q) ||
+ f.best_annotation?.toLowerCase().includes(q) ||
+ (localStorage.getItem(`featureTitle_${f.feature_id}`) || '').toLowerCase().includes(q)
+ )
+ }
+
+ // Filter by edited features only
+ if (showEditedOnly) {
+ result = result.filter(f => localStorage.getItem(`featureTitle_${f.feature_id}`) !== null)
+ }
+
+ // Helper: unlabeled features sort last
+ const isUnlabeled = (f) => {
+ const lbl = (f.label || f.description || '').toLowerCase()
+ return !lbl || lbl.startsWith('feature ') || lbl.includes('common codons')
+ }
+
+ // Sort (labeled features first, then by chosen metric)
+ if (sortBy === 'frequency') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (b.activation_freq || 0) - (a.activation_freq || 0))
+ } else if (sortBy === 'max_activation') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (b.max_activation || 0) - (a.max_activation || 0))
+ } else if (sortBy === 'feature_id') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || a.feature_id - b.feature_id)
+ } else if (sortBy === 'high_score_fraction') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (b.high_score_fraction || 0) - (a.high_score_fraction || 0))
+ } else if (sortBy === 'mean_variant_delta') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || Math.abs(b.mean_variant_delta || 0) - Math.abs(a.mean_variant_delta || 0))
+ } else if (sortBy === 'mean_site_delta') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || Math.abs(b.mean_site_delta || 0) - Math.abs(a.mean_site_delta || 0))
+ } else if (sortBy === 'mean_local_delta') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || Math.abs(b.mean_local_delta || 0) - Math.abs(a.mean_local_delta || 0))
+ } else if (sortBy === 'clinvar_fraction') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (b.clinvar_fraction || 0) - (a.clinvar_fraction || 0))
+ } else if (sortBy === 'mean_phylop') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (b.mean_phylop || 0) - (a.mean_phylop || 0))
+ } else if (sortBy === 'gc_mean') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || Math.abs((b.gc_mean || 0.5) - 0.5) - Math.abs((a.gc_mean || 0.5) - 0.5))
+ } else if (sortBy === 'trinuc_entropy') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (a.trinuc_entropy ?? 99) - (b.trinuc_entropy ?? 99))
+ } else if (sortBy === 'gene_entropy') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (a.gene_entropy ?? 99) - (b.gene_entropy ?? 99))
+ } else if (sortBy === 'gene_n_unique') {
+ result = [...result].sort((a, b) => isUnlabeled(a) - isUnlabeled(b) || (a.gene_n_unique || 999) - (b.gene_n_unique || 999))
+ }
+
+ return result
+ }, [features, sortBy, selectedFeatureIds, searchTerm, showEditedOnly])
+
+ // Reset pagination when filters change
+ useEffect(() => {
+ setDisplayedCardCount(20)
+ loadingMoreRef.current = false
+ }, [searchTerm, sortBy, selectedFeatureIds, showEditedOnly])
+
+ if (loading) {
+ const pct = Math.round(((loadingProgress.step - 1) / loadingProgress.total) * 100)
+ return (
+
+
Loading dashboard...
+
+
{loadingProgress.message}
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
Error: {error}
+
+ Make sure features_atlas.parquet, feature_metadata.parquet, and feature_examples.parquet exist in the public/ folder.
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ Export Edited
+
+ setDarkMode(d => !d)}
+ title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
+ style={{
+ padding: '6px',
+ border: '1px solid var(--border-input)',
+ borderRadius: '6px',
+ background: 'var(--bg-input)',
+ cursor: 'pointer',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--text-secondary)',
+ }}
+ >
+ {darkMode ? : }
+
+
+
+
+
+
+
+
+
+ Decoder UMAP
+
+
+ Color by:
+ {
+ const val = e.target.value
+ setSelectedCategory(val)
+ setHiddenCategories(new Set())
+ setHistMetric3(val)
+ setClickedFeatureId(null)
+ setCardResetKey(k => k + 1)
+ }}
+ style={styles.colorSelect}
+ >
+ None
+ {categoryColumns.map(col => (
+
+ {col.name.replace(/_/g, ' ')}
+
+ ))}
+
+ setShowMetricsModal(true)}
+ style={{
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ width: '15px', height: '15px', borderRadius: '50%', border: '1px solid var(--border-input)',
+ fontSize: '10px', fontWeight: '600', color: 'var(--text-tertiary)', cursor: 'pointer',
+ userSelect: 'none', lineHeight: 1, flexShrink: 0,
+ }}
+ >i
+
+ Clear Selection
+
+
+
+
+ {mosaicReady && (
+
+ )}
+ {selectedCategory && selectedCategory !== 'none' && (() => {
+ const colInfo = categoryColumns.find(c => c.name === selectedCategory)
+ if (!colInfo) return null
+
+ if (colInfo.type === 'sequential') {
+ const colors = [
+ "#c359ef", "#9525C6", "#0046a4", "#0074DF", "#3f8500",
+ "#76B900", "#ef9100", "#F9C500", "#ff8181", "#EF2020"
+ ]
+ const vals = features
+ .map(f => f[selectedCategory])
+ .filter(v => v != null && !isNaN(v))
+ const minVal = vals.length > 0 ? Math.min(...vals) : 0
+ const maxVal = vals.length > 0 ? Math.max(...vals) : 1
+ const fmt = (v) => Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 1 ? v.toFixed(1) : v.toFixed(3)
+ return (
+
+
{fmt(maxVal)}
+
+
{fmt(minVal)}
+
+ {selectedCategory.replace(/_/g, ' ')}
+
+
+ )
+ }
+
+ if (colInfo.type === 'string' || colInfo.type === 'integer') {
+ const catColors = [
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
+ "#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5",
+ "#c49c94", "#f7b6d2", "#c7c7c7", "#dbdb8d", "#9edae5"
+ ]
+ // Count occurrences of each category value, sorted alphabetically
+ // (matching DENSE_RANK ORDER BY which is alphabetical)
+ const counts = {}
+ for (const f of features) {
+ const val = f[selectedCategory]
+ if (val != null && val !== '') {
+ counts[val] = (counts[val] || 0) + 1
+ }
+ }
+ // Sort alphabetically to match dense_rank ordering
+ const sortedCategories = Object.keys(counts).sort()
+ return (
+
+
+ {selectedCategory.replace(/_/g, ' ').replace('gsea ', '')}
+
+ {sortedCategories.map((cat, i) => {
+ const hasFilter = hiddenCategories.size > 0
+ const isHidden = hasFilter && !hiddenCategories.has(cat)
+ return (
+
{
+ if (e.metaKey || e.ctrlKey) {
+ // Cmd/Ctrl+click: toggle this category in the selection
+ setHiddenCategories(prev => {
+ const next = new Set(prev)
+ if (next.has(cat)) {
+ next.delete(cat)
+ // If nothing left selected, clear filter
+ return next.size === 0 ? new Set() : next
+ } else {
+ next.add(cat)
+ return next
+ }
+ })
+ } else {
+ // Regular click: solo this category (or clear if already solo'd)
+ setHiddenCategories(prev => {
+ if (prev.size === 1 && prev.has(cat)) return new Set()
+ return new Set([cat])
+ })
+ }
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: '5px', padding: '2px 0',
+ cursor: 'pointer', opacity: isHidden ? 0.15 : 1,
+ userSelect: 'none',
+ }}
+ >
+
+
+ {cat}
+
+
+ {counts[cat]}
+
+
+ )
+ })}
+
+ )
+ }
+
+ return null
+ })()}
+
+
+
+
+ {[
+ { value: histMetric1, setter: setHistMetric1 },
+ { value: histMetric2, setter: setHistMetric2 },
+ { value: histMetric3, setter: setHistMetric3 },
+ ].map(({ value, setter }, i) => (
+
+
+ setter(e.target.value)}
+ style={{
+ padding: '4px 8px',
+ fontSize: '11px',
+ border: '1px solid var(--border-input)',
+ borderRadius: '4px',
+ background: 'var(--bg-input)',
+ color: 'var(--text)',
+ cursor: 'pointer',
+ }}
+ >
+ Log Frequency
+ Max Activation
+ Activation Frequency
+ {categoryColumns
+ .filter(c => c.type === 'sequential')
+ .map(col => (
+
+ {col.name.replace(/_/g, ' ')}
+
+ ))}
+
+
+ {mosaicReady && value && value !== 'none' && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ setSortBy(e.target.value)}
+ style={styles.sortSelect}
+ >
+ By Frequency
+ By Max Activation
+ By Feature ID
+ By High Score Fraction
+ By Variant Delta
+ By Site Delta
+ By Local Delta
+ By ClinVar Fraction
+ By PhyloP
+ By GC Bias
+ By Trinuc Specificity
+ By Gene Specificity
+ By Gene Specificity (count)
+
+
+ setShowEditedOnly(!showEditedOnly)}
+ style={{ cursor: 'pointer' }}
+ />
+ Edited Only
+
+
+
+
+
+ Showing {filteredFeatures.length} of {features.length} features
+ {selectedFeatureIds !== null && ` (${selectedFeatureIds.size} selected in UMAP)`}
+
+ setShowGuideModal(true)}
+ style={{
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ width: '15px', height: '15px', borderRadius: '50%', border: '1px solid #bbb',
+ fontSize: '10px', fontWeight: '600', color: '#888', cursor: 'pointer',
+ userSelect: 'none', lineHeight: 1, flexShrink: 0,
+ }}
+ >i
+
+
+
setDisplayedCardCount(c => Math.min(c + 200, filteredFeatures.length))}
+ />
+
+
+
+ {showGuideModal && (
+
setShowGuideModal(false)}
+ style={{
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
+ }}
+ >
+
e.stopPropagation()}
+ style={{
+ background: 'var(--bg-card)', borderRadius: '10px', maxWidth: '560px', width: '90%',
+ maxHeight: '80vh', overflowY: 'auto', padding: '28px 32px',
+ boxShadow: '0 8px 30px rgba(0,0,0,0.2)',
+ }}
+ >
+
+
Feature Card Guide
+ setShowGuideModal(false)}
+ style={{ cursor: 'pointer', fontSize: '20px', color: '#999', lineHeight: 1 }}
+ >×
+
+
+
+
Decoder Logits
+
+ The decoder logits histogram shows the projection of each feature's learned decoder weight vector through the language model's prediction head, with the mean logit vector subtracted across all features. This mean-centering removes the model's shared baseline bias toward common codons (e.g. GCC), so values reflect what each feature specifically promotes or suppresses relative to the average feature. Each bar represents a codon. Green bars indicate codons the feature promotes above baseline; red bars indicate codons it suppresses below baseline. Gray bars have no feature-specific effect. This tells you what the feature pushes the model to output — not what activates it. Stop codons (TAA, TAG, TGA) are excluded because the model was trained on coding sequences where internal stops almost never appear, so all features uniformly suppress them.
+
+
+
Top Activating Sequences
+
+ These are the protein-coding sequences where this feature fires most strongly. Each codon is colored by its activation value — brighter highlights mean the feature responds more strongly at that position. This shows what inputs trigger the feature, which is conceptually distinct from decoder logits. A feature can activate strongly on a particular codon (e.g., lysine codons) without promoting that same codon in the output — it may instead influence downstream or contextual predictions.
+
+
+
+
+
+ )}
+
+ {showMetricsModal && (
+
setShowMetricsModal(false)}
+ style={{
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
+ }}
+ >
+
e.stopPropagation()}
+ style={{
+ background: 'var(--bg-card)', borderRadius: '10px', maxWidth: '620px', width: '90%',
+ maxHeight: '80vh', overflowY: 'auto', padding: '28px 32px',
+ boxShadow: '0 8px 30px rgba(0,0,0,0.2)',
+ }}
+ >
+
+
Variant Analysis Metrics
+ setShowMetricsModal(false)}
+ style={{ cursor: 'pointer', fontSize: '20px', color: '#999', lineHeight: 1 }}
+ >×
+
+
+
+
Mean Variant Score (per model)
+
+ For each feature, the average model effect score across variant sequences where the feature fires. Computed for the 1b_cdwt model score column. A high value means the feature preferentially activates on variants that model predicts to be functionally impactful.
+
+
+
High Score Fraction
+
+ Variants are split at the median model score. Among variants where a feature fires, what fraction are high-scoring? A value of 0.5 means no preference. Above 0.5 means the feature disproportionately fires on high-impact variants. Robust to outliers — measures distributional preference rather than average.
+
+
+
ClinVar Fraction
+
+ Among variant sequences where the feature fires, the fraction from ClinVar vs COSMIC. ClinVar variants are germline (inherited, Mendelian disease). COSMIC variants are somatic (cancer mutations). High ClinVar fraction means the feature responds to germline disease patterns; low means it prefers somatic cancer mutation patterns.
+
+
+
Mean PhyloP
+
+ Average evolutionary conservation score (PhyloP) across sequences where the feature fires. High values indicate conserved positions (functionally important). Negative values indicate rapidly evolving regions. Features with high mean PhyloP capture evolutionarily constrained patterns.
+
+
+
Mean Variant Delta
+
+ For each gene, the difference in max feature activation between the variant and reference sequence: max_act(variant) − max_act(ref), averaged across all variant-ref pairs. Positive means the mutation increases feature activation; negative means it suppresses it. Near zero means the feature responds to the gene background, not the specific mutation. This controls for gene identity.
+
+
+
Mean Site Delta
+
+ Like mean variant delta, but measured only at the exact codon position where the mutation occurs: activation_f(variant, pos) − activation_f(ref, pos). This captures direct effects — the feature responding to the changed codon itself. Compare with mean variant delta: a large variant delta but small site delta means the feature captures indirect/distal effects of the mutation (e.g., changes to predicted protein folding context), not the local codon change.
+
+
+
Mean Local Delta
+
+ Like variant delta, but using the max activation within a 3-codon window around the variant site instead of the full sequence. Captures local effects of the mutation: max(window_variant) − max(window_ref). A large local delta with a small global delta means the mutation's effect is localized. Compare with site delta (exact position only) and variant delta (full sequence).
+
+
+
GC Content (mean, std)
+
+ Mean and standard deviation of GC content across all sequences where the feature fires. Features with extreme GC mean (far from ~0.5) are GC-biased. Features with low GC std activate only on sequences with similar GC content — suggesting sensitivity to nucleotide composition rather than specific codon patterns.
+
+
+
Trinuc Entropy
+
+ Shannon entropy (in bits) of the trinucleotide context distribution among variant sequences where the feature fires. Low entropy means the feature concentrates on specific mutation contexts (e.g., C[C>T]G for CpG transitions). High entropy means it fires across diverse mutation types. The dominant fraction shows what fraction of activations come from the most common trinuc context.
+
+
+
Gene Distribution
+
+ Shannon entropy of the gene distribution among sequences where the feature fires. Low entropy means the feature is gene-specific — it concentrates on a few genes. High entropy means it fires broadly. gene_n_unique is the number of distinct genes. gene_dominant_frac is the fraction from the most common gene. A feature with low entropy and high dominant fraction has learned something specific to one gene family.
+
+
+
High Score Delta
+
+ Same as mean variant delta, but averaged only over variants with model scores above the median. Shows how the feature responds specifically to high-impact mutations. Compare with low score delta: if high_score_delta >> low_score_delta, the feature selectively detects impactful mutations.
+
+
+
Low Score Delta
+
+ Same as mean variant delta, but averaged only over variants with model scores below the median. Features where high score delta and low score delta differ significantly have learned to discriminate mutation severity. Features where both are similar just detect that a mutation occurred without distinguishing impact.
+
+
+
+
+ )}
+
+ )
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/Dashboard.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/Dashboard.jsx
new file mode 100644
index 0000000000..21ab5447e0
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/Dashboard.jsx
@@ -0,0 +1,71 @@
+import React, { useEffect, useState } from 'react'
+import App from './App'
+import GenerativeSteering from './GenerativeSteering'
+import SequenceInspector from './SequenceInspector'
+import SequenceUMAPView from './SequenceUMAPView'
+import { Sun, Moon } from 'lucide-react'
+
+// Four-tab shell. The Feature atlas is the static-parquet explorer (works offline);
+// Generative steering, Sequence inspector, and Sequence UMAP all talk to the live
+// backend (server.py) through the /api proxy.
+const TABS = [
+ { id: 'atlas', label: 'Feature atlas' },
+ { id: 'steering', label: 'Generative steering' },
+ { id: 'inspector', label: 'Sequence inspector' },
+ { id: 'sequmap', label: 'Sequence UMAP' },
+]
+
+export default function Dashboard() {
+ const [tab, setTab] = useState('atlas')
+ const [dark, setDark] = useState(true)
+
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', dark)
+ }, [dark])
+
+ return (
+
+
+ Evo 2 SAE Feature Explorer
+ {TABS.map((t) => (
+ setTab(t.id)} style={tab === t.id ? S.tabOn : S.tabOff}>
+ {t.label}
+
+ ))}
+ setDark((d) => !d)} style={S.theme} title="Toggle theme">
+ {dark ? : }
+
+
+
+
+ {tab === 'atlas' &&
}
+ {tab === 'steering' &&
}
+ {tab === 'inspector' &&
}
+ {tab === 'sequmap' &&
}
+
+
+ )
+}
+
+const S = {
+ shell: { height: '100vh', display: 'flex', flexDirection: 'column', background: 'var(--bg)', color: 'var(--text)' },
+ tabBar: {
+ display: 'flex', alignItems: 'center', gap: '6px', padding: '8px 16px',
+ background: 'var(--bg-card)', borderBottom: '1px solid var(--border)', flexShrink: 0,
+ },
+ brand: { fontSize: '13px', fontWeight: 700, color: 'var(--text-heading)', marginRight: '14px' },
+ tabOn: {
+ padding: '6px 14px', border: '1px solid var(--accent)', background: 'var(--bg-card-expanded)',
+ color: 'var(--accent)', borderRadius: '5px', cursor: 'pointer', fontSize: '12px', fontWeight: 600,
+ },
+ tabOff: {
+ padding: '6px 14px', border: '1px solid var(--border)', background: 'transparent',
+ color: 'var(--text-secondary)', borderRadius: '5px', cursor: 'pointer', fontSize: '12px',
+ },
+ theme: {
+ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ width: '30px', height: '30px', border: '1px solid var(--border)', background: 'transparent',
+ color: 'var(--text-secondary)', borderRadius: '5px', cursor: 'pointer',
+ },
+ content: { flex: 1, minHeight: 0 },
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/EmbeddingView.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/EmbeddingView.jsx
new file mode 100644
index 0000000000..4c3216cfe4
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/EmbeddingView.jsx
@@ -0,0 +1,334 @@
+import React, { useEffect, useRef } from 'react'
+import { EmbeddingViewMosaic } from 'embedding-atlas'
+
+// Color palette for categories (D3 category10 + extended)
+const CATEGORY_COLORS = [
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
+ "#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5",
+ "#c49c94", "#f7b6d2", "#c7c7c7", "#dbdb8d", "#9edae5"
+]
+
+// Sequential color palette (NVIDIA brand)
+const SEQUENTIAL_COLORS = [
+ "#c359ef", "#9525C6", "#0046a4", "#0074DF", "#3f8500",
+ "#76B900", "#ef9100", "#F9C500", "#ff8181", "#EF2020"
+]
+
+// Default color for uniform coloring (NVIDIA green)
+const DEFAULT_COLOR = "#76b900"
+
+// Custom tooltip renderer
+class FeatureTooltip {
+ constructor(node, props) {
+ this.node = node
+ this.inner = document.createElement("div")
+ this.inner.style.cssText = `
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-family: 'NVIDIA Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 13px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.25);
+ max-width: 300px;
+ color: var(--text);
+ `
+ this.node.appendChild(this.inner)
+ this.update(props)
+ }
+
+ update(props) {
+ const { tooltip } = props
+ if (!tooltip) {
+ this.inner.innerHTML = ""
+ return
+ }
+ const featureId = tooltip.identifier ?? ""
+ const label = tooltip.fields?.label ?? tooltip.text ?? ""
+ const logFreq = tooltip.fields?.log_frequency
+ const maxAct = tooltip.fields?.max_activation
+ const colorField = tooltip.fields?.color_field
+
+ this.inner.innerHTML = `
+ Feature #${featureId}
+ ${label}
+ ${colorField ? `Category: ${colorField}
` : ""}
+ ${logFreq !== undefined ? `Log Frequency: ${logFreq.toFixed(3)}
` : ""}
+ ${maxAct !== undefined ? `Max Activation: ${maxAct.toFixed(2)}
` : ""}
+ `
+ }
+
+ destroy() {
+ this.inner.remove()
+ }
+}
+
+export default function EmbeddingView({ brush, categoryColumn, categoryColumns, onFeatureClick, highlightedFeatureId, viewportState, onViewportChange, labels, features, selectedCategory, darkMode, hiddenCategories }) {
+ const containerRef = useRef(null)
+ const viewRef = useRef(null)
+ const onFeatureClickRef = useRef(onFeatureClick)
+ const onViewportChangeRef = useRef(onViewportChange)
+
+ // Keep the callback refs updated
+ useEffect(() => {
+ onFeatureClickRef.current = onFeatureClick
+ }, [onFeatureClick])
+
+ useEffect(() => {
+ onViewportChangeRef.current = onViewportChange
+ }, [onViewportChange])
+
+ // Update selection and tooltip when highlightedFeatureId changes
+ useEffect(() => {
+ if (viewRef.current && highlightedFeatureId != null) {
+ // Find the feature data
+ const feature = features?.find(f => f.feature_id === highlightedFeatureId)
+
+ // Build tooltip fields
+ const fields = {
+ label: feature?.label || `Feature ${highlightedFeatureId}`,
+ log_frequency: feature?.log_frequency || feature?.activation_freq || 0,
+ max_activation: feature?.max_activation || 0,
+ color_field: null
+ }
+
+ // Add selected category metric if available
+ if (selectedCategory && selectedCategory !== 'none' && feature) {
+ const metricName = selectedCategory.replace(/_/g, ' ')
+ const metricValue = feature[selectedCategory]
+ if (metricValue !== undefined && metricValue !== null) {
+ fields.color_field = `${metricName}: ${typeof metricValue === 'number' ? metricValue.toFixed(3) : metricValue}`
+ }
+ }
+
+ // Construct tooltip object with feature data
+ const tooltipObj = {
+ identifier: highlightedFeatureId,
+ text: `Feature #${highlightedFeatureId}`,
+ x: feature?.x,
+ y: feature?.y,
+ fields: fields
+ }
+ // Clear previous selection first to avoid animated transition
+ viewRef.current.update({
+ selection: null,
+ tooltip: null
+ })
+ viewRef.current.update({
+ selection: [highlightedFeatureId],
+ tooltip: tooltipObj
+ })
+ } else if (viewRef.current && highlightedFeatureId == null) {
+ viewRef.current.update({
+ selection: null,
+ tooltip: null
+ })
+ }
+ }, [highlightedFeatureId, features, selectedCategory])
+
+ // Update viewport when viewportState changes (skip null to let auto-fit persist)
+ useEffect(() => {
+ if (viewRef.current && viewportState != null) {
+ viewRef.current.update({
+ viewportState: viewportState
+ })
+ }
+ }, [viewportState])
+
+ // Update color scheme when dark mode changes
+ useEffect(() => {
+ if (viewRef.current) {
+ viewRef.current.update({
+ config: { colorScheme: darkMode ? "dark" : "light" }
+ })
+ }
+ }, [darkMode])
+
+ // Update labels when they change
+ useEffect(() => {
+ if (viewRef.current && labels) {
+ console.log('[EmbeddingView] updating labels:', labels.length, labels.slice(0, 2))
+ viewRef.current.update({
+ labels: labels
+ })
+ }
+ }, [labels])
+
+ useEffect(() => {
+ if (!containerRef.current || !brush) return
+
+ // Clear previous view
+ if (viewRef.current) {
+ containerRef.current.innerHTML = ''
+ }
+
+ // Determine category column and colors
+ let categoryColName = null
+ let colors = Array(50).fill(DEFAULT_COLOR)
+ let additionalFields = {
+ label: "label",
+ log_frequency: "log_frequency",
+ max_activation: "max_activation",
+ }
+
+ if (categoryColumn && categoryColumn !== "none") {
+ const colInfo = categoryColumns?.find(c => c.name === categoryColumn)
+ if (colInfo) {
+ if (colInfo.type === 'sequential') {
+ // Sequential column - use binned version and sequential colors
+ categoryColName = `${categoryColumn}_bin`
+ colors = SEQUENTIAL_COLORS
+ } else if (colInfo.type === 'string') {
+ // Categorical string column
+ categoryColName = `${categoryColumn}_cat`
+ colors = CATEGORY_COLORS.slice(0, Math.max(colInfo.nUnique, 10))
+ } else {
+ // Integer categorical column
+ categoryColName = categoryColumn
+ colors = CATEGORY_COLORS.slice(0, Math.max(colInfo.nUnique, 10))
+ }
+ additionalFields.color_field = categoryColumn
+ }
+ }
+
+ const width = containerRef.current.clientWidth
+ const height = containerRef.current.clientHeight
+
+ try {
+ viewRef.current = new EmbeddingViewMosaic(
+ containerRef.current,
+ {
+ table: "features",
+ x: "x",
+ y: "y",
+ category: categoryColName,
+ text: "label",
+ identifier: "feature_id",
+ filter: brush,
+ rangeSelection: brush,
+ selection: highlightedFeatureId != null ? [highlightedFeatureId] : null,
+ viewportState: viewportState,
+ categoryColors: colors,
+ width: width,
+ height: height,
+ labels: labels || null,
+ config: {
+ mode: "points",
+ colorScheme: document.documentElement.classList.contains('dark') ? "dark" : "light",
+ autoLabelEnabled: false,
+ },
+ theme: {
+ brandingLink: {
+ text: "NVIDIA BioNeMo",
+ href: "https://github.com/NVIDIA/bionemo-framework",
+ },
+ },
+ additionalFields: additionalFields,
+ customTooltip: FeatureTooltip,
+ onSelection: (selection) => {
+ // selection is DataPoint[] | null
+ if (!onFeatureClickRef.current) return
+
+ if (selection && selection.length > 0) {
+ // Get the last clicked point (most recent selection)
+ const lastPoint = selection[selection.length - 1]
+ const featureId = lastPoint?.identifier ?? lastPoint
+ const x = lastPoint?.x
+ const y = lastPoint?.y
+ if (featureId != null) {
+ onFeatureClickRef.current(featureId, x, y)
+ }
+ } else {
+ // Clicked on empty canvas - clear selection
+ onFeatureClickRef.current(null)
+ }
+ },
+ onViewportState: (vp) => {
+ if (onViewportChangeRef.current && vp) {
+ onViewportChangeRef.current(vp)
+ }
+ },
+ }
+ )
+ } catch (err) {
+ console.warn('Error creating EmbeddingViewMosaic:', err)
+ }
+
+ return () => {
+ if (containerRef.current) {
+ containerRef.current.innerHTML = ''
+ }
+ }
+ }, [brush])
+
+ // Update category coloring in-place (without recreating the view)
+ useEffect(() => {
+ if (!viewRef.current) return
+
+ let categoryColName = null
+ const HIDDEN_COLOR = darkMode ? "#0a0a0a" : "#fafafa"
+ let colors = Array(50).fill(HIDDEN_COLOR)
+
+ if (categoryColumn && categoryColumn !== "none") {
+ const colInfo = categoryColumns?.find(c => c.name === categoryColumn)
+ if (colInfo) {
+ if (colInfo.type === 'sequential') {
+ categoryColName = `${categoryColumn}_bin`
+ colors = SEQUENTIAL_COLORS
+ } else if (colInfo.type === 'string') {
+ categoryColName = `${categoryColumn}_cat`
+ colors = CATEGORY_COLORS.slice(0, Math.max(colInfo.nUnique, 10))
+ // Map colors to match DENSE_RANK order, dim non-selected when filtering
+ if (hiddenCategories && hiddenCategories.size > 0 && features) {
+ const allCatNames = [...new Set(
+ features.map(f => f[categoryColumn]).filter(v => v != null)
+ )].sort()
+ colors = colors.map((c, i) => {
+ const name = allCatNames[i]
+ if (!name) return c
+ return !hiddenCategories.has(name) ? HIDDEN_COLOR : c
+ })
+ }
+ } else {
+ categoryColName = categoryColumn
+ colors = CATEGORY_COLORS.slice(0, Math.max(colInfo.nUnique, 10))
+ }
+ }
+ }
+
+ viewRef.current.update({
+ category: categoryColName,
+ categoryColors: colors,
+ selection: null,
+ tooltip: null,
+ })
+ }, [categoryColumn, categoryColumns, hiddenCategories, darkMode, features])
+
+ // Handle resize
+ useEffect(() => {
+ const handleResize = () => {
+ if (viewRef.current && containerRef.current) {
+ const width = containerRef.current.clientWidth
+ const height = containerRef.current.clientHeight
+ viewRef.current.update({ width, height })
+ }
+ }
+
+ const resizeObserver = new ResizeObserver(handleResize)
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current)
+ }
+
+ return () => {
+ resizeObserver.disconnect()
+ }
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureCard.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureCard.jsx
new file mode 100644
index 0000000000..d45bc121df
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureCard.jsx
@@ -0,0 +1,518 @@
+import React, { useState, useEffect, useRef, forwardRef } from 'react'
+import SequenceView, { computeAlignInfo } from './SequenceView'
+import FeatureDetailPage from './FeatureDetailPage'
+import { getRegionLabel } from './utils'
+
+const styles = {
+ card: {
+ background: 'var(--bg-card)',
+ borderRadius: '8px',
+ border: '1px solid var(--border)',
+ flexShrink: 0,
+ },
+ cardHighlighted: {
+ background: 'var(--bg-card)',
+ borderRadius: '8px',
+ border: '2px solid var(--highlight-border)',
+ flexShrink: 0,
+ boxShadow: '0 2px 8px var(--highlight-shadow)',
+ },
+ header: {
+ padding: '12px 14px',
+ borderBottom: '1px solid var(--border-light)',
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ gap: '10px',
+ },
+ headerLeft: {
+ flex: 1,
+ minWidth: 0,
+ },
+ featureId: {
+ fontSize: '11px',
+ color: 'var(--text-tertiary)',
+ fontFamily: 'monospace',
+ marginBottom: '2px',
+ },
+ description: {
+ fontSize: '13px',
+ fontWeight: '500',
+ wordBreak: 'break-word',
+ lineHeight: '1.4',
+ color: 'var(--text)',
+ },
+ userTitle: {
+ fontSize: '13px',
+ fontWeight: '500',
+ wordBreak: 'break-word',
+ lineHeight: '1.4',
+ color: 'var(--accent)',
+ fontStyle: 'italic',
+ },
+ stats: {
+ display: 'flex',
+ gap: '12px',
+ fontSize: '11px',
+ color: 'var(--text-secondary)',
+ flexShrink: 0,
+ },
+ stat: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-end',
+ },
+ statLabel: {
+ color: 'var(--text-muted)',
+ fontSize: '9px',
+ textTransform: 'uppercase',
+ },
+ statValue: {
+ fontFamily: 'monospace',
+ fontWeight: '500',
+ },
+ expandIcon: {
+ color: 'var(--text-muted)',
+ fontSize: '10px',
+ marginLeft: '6px',
+ },
+ expandedContent: {
+ padding: '10px 14px',
+ background: 'var(--bg-card-expanded)',
+ maxHeight: '900px',
+ overflowY: 'auto',
+ },
+ sectionHeader: {
+ fontSize: '10px',
+ color: 'var(--text-tertiary)',
+ textTransform: 'uppercase',
+ marginBottom: '8px',
+ fontWeight: '500',
+ },
+ example: {
+ marginBottom: '8px',
+ padding: '8px 10px',
+ background: 'var(--bg-example)',
+ borderRadius: '4px',
+ border: '1px solid var(--border-light)',
+ },
+ exampleMeta: {
+ fontSize: '10px',
+ color: 'var(--text-muted)',
+ marginBottom: '4px',
+ fontFamily: 'monospace',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ proteinId: {
+ color: 'var(--text-heading)',
+ fontWeight: '700',
+ },
+ annotation: {
+ color: 'var(--text-secondary)',
+ fontStyle: 'italic',
+ marginLeft: '8px',
+ },
+ uniprotLink: {
+ color: 'var(--link)',
+ textDecoration: 'none',
+ fontSize: '11px',
+ marginLeft: '4px',
+ opacity: 0.6,
+ },
+ noExamples: {
+ color: 'var(--text-muted)',
+ fontSize: '12px',
+ fontStyle: 'italic',
+ },
+ densityBar: {
+ width: '50px',
+ height: '3px',
+ background: 'var(--density-bar-bg)',
+ borderRadius: '2px',
+ overflow: 'hidden',
+ marginTop: '3px',
+ },
+ densityFill: {
+ height: '100%',
+ background: '#76b900',
+ borderRadius: '2px',
+ },
+ alignBar: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px',
+ marginBottom: '10px',
+ fontSize: '10px',
+ color: '#888',
+ },
+ alignLabel: {
+ textTransform: 'uppercase',
+ fontWeight: '500',
+ },
+ alignBtn: {
+ padding: '2px 8px',
+ border: '1px solid #ddd',
+ borderRadius: '3px',
+ background: '#fff',
+ cursor: 'pointer',
+ fontSize: '10px',
+ color: '#555',
+ },
+ alignBtnActive: {
+ padding: '2px 8px',
+ border: '1px solid #76b900',
+ borderRadius: '3px',
+ background: '#f0f9e0',
+ cursor: 'pointer',
+ fontSize: '10px',
+ color: '#333',
+ fontWeight: '600',
+ },
+}
+
+const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, forceExpanded, onClick, loadExamples }, ref) {
+ const [expanded, setExpanded] = useState(false)
+ const [showDetailPage, setShowDetailPage] = useState(false)
+ const [examples, setExamples] = useState([])
+ const [loadingExamples, setLoadingExamples] = useState(false)
+ const examplesCacheRef = useRef(null)
+ const [alignMode, setAlignMode] = useState('start')
+ const scrollGroupRef = useRef([])
+ const [editingTitle, setEditingTitle] = useState(false)
+ const [userTitle, setUserTitle] = useState('')
+ const inputRef = useRef(null)
+
+ // Load user-provided title from localStorage
+ useEffect(() => {
+ const stored = localStorage.getItem(`featureTitle_${feature.feature_id}`)
+ if (stored) {
+ setUserTitle(stored)
+ }
+ }, [feature.feature_id])
+
+ // Focus input when editing starts
+ useEffect(() => {
+ if (editingTitle && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [editingTitle])
+
+ // Reset scroll group when alignment changes
+ useEffect(() => { scrollGroupRef.current = [] }, [alignMode])
+
+ // If forceExpanded changes to true, expand the card
+ useEffect(() => {
+ if (forceExpanded) {
+ setExpanded(true)
+ }
+ }, [forceExpanded])
+
+ // Lazy-load examples from DuckDB when card is expanded
+ useEffect(() => {
+ if (!expanded || !loadExamples || examplesCacheRef.current) return
+ let cancelled = false
+ setLoadingExamples(true)
+ loadExamples(feature.feature_id).then(result => {
+ if (cancelled) return
+ examplesCacheRef.current = result
+ setExamples(result)
+ setLoadingExamples(false)
+ }).catch(err => {
+ if (cancelled) return
+ console.error('Error loading examples for feature', feature.feature_id, err)
+ setLoadingExamples(false)
+ })
+ return () => { cancelled = true }
+ }, [expanded, loadExamples, feature.feature_id])
+
+ const freq = feature.activation_freq || 0
+ const maxAct = feature.max_activation || 0
+ const rawDesc = feature.label || feature.description || `Feature ${feature.feature_id}`
+ const description = rawDesc.toLowerCase().includes('common codons') ? 'Unidentified Feature' : rawDesc
+
+
+ const handleClick = () => {
+ const willExpand = !expanded
+ // Update UMAP highlight immediately, defer card expansion so it doesn't block
+ if (onClick) {
+ onClick(feature.feature_id, willExpand)
+ }
+ requestAnimationFrame(() => {
+ setExpanded(willExpand)
+ })
+ }
+
+ const handleSaveTitle = () => {
+ if (userTitle.trim()) {
+ localStorage.setItem(`featureTitle_${feature.feature_id}`, userTitle.trim())
+ } else {
+ localStorage.removeItem(`featureTitle_${feature.feature_id}`)
+ setUserTitle('')
+ }
+ setEditingTitle(false)
+ }
+
+ const handleCancelEdit = () => {
+ const stored = localStorage.getItem(`featureTitle_${feature.feature_id}`)
+ setUserTitle(stored || '')
+ setEditingTitle(false)
+ }
+
+ const displayTitle = userTitle || description
+
+ const handleTitleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ handleSaveTitle()
+ } else if (e.key === 'Escape') {
+ handleCancelEdit()
+ }
+ }
+
+ const exportToCSV = () => {
+ const lines = []
+
+ // Feature metadata section
+ lines.push('=== FEATURE METADATA ===')
+ lines.push(`Feature ID,${feature.feature_id}`)
+ lines.push(`Label,${displayTitle}`)
+ if (userTitle) {
+ lines.push(`User Title,${userTitle}`)
+ }
+ lines.push(`Activation Frequency,${(freq * 100).toFixed(2)}%`)
+ lines.push(`Max Activation,${maxAct.toFixed(4)}`)
+ lines.push('')
+
+ // Examples section
+ if (examples && examples.length > 0) {
+ lines.push('=== ACTIVATION EXAMPLES ===')
+ lines.push('Rank,Region,Max Activation,Sequence')
+ examples.forEach((ex, i) => {
+ lines.push(`${i + 1},${getRegionLabel(ex) || ''},${ex.max_activation?.toFixed(4) || ''},${ex.sequence || ''}`)
+ })
+ }
+
+ // Generate CSV
+ const csv = lines.join('\n')
+
+ // Create download link
+ const filename = `feature_${feature.feature_id}_${displayTitle.replace(/[^a-z0-9]/gi, '_').substring(0, 20)}.csv`
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
+ const link = document.createElement('a')
+ link.setAttribute('href', URL.createObjectURL(blob))
+ link.setAttribute('download', filename)
+ link.style.visibility = 'hidden'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ return (
+
+
+
+
Feature #{feature.feature_id}
+ {editingTitle ? (
+
+ setUserTitle(e.target.value)}
+ onKeyDown={handleTitleKeyDown}
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ fontSize: '13px',
+ fontWeight: '500',
+ padding: '4px 8px',
+ border: '1px solid #76b900',
+ borderRadius: '4px',
+ flex: 1,
+ }}
+ />
+ { e.stopPropagation(); handleSaveTitle() }}
+ style={{
+ padding: '2px 6px',
+ fontSize: '10px',
+ background: '#76b900',
+ color: '#fff',
+ border: 'none',
+ borderRadius: '3px',
+ cursor: 'pointer',
+ }}
+ >
+ ✓
+
+ { e.stopPropagation(); handleCancelEdit() }}
+ style={{
+ padding: '2px 6px',
+ fontSize: '10px',
+ background: '#ddd',
+ color: '#333',
+ border: 'none',
+ borderRadius: '3px',
+ cursor: 'pointer',
+ }}
+ >
+ ✕
+
+
+ ) : (
+
+
{displayTitle}
+
{ e.stopPropagation(); setEditingTitle(true) }}
+ style={{
+ fontSize: '11px',
+ color: '#999',
+ cursor: 'pointer',
+ padding: '2px 4px',
+ borderRadius: '3px',
+ userSelect: 'none',
+ }}
+ title="Click to edit title"
+ >
+ ✎
+
+
+ )}
+
+
+
+
Freq
+
{(freq * 100).toFixed(1)}%
+
+
+
+ Max
+ {maxAct.toFixed(1)}
+
+ {/* v2 roadmap placeholders — populated when real eval pipeline lands. */}
+
+ Annotation
+ —
+
+
+ Sensitivity
+ —
+
+
+ Recon Δ
+ —
+
+
{expanded ? '▼' : '▶'}
+
+
+
+ {/* Details and export buttons - shown when expanded */}
+ {expanded && (
+
+ { e.stopPropagation(); setShowDetailPage(true) }}
+ style={{
+ background: 'var(--bg-card-expanded)', border: '1px solid var(--accent)', borderRadius: '4px',
+ padding: '4px 12px', fontSize: '11px', color: 'var(--accent)', cursor: 'pointer',
+ fontWeight: '500',
+ }}
+ >
+ Full analysis
+
+ { e.stopPropagation(); exportToCSV() }}
+ style={{
+ background: 'none', border: '1px solid var(--border-input)', borderRadius: '4px',
+ padding: '4px 12px', fontSize: '11px', color: 'var(--text-secondary)', cursor: 'pointer',
+ }}
+ >
+ Export
+
+
+ )}
+
+ {expanded && (
+
+ {feature.logo_path && (
+
+
Sequence Logo
+
+
+ )}
+ {/* Sequence examples */}
+
+
Top Activating Sequences
+
+ Align by:
+ {['start', 'first_activation', 'max_activation'].map(mode => (
+ { e.stopPropagation(); setAlignMode(mode) }}
+ >
+ {mode === 'start' ? 'sequence start' : mode === 'first_activation' ? 'first activation' : 'max activation'}
+
+ ))}
+
+
+ {loadingExamples ? (
+
+ Loading examples...
+
+ ) : examples.length > 0 ? (
+ <>
+ {(() => {
+ const visibleExamples = examples.slice(0, 6)
+ const { anchor: alignAnchor, totalLength } = computeAlignInfo(visibleExamples, alignMode)
+ return visibleExamples.map((ex, i) => (
+
+
+
+ {getRegionLabel(ex)}
+ {ex.best_annotation && (
+ {ex.best_annotation}
+ )}
+
+ max: {ex.max_activation?.toFixed(3) || 'N/A'}
+
+
+
+ ))
+ })()}
+
+ >
+ ) : (
+
No examples available
+ )}
+
+ )}
+
+ {showDetailPage && (
+
setShowDetailPage(false)}
+ />
+ )}
+
+ )
+})
+
+export default FeatureCard
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureDetailPage.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureDetailPage.jsx
new file mode 100644
index 0000000000..b70fdcdbde
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureDetailPage.jsx
@@ -0,0 +1,198 @@
+import React, { useState, useEffect, useRef } from 'react'
+import SequenceView, { computeAlignInfo } from './SequenceView'
+import { getRegionLabel } from './utils'
+
+const styles = {
+ overlay: {
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(0, 0, 0, 0.5)',
+ zIndex: 2000,
+ overflowY: 'auto',
+ },
+ page: {
+ maxWidth: '960px',
+ margin: '20px auto',
+ background: 'var(--bg-card)',
+ borderRadius: '8px',
+ boxShadow: '0 4px 24px rgba(0,0,0,0.2)',
+ color: 'var(--text)',
+ },
+ header: {
+ padding: '12px 20px',
+ borderBottom: '1px solid var(--border-light)',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: '14px',
+ fontWeight: '700',
+ color: 'var(--text-heading)',
+ },
+ closeBtn: {
+ background: 'none',
+ border: '1px solid var(--border-input)',
+ borderRadius: '4px',
+ padding: '3px 10px',
+ cursor: 'pointer',
+ fontSize: '11px',
+ color: 'var(--text-secondary)',
+ },
+ section: {
+ padding: '10px 20px',
+ borderBottom: '1px solid var(--border-light)',
+ },
+ sectionTitle: {
+ fontSize: '11px',
+ fontWeight: '600',
+ marginBottom: '6px',
+ color: 'var(--text-heading)',
+ textTransform: 'uppercase',
+ },
+ example: {
+ marginBottom: '6px',
+ padding: '6px 8px',
+ background: 'var(--bg-example)',
+ borderRadius: '4px',
+ border: '1px solid var(--border-light)',
+ },
+ exampleMeta: {
+ fontSize: '10px',
+ color: 'var(--text-secondary)',
+ marginBottom: '4px',
+ fontFamily: 'monospace',
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ placeholder: {
+ border: '1px dashed var(--border)',
+ borderRadius: '6px',
+ padding: '24px',
+ textAlign: 'center',
+ color: 'var(--text-muted)',
+ fontSize: '12px',
+ fontStyle: 'italic',
+ },
+ placeholderLabel: {
+ fontSize: '13px',
+ fontWeight: '500',
+ color: 'var(--text-muted)',
+ marginBottom: '8px',
+ },
+}
+
+export default function FeatureDetailPage({ feature, examples, onClose }) {
+ const [alignMode, setAlignMode] = useState('max_activation')
+ const scrollGroupRef = useRef(null)
+
+ const freq = feature.activation_freq || 0
+ const maxAct = feature.max_activation || 0
+ const description = feature.description || feature.label || `Feature ${feature.feature_id}`
+
+ useEffect(() => {
+ const handleKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', handleKey)
+ return () => document.removeEventListener('keydown', handleKey)
+ }, [onClose])
+
+ const visibleExamples = (examples || []).slice(0, 30)
+ const { anchor: alignAnchor, totalLength } = computeAlignInfo(visibleExamples.slice(0, 6), alignMode)
+
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
+
+
+ Feature #{feature.feature_id}
+
+ {description}
+
+
+
+
+
+ freq: {(freq * 100).toFixed(1)}%
+ max: {maxAct.toFixed(1)}
+
+
✕
+
+
+
+ {feature.logo_path && (
+
+
Sequence Logo
+
+
+ )}
+
+
+
+
Top Activating Sequences
+
+ {['start', 'first_activation', 'max_activation'].map(mode => (
+ setAlignMode(mode)}
+ style={{
+ padding: '2px 8px', borderRadius: '3px', cursor: 'pointer', fontSize: '10px',
+ border: alignMode === mode ? '1px solid var(--accent)' : '1px solid var(--border-input)',
+ background: alignMode === mode ? 'var(--bg-card-expanded)' : 'var(--bg-input)',
+ color: alignMode === mode ? 'var(--accent)' : 'var(--text-secondary)',
+ fontWeight: alignMode === mode ? '600' : '400',
+ }}
+ >
+ {mode === 'start' ? 'seq start' : mode === 'first_activation' ? 'first act.' : 'max act.'}
+
+ ))}
+
+
+
+ {visibleExamples.length > 0 ? (
+ visibleExamples.map((ex, i) => (
+
+
+ {getRegionLabel(ex)}
+ max: {ex.max_activation?.toFixed(3)}
+
+
+
+ ))
+ ) : (
+
No examples loaded
+ )}
+
+
+ {/* v2 roadmap placeholders — populated when annotation + conservation pipelines land. */}
+
+
Annotations
+
+ Annotation overlay (RefSeq, Rfam, JASPAR) — coming in v2
+
+
+
+
+
Conservation
+
+ Conservation track (phyloP) — coming in v2
+
+
+
+
+
+ )
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureList.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureList.jsx
new file mode 100644
index 0000000000..b63aa71fa4
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/FeatureList.jsx
@@ -0,0 +1,91 @@
+import React, { memo } from 'react'
+import FeatureCard from './FeatureCard'
+
+const styles = {
+ featureList: {
+ flex: 1,
+ overflowY: 'auto',
+ overflowX: 'hidden',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ paddingRight: '8px',
+ minHeight: 0,
+ },
+}
+
+function FeatureListComponent({
+ filteredFeatures,
+ displayedCardCount,
+ clickedFeatureId,
+ features,
+ cardResetKey,
+ handleCardClick,
+ loadExamples,
+ vocabLogits,
+ featureAnalysis,
+ featureListRef,
+ endOfListRef,
+ featureRefs,
+ onLoadMore,
+}) {
+ const visibleFeatures = filteredFeatures.slice(0, displayedCardCount)
+ const clickedIsVisible = clickedFeatureId != null &&
+ visibleFeatures.some(f => Number(f.feature_id) === Number(clickedFeatureId))
+ const clickedFeature = clickedFeatureId != null && !clickedIsVisible
+ ? features.find(f => Number(f.feature_id) === Number(clickedFeatureId))
+ : null
+
+ return (
+
+ {/* Only render clicked feature at top if NOT already in visible list */}
+ {clickedFeature && (
+
{ featureRefs.current[clickedFeature.feature_id] = el }}
+ feature={clickedFeature}
+ isHighlighted={true}
+ forceExpanded={true}
+ onClick={handleCardClick}
+ loadExamples={loadExamples}
+ vocabLogits={vocabLogits}
+ featureAnalysis={featureAnalysis}
+ />
+ )}
+ {visibleFeatures.map(feature => (
+ { featureRefs.current[feature.feature_id] = el }}
+ feature={feature}
+ isHighlighted={Number(clickedFeatureId) === Number(feature.feature_id)}
+ forceExpanded={Number(clickedFeatureId) === Number(feature.feature_id)}
+ onClick={handleCardClick}
+ loadExamples={loadExamples}
+ vocabLogits={vocabLogits}
+ featureAnalysis={featureAnalysis}
+ />
+ ))}
+ {/* Sentinel element for infinite scroll detection */}
+
+ {displayedCardCount < filteredFeatures.length && (
+
+ Load more — showing {visibleFeatures.length} of {filteredFeatures.length} (search/sort to find specific features)
+
+ )}
+ {filteredFeatures.length === 0 && clickedFeatureId == null && (
+
+ No features match your selection.
+
+ )}
+
+ )
+}
+
+export default memo(FeatureListComponent)
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/GenerativeSteering.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/GenerativeSteering.jsx
new file mode 100644
index 0000000000..84e31d9d7c
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/GenerativeSteering.jsx
@@ -0,0 +1,213 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { useHealth, postJSON, getJSON, cleanDNA } from './backend'
+import { BackendBanner, OrganismField, FeaturePicker, resolveFeatureId, Row, userLabel } from './SequenceInspector'
+
+// Generative steering: autoregressively generate DNA from Evo2 while ADDITIVELY
+// clamping one or more SAE features (picked by name) on the generated
+// continuation only. Real model + real SAE via backend /generate.
+
+const BASES_PER_LINE = 80
+
+export default function GenerativeSteering() {
+ const health = useHealth()
+ const organismTags = health.info?.organism_tags
+
+ const [catalog, setCatalog] = useState([])
+ const [organism, setOrganism] = useState('E. coli')
+ const [tag, setTag] = useState(null)
+ // Open as a working demo: a coding-DNA seed + a known-steerable feature (#643) clamped to 0
+ // (suppress), greedy decoding, baseline comparison on — so the first "Generate" visibly steers.
+ const [prompt, setPrompt] = useState('ATGACCATGATTACGGATTCACTGGCCGTCGTTTTACAACGTCGTGACTGGGAAAACCCTG')
+ const [rows, setRows] = useState([{ q: '643', strength: 0 }])
+ const [nTokens, setNTokens] = useState(120)
+ const [temperature, setTemperature] = useState(0)
+ const [compareBaseline, setCompareBaseline] = useState(true)
+
+ const [result, setResult] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (health.status !== 'ready') return
+ if (tag === null && organismTags) setTag(organismTags[organism] ?? '')
+ if (!catalog.length) getJSON('/features').then(setCatalog).catch(() => {})
+ }, [health.status, organismTags])
+
+ const nFeatures = health.info?.n_features
+ const clamps = rows
+ .map((r) => ({ id: resolveFeatureId(catalog, r.q), strength: Number(r.strength) }))
+ .filter((c) => c.id != null && (nFeatures == null || (c.id >= 0 && c.id < nFeatures)))
+
+ const generate = async () => {
+ setBusy(true)
+ setError(null)
+ try {
+ const body = {
+ prompt: cleanDNA(prompt),
+ organism,
+ tag: tag ?? (organismTags?.[organism] ?? ''),
+ features: clamps.map((c) => ({ feature_id: c.id, strength: c.strength })),
+ n_tokens: Number(nTokens),
+ temperature: Number(temperature),
+ compare_baseline: compareBaseline,
+ }
+ setResult(await postJSON('/generate', body))
+ } catch (e) {
+ setError(String(e.message || e))
+ setResult(null)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const canRun = health.status === 'ready' && !busy // clamps optional — [] = plain generation
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setTemperature(parseFloat(e.target.value))} style={{ width: '220px' }} />
+ {Number(temperature).toFixed(2)}
+ {temperature == 0 ? 'greedy (argmax)' : temperature < 0.8 ? 'conservative' : temperature > 1.2 ? 'diverse' : 'balanced'}
+
+
+
+ tokens
+ setNTokens(e.target.value)} style={S.num} />
+
+
+
+
+
+ setCompareBaseline(e.target.checked)} disabled={clamps.length === 0} />
+ also generate an unsteered baseline to compare
+
+ {clamps.length === 0 ? '(no clamp — single plain generation)' : 'off by default; doubles generation time'}
+
+
+
+
+ {busy ? 'Generating…' : clamps.length ? `Generate (clamp ${clamps.length} feature${clamps.length === 1 ? '' : 's'})` : 'Generate (no clamp)'}
+
+ {health.status !== 'ready' && × backend {health.status === 'offline' ? 'down' : 'loading'} }
+ {error && × {error} }
+
+
+
+ {!result ? (
+
Pick one or more features, set their clamp values, and click Generate to compare an unsteered vs feature-steered Evo2 sample.
+ ) : (
+
+ )}
+
+ )
+}
+
+function Formula() {
+ return (
+
+
Additive steering (feature clamp) — applied to the residual stream at layer 19, generated positions only:
+
h ← h + Σf ( tf − af (h) ) · df
+
+ h = base-model hidden state
+ af = relu((h − bpre )·Wenc [f] + bf ) current activation
+ df = SAE decoder column for feature f
+ tf = the activation you clamp feature f to
+
+
+ )
+}
+
+function SteerResult({ result }) {
+ const feats = result.features || []
+ const gen = result.generation
+ const base = result.baseline
+ const mean = (a) => (a && a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0)
+ return (
+
+ {feats.length ? (
+
+ Clamped {feats.length} feature{feats.length === 1 ? '' : 's'} on the generated continuation ({result.organism}).
+ {feats.map((f) => (
+
+ #{f.id} {userLabel(f.id) || f.label} mean {mean(gen.activations[f.id]).toFixed(3)}
+ {base ? ` (baseline ${mean(base.activations[f.id]).toFixed(3)})` : ''} @ clamp {f.strength}
+
+ ))}
+
+ ) : (
+
Unsteered generation · {result.organism} · {gen.sequence.length} bp.
+ )}
+
+ {base &&
}
+
+ )
+}
+
+// Plain DNA readout — black monospace text on a light panel (no activation colormap).
+function SteerBlock({ title, seq }) {
+ const bases = [...seq]
+ const lines = []
+ for (let i = 0; i < bases.length; i += BASES_PER_LINE) lines.push(i)
+ const gc = bases.length ? (bases.filter((b) => b === 'G' || b === 'C').length / bases.length) * 100 : 0
+ return (
+
+
{title} {bases.length} bp · GC {gc.toFixed(0)}%
+
+ {lines.map((start) => (
+
+ {String(start + 1).padStart(5, ' ')}
+ {bases.slice(start, start + BASES_PER_LINE).join('')}
+
+ ))}
+
+
+ )
+}
+
+const S = {
+ wrap: { padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: '14px', maxWidth: '1200px', margin: '0 auto' },
+ formula: { background: 'var(--bg-card-expanded)', border: '1px solid var(--border)', borderRadius: '8px', padding: '10px 14px' },
+ formulaTitle: { fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '6px' },
+ formulaEq: { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: '15px', color: 'var(--text-heading)', marginBottom: '6px' },
+ formulaLegend: { display: 'flex', flexWrap: 'wrap', gap: '14px', fontSize: '11px', color: 'var(--text-muted)' },
+ card: { background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '8px', padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '10px' },
+ textarea: { width: '100%', fontFamily: 'monospace', fontSize: '12px', padding: '8px', border: '1px solid var(--border-input)', borderRadius: '6px', background: 'var(--bg-input)', color: 'var(--text)', boxSizing: 'border-box', resize: 'vertical' },
+ hint: { fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px' },
+ mono: { fontFamily: 'monospace', fontSize: '12px', fontWeight: 600, minWidth: '42px' },
+ help: { fontSize: '11px', color: 'var(--text-muted)', fontStyle: 'italic' },
+ inlineField: { fontSize: '12px', color: 'var(--text-secondary)', display: 'inline-flex', alignItems: 'center' },
+ num: { width: '64px', padding: '4px 6px', fontSize: '12px', borderRadius: '4px', border: '1px solid var(--border-input)', background: 'var(--bg-input)', color: 'var(--text)' },
+ actions: { display: 'flex', alignItems: 'center', gap: '12px', marginTop: '4px' },
+ primary: { padding: '7px 16px', border: '1px solid var(--accent)', background: 'var(--accent)', color: '#000', borderRadius: '5px', cursor: 'pointer', fontSize: '12px', fontWeight: 700 },
+ down: { color: '#d9534f', fontSize: '12px' },
+ empty: { padding: '40px', textAlign: 'center', color: 'var(--text-muted)', fontStyle: 'italic', border: '1px dashed var(--border)', borderRadius: '8px' },
+ resultMeta: { fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 },
+ block: { background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '8px', padding: '10px 14px' },
+ blockHead: { display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '8px' },
+ blockTitle: { fontSize: '13px', fontWeight: 600, color: 'var(--text-heading)' },
+ blockMeta: { marginLeft: 'auto', fontFamily: 'monospace', fontSize: '11px', color: 'var(--text-secondary)' },
+ trackLabel: { fontSize: '11px', color: 'var(--text-tertiary)', fontFamily: 'monospace', marginBottom: '2px' },
+ seqReadout: { background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: '6px', padding: '8px 10px', fontFamily: 'ui-monospace, Menlo, monospace', fontSize: '13px', lineHeight: 1.7 },
+ seqLine: { display: 'flex', gap: '8px', alignItems: 'baseline' },
+ seqIdx: { color: '#999', fontSize: '11px', minWidth: '40px', textAlign: 'right', whiteSpace: 'pre' },
+ seqText: { color: '#111', letterSpacing: '1px', wordBreak: 'break-all' },
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/Histogram.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/Histogram.jsx
new file mode 100644
index 0000000000..553330862d
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/Histogram.jsx
@@ -0,0 +1,85 @@
+import React, { useEffect, useRef } from 'react'
+import * as vg from '@uwdata/vgplot'
+
+const FILL_COLOR = "#76b900"
+
+function injectAxisLine(plot, marginLeft, marginRight, marginBottom, height, axisColor) {
+ const svg = plot.tagName === 'svg' ? plot : plot.querySelector?.('svg')
+ if (!svg) return
+ // Remove any previously injected line
+ svg.querySelectorAll('.x-axis-line').forEach(el => el.remove())
+ const svgWidth = svg.getAttribute('width') || svg.clientWidth
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
+ line.classList.add('x-axis-line')
+ line.setAttribute('x1', marginLeft)
+ line.setAttribute('x2', svgWidth - marginRight)
+ line.setAttribute('y1', height - marginBottom)
+ line.setAttribute('y2', height - marginBottom)
+ line.setAttribute('stroke', axisColor)
+ line.setAttribute('stroke-width', '1')
+ svg.appendChild(line)
+}
+
+export default function Histogram({ brush, column, label }) {
+ const containerRef = useRef(null)
+
+ useEffect(() => {
+ if (!containerRef.current || !brush) return
+
+ // Clear previous content
+ containerRef.current.innerHTML = ''
+
+ const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--density-bar-bg').trim() || '#e0e0e0'
+ const axisColor = getComputedStyle(document.documentElement).getPropertyValue('--text-tertiary').trim() || '#888'
+ const width = containerRef.current.clientWidth - 20
+ const height = 50
+ const marginLeft = 45
+ const marginBottom = 20
+ const marginRight = 10
+ const marginTop = 5
+
+ const plot = vg.plot(
+ // Background histogram: full data (no filterBy)
+ vg.rectY(
+ vg.from("features"),
+ { x: vg.bin(column), y: vg.count(), fill: bgColor, inset: 1 }
+ ),
+ // Foreground histogram: filtered data
+ vg.rectY(
+ vg.from("features", { filterBy: brush }),
+ { x: vg.bin(column), y: vg.count(), fill: FILL_COLOR, inset: 1 }
+ ),
+ vg.intervalX({ as: brush }),
+ vg.xLabel(null),
+ vg.yLabel(null),
+ vg.width(width),
+ vg.height(height),
+ vg.marginLeft(marginLeft),
+ vg.marginBottom(marginBottom),
+ vg.marginTop(marginTop),
+ vg.marginRight(marginRight)
+ )
+
+ containerRef.current.appendChild(plot)
+
+ // Inject axis line into the SVG directly (immune to container resize)
+ // Use a short delay to ensure the SVG is rendered
+ const timer = setTimeout(() => {
+ injectAxisLine(plot, marginLeft, marginRight, marginBottom, height, axisColor)
+ }, 50)
+
+ return () => {
+ clearTimeout(timer)
+ if (containerRef.current) {
+ containerRef.current.innerHTML = ''
+ }
+ }
+ }, [brush, column, label])
+
+ return (
+
+ )
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/InfoButton.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/InfoButton.jsx
new file mode 100644
index 0000000000..40184d0ef6
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/InfoButton.jsx
@@ -0,0 +1,78 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+
+export default function InfoButton({ text }) {
+ const [open, setOpen] = useState(false)
+ const wrapperRef = useRef(null)
+ const buttonRef = useRef(null)
+ const [pos, setPos] = useState(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handleClick = (e) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [open])
+
+ useEffect(() => {
+ if (open && buttonRef.current) {
+ const rect = buttonRef.current.getBoundingClientRect()
+ setPos({
+ top: rect.top - 8,
+ left: rect.left + rect.width / 2,
+ })
+ }
+ }, [open])
+
+ return (
+
+ setOpen(o => !o)}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '15px',
+ height: '15px',
+ borderRadius: '50%',
+ border: '1px solid var(--border-input)',
+ fontSize: '10px',
+ fontWeight: '600',
+ color: 'var(--text-tertiary)',
+ cursor: 'pointer',
+ userSelect: 'none',
+ lineHeight: 1,
+ }}
+ >
+ i
+
+ {open && pos && createPortal(
+
+ {text}
+
,
+ document.body
+ )}
+
+ )
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/RegionDetailModal.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/RegionDetailModal.jsx
new file mode 100644
index 0000000000..d72dc41358
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/RegionDetailModal.jsx
@@ -0,0 +1,157 @@
+import React, { useEffect } from 'react'
+import ReactDOM from 'react-dom'
+import SequenceView from './SequenceView'
+import { getRegionLabel } from './utils'
+
+const styles = {
+ backdrop: {
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(0,0,0,0.5)',
+ zIndex: 9999,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ modal: {
+ background: '#fff',
+ borderRadius: '12px',
+ width: '90vw',
+ maxWidth: '1000px',
+ maxHeight: '85vh',
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
+ position: 'relative',
+ },
+ closeBtn: {
+ position: 'absolute',
+ top: '12px',
+ right: '12px',
+ zIndex: 10,
+ background: 'rgba(255,255,255,0.9)',
+ border: '1px solid #ddd',
+ borderRadius: '50%',
+ width: '32px',
+ height: '32px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ fontSize: '16px',
+ color: '#555',
+ },
+ body: {
+ flex: 1,
+ padding: '32px',
+ overflowY: 'auto',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '20px',
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ flexWrap: 'wrap',
+ },
+ regionLabel: {
+ fontSize: '18px',
+ fontWeight: '700',
+ fontFamily: 'monospace',
+ color: '#222',
+ },
+ statsRow: {
+ display: 'flex',
+ gap: '20px',
+ flexWrap: 'wrap',
+ },
+ statBox: {
+ padding: '10px 14px',
+ background: '#f9fafb',
+ borderRadius: '8px',
+ border: '1px solid #eee',
+ },
+ statLabel: {
+ fontSize: '10px',
+ color: '#888',
+ textTransform: 'uppercase',
+ marginBottom: '2px',
+ },
+ statValue: {
+ fontSize: '14px',
+ fontWeight: '600',
+ fontFamily: 'monospace',
+ color: '#333',
+ },
+ sectionLabel: {
+ fontSize: '11px',
+ color: '#888',
+ textTransform: 'uppercase',
+ fontWeight: '500',
+ },
+ sequenceBox: {
+ background: '#fafafa',
+ border: '1px solid #eee',
+ borderRadius: '8px',
+ padding: '12px',
+ maxHeight: '300px',
+ overflowY: 'auto',
+ },
+}
+
+export default function RegionDetailModal({ region, onClose }) {
+ useEffect(() => {
+ const handleKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', handleKey)
+ return () => document.removeEventListener('keydown', handleKey)
+ }, [onClose])
+
+ const label = getRegionLabel(region)
+ const sequenceLength = (region.sequence || '').length
+
+ const modal = (
+
+
e.stopPropagation()}>
+
x
+
+
+
+ {label}
+
+
+
+
+
Max Activation
+
{(region.max_activation || 0).toFixed(4)}
+
+
+
Sequence Length
+
{sequenceLength} bp
+
+ {region.best_annotation && (
+
+
Annotation
+
{region.best_annotation}
+
+ )}
+
+
+
+
Sequence (activation highlighted)
+
+
+
+
+
+
+
+ )
+
+ return ReactDOM.createPortal(modal, document.body)
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceInspector.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceInspector.jsx
new file mode 100644
index 0000000000..1191e57605
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceInspector.jsx
@@ -0,0 +1,331 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { useHealth, postJSON, getJSON, activationColor, legendGradient, cleanDNA } from './backend'
+
+// Sequence inspector: paste DNA -> per-base SAE activations from the live backend
+// (/annotate). DNA-only display; absolute 0->max coloring (clear -> green).
+// Feature selection mirrors the steering tab: by-name, multi-feature.
+
+const DEFAULT_SEQ = 'ATGGCTGAAAAGCTGGAAGCGGCAATTGAGCAGGCTGCAGTGGCAAATCAAGCG'
+const BASES_PER_LINE = 80
+
+export default function SequenceInspector() {
+ const health = useHealth()
+ const organismTags = health.info?.organism_tags
+ const [catalog, setCatalog] = useState([])
+
+ const [sequence, setSequence] = useState(DEFAULT_SEQ)
+ const [organism, setOrganism] = useState('Human')
+ const [tag, setTag] = useState(null) // editable phylo tag; null until prefilled from health
+ const [mode, setMode] = useState('topk') // 'topk' | 'pick'
+ const [k, setK] = useState(8)
+ const [pickRows, setPickRows] = useState([{ q: '' }])
+
+ const [result, setResult] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (health.status !== 'ready') return
+ if (tag === null && organismTags) setTag(organismTags[organism] ?? '')
+ if (!catalog.length) getJSON('/features').then(setCatalog).catch(() => {})
+ }, [health.status, organismTags])
+
+ const cleaned = useMemo(() => cleanDNA(sequence), [sequence])
+
+ const annotate = async () => {
+ setBusy(true)
+ setError(null)
+ try {
+ const body = { sequence: cleaned, organism, tag: tag ?? (organismTags?.[organism] ?? ''), mode, k: Number(k) }
+ if (mode === 'pick') {
+ body.feature_ids = pickRows.map((r) => resolveFeatureId(catalog, r.q)).filter((x) => x != null)
+ if (!body.feature_ids.length) throw new Error('pick at least one feature by name or #id')
+ }
+ setResult(await postJSON('/annotate', body))
+ } catch (e) {
+ setError(String(e.message || e))
+ setResult(null)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ setMode('topk')}>Top-K by max activation
+ setMode('pick')}>Pick features
+
+
+ {mode === 'topk' ? (
+
+ K =
+ setK(e.target.value)} style={S.num} />
+
+ the K features that fire hardest on this sequence
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {busy ? 'Annotating…' : 'Annotate sequence'}
+
+ {health.status !== 'ready' && × backend {health.status === 'offline' ? 'down' : 'loading'} }
+ {error && × {error} }
+
+
+
+ {!result ? (
+
Paste a DNA sequence above and click Annotate sequence to see per-base SAE activations.
+ ) : (
+
+ )}
+
+ )
+}
+
+function Result({ result }) {
+ const tagLen = result.tag_len || 0 // DNA-only: always strip the phylo prefix
+ const bases = result.bases.slice(tagLen)
+ return (
+
+
+ {result.features.length} feature{result.features.length === 1 ? '' : 's'} · {bases.length} bases ·
+ layer {result.layer} · organism {result.organism}
+ {result.tag_len > 0 ? ` · phylo tag (${result.tag_len} bp) stripped` : ''}
+
+
+ {result.features.map((f) =>
)}
+
+ )
+}
+
+function FeatureHeatmap({ feature, tagLen, bases }) {
+ const acts = feature.activations.slice(tagLen)
+ const max = acts.length ? Math.max(...acts) : 0
+ const lines = []
+ for (let i = 0; i < bases.length; i += BASES_PER_LINE) lines.push(i)
+ return (
+
+
+ {feature.label || `feature ${feature.feature_id}`}
+ #{feature.feature_id}
+ max {feature.max_activation?.toFixed(2)}
+
+
+
+ )
+}
+
+export function Heat({ bases, acts, max, lines }) {
+ // Per-cell letter color so text stays legible across the Viridis ramp: dark
+ // text on the light (high-activation) end, light text on the dark/empty end.
+ const dark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
+ const empty = dark ? '#dcdcdc' : '#333'
+ return (
+
+ {lines.map((start) => (
+
+ {String(start + 1).padStart(5, ' ')}
+
+ {bases.slice(start, start + BASES_PER_LINE).map((b, j) => {
+ const idx = start + j
+ const a = acts[idx] ?? 0
+ const t = max > 0 ? Math.min(1, a / max) : 0
+ const letter = a <= 0 || t < 0.02 ? empty : t > 0.45 ? '#0a0a0a' : '#f4f4f4'
+ return (
+ {b}
+ )
+ })}
+
+
+ ))}
+
+ )
+}
+
+// Viridis colorbar legend.
+export function Legend({ label = 'SAE activation', note }) {
+ return (
+
+ {label}
+ low
+
+ high
+ {note && {note} }
+
+ )
+}
+
+// Resolve a picker row's text ("#123 …" or an exact label) to a feature id.
+export function resolveFeatureId(catalog, q) {
+ // A bare numeric id resolves to ANY feature (the catalog only lists the labeled subset, but
+ // every feature 0..n_features-1 is clampable). Otherwise match an exact label from the catalog.
+ const m = String(q).match(/#?(\d+)/)
+ if (m) {
+ const id = Number(m[1])
+ if (Number.isInteger(id) && id >= 0) return id
+ }
+ const lab = String(q).trim()
+ const hit = catalog.find((f) => f.label === lab)
+ return hit ? hit.id : null
+}
+
+// In-UI feature renames live in localStorage (featureTitle_). Overlay them so a name set
+// in the atlas also shows in the steering/inspector pickers (cross-tab carry-over, per browser).
+export function userLabel(id) {
+ try {
+ return localStorage.getItem(`featureTitle_${id}`) || null
+ } catch {
+ return null
+ }
+}
+
+// Shared by-name feature picker (used by both tabs). withStrength adds a clamp value.
+// CLAMP_MAX mirrors the backend MAX_CLAMP_STRENGTH guard — the UI can't request a target
+// the engine would reject/cap. Steering clamps an absolute SAE-code value (0 = suppress;
+// above the feature's natural peak = amplify), so the slider spans the real activation scale.
+const CLAMP_MAX = 300
+
+export function FeaturePicker({ catalog, rows, setRows, withStrength, nFeatures }) {
+ const byId = useMemo(() => Object.fromEntries(catalog.map((f) => [f.id, f])), [catalog])
+ const setRow = (i, patch) => setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)))
+ const add = () => setRows((rs) => [...rs, withStrength ? { q: '', strength: 0 } : { q: '' }])
+ const del = (i) => setRows((rs) => (rs.length > 1 ? rs.filter((_, j) => j !== i) : rs))
+ return (
+
+ )
+}
+
+// Organism preset dropdown + an always-editable phylo tag (prefilled from the preset).
+export function OrganismField({ organismTags, organism, setOrganism, tag, setTag }) {
+ const names = Object.keys(organismTags || { 'None (raw DNA)': '' })
+ return (
+
+ { const v = e.target.value; setOrganism(v); setTag(organismTags?.[v] ?? '') }} style={S.select}>
+ {names.map((o) => {o} )}
+
+ setTag(e.target.value)} style={S.customTag}
+ title="Phylogenetic tag prepended to the sequence — edit freely to use a custom lineage"
+ placeholder="|d__…;s__…| phylo tag (editable)" />
+
+ )
+}
+
+export function BackendBanner({ health }) {
+ if (health.status === 'ready') {
+ const i = health.info || {}
+ return ● Backend live — Evo2 layer {i.layer}, {i.n_features} SAE features ({i.n_labels} labeled) on {i.device}.
+ }
+ if (health.status === 'loading') return ◐ Backend loading model + SAE… (~1 min at startup)
+ return Backend offline. Start the backend: launch_inference.sh serve on port 8001 (7B, layer 26).
+}
+
+export function Row({ label, children }) {
+ return
+}
+export function Toggle({ active, onClick, children }) {
+ return {children}
+}
+
+export const S = {
+ wrap: { padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: '14px', maxWidth: '1200px', margin: '0 auto' },
+ banner: { padding: '8px 14px', borderRadius: '6px', fontSize: '12px' },
+ bannerOk: { background: 'rgba(118,185,0,0.12)', border: '1px solid var(--accent)', color: 'var(--accent)' },
+ bannerWarn: { background: 'rgba(255,193,7,0.10)', border: '1px solid #b8860b', color: '#d9a400' },
+ card: { background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '8px', padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '10px' },
+ row: { display: 'flex', alignItems: 'flex-start', gap: '14px' },
+ rowLabel: { width: '120px', flexShrink: 0, fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)', paddingTop: '6px' },
+ rowBody: { flex: 1, display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
+ textarea: { width: '100%', fontFamily: 'monospace', fontSize: '12px', padding: '8px', border: '1px solid var(--border-input)', borderRadius: '6px', background: 'var(--bg-input)', color: 'var(--text)', boxSizing: 'border-box', resize: 'vertical' },
+ hint: { fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px' },
+ select: { padding: '5px 8px', fontSize: '12px', borderRadius: '4px', border: '1px solid var(--border-input)', background: 'var(--bg-input)', color: 'var(--text)', minWidth: '170px' },
+ customTag: { flex: 1, minWidth: '320px', fontFamily: 'monospace', fontSize: '11px', padding: '5px 8px', borderRadius: '4px', border: '1px solid var(--border-input)', background: 'var(--bg-input)', color: 'var(--text)' },
+ inlineField: { fontSize: '12px', color: 'var(--text-secondary)', display: 'inline-flex', alignItems: 'center' },
+ help: { fontSize: '11px', color: 'var(--text-muted)', fontStyle: 'italic' },
+ num: { width: '64px', padding: '4px 6px', fontSize: '12px', borderRadius: '4px', border: '1px solid var(--border-input)', background: 'var(--bg-input)', color: 'var(--text)' },
+ actions: { display: 'flex', alignItems: 'center', gap: '12px', marginTop: '4px' },
+ primary: { padding: '7px 16px', border: '1px solid var(--accent)', background: 'var(--accent)', color: '#000', borderRadius: '5px', cursor: 'pointer', fontSize: '12px', fontWeight: 700 },
+ down: { color: '#d9534f', fontSize: '12px' },
+ empty: { padding: '40px', textAlign: 'center', color: 'var(--text-muted)', fontStyle: 'italic', border: '1px dashed var(--border)', borderRadius: '8px' },
+ resultMeta: { fontSize: '11px', color: 'var(--text-muted)' },
+ toggleOn: { padding: '5px 12px', border: '1px solid var(--accent)', background: 'var(--bg-card-expanded)', color: 'var(--accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', fontWeight: 600 },
+ toggleOff: { padding: '5px 12px', border: '1px solid var(--border-input)', background: 'var(--bg-input)', color: 'var(--text-secondary)', borderRadius: '4px', cursor: 'pointer', fontSize: '11px' },
+ pickRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
+ featInput: { width: '230px', fontSize: '12px', padding: '5px 8px', borderRadius: '4px', border: '1px solid var(--border-input)', background: 'var(--bg-input)', color: 'var(--text)' },
+ resolved: { fontSize: '11px', color: 'var(--text-muted)', minWidth: '210px', fontFamily: 'monospace' },
+ strengthWrap: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--text-secondary)' },
+ del: { border: '1px solid var(--border-input)', background: 'transparent', color: 'var(--text-muted)', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', padding: '3px 7px' },
+ addBtn: { border: '1px dashed var(--border-input)', background: 'transparent', color: 'var(--text-secondary)', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', padding: '4px 10px' },
+ featCard: { background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '8px', padding: '10px 14px' },
+ featHead: { display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '8px' },
+ featLabel: { fontSize: '13px', fontWeight: 600, color: 'var(--text-heading)' },
+ featId: { fontFamily: 'monospace', fontSize: '11px', color: 'var(--text-tertiary)' },
+ featMax: { marginLeft: 'auto', fontFamily: 'monospace', fontSize: '11px', color: 'var(--text-secondary)' },
+ heatBody: { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: '13px', lineHeight: 1.7 },
+ heatLine: { display: 'flex', gap: '8px', alignItems: 'baseline' },
+ heatIdx: { color: 'var(--text-muted)', fontSize: '11px', minWidth: '40px', textAlign: 'right', whiteSpace: 'pre' },
+ heatSeq: { letterSpacing: '1px', wordBreak: 'break-all' },
+ legend: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px', color: 'var(--text-muted)' },
+ legendLabel: { fontWeight: 600, color: 'var(--text-secondary)' },
+ legendBar: { width: '160px', height: '10px', borderRadius: '3px', border: '1px solid var(--border)' },
+ legendNote: { fontStyle: 'italic' },
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceUMAPView.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceUMAPView.jsx
new file mode 100644
index 0000000000..a64aa5d979
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceUMAPView.jsx
@@ -0,0 +1,424 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: LicenseRef-Apache2
+//
+// Sequence UMAP: embed a set of sequences live (Evo2 -> layer-L -> SAE, mean-pooled
+// per sequence) via /api/gene_embed, UMAP them client-side, then recolor or
+// *reorganize* the layout by a chosen SAE feature. Adapted from the dashboard
+// mockup's GeneUMAPView (umap-js recolor + reorganize core), with two input modes:
+// - Preset: pick from a bundled labeled library (/sequence_library.json)
+// - Custom: paste FASTA (>name|label) or TSV (namelabelseq)
+// Feature ids map to the live SAE's labels (same SAE as the feature atlas).
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import { UMAP } from 'umap-js'
+
+const BACKEND = '/api'
+const LAMBDA = 4.0 // feature amplification for "reorganize"
+const ANIM_MS = 700
+const CAT_COLORS = ['#76b900', '#3b82f6', '#ef4444', '#f59e0b', '#a855f7', '#14b8a6', '#ec4899', '#84cc16', '#06b6d4', '#f97316']
+const NOISE = '#555'
+// Perceptual "turbo-lite" ramp: blue -> cyan -> green -> amber -> red. Bright at
+// both ends so it reads on dark AND light themes (unlike viridis' near-black low end).
+const RAMP = [[59, 76, 192], [34, 211, 238], [118, 185, 0], [251, 191, 36], [239, 68, 68]]
+
+function hashHue(s) {
+ let h = 0
+ for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0
+ return ((h % 360) + 360) % 360
+}
+function colorForLabel(label) {
+ if (label == null) return NOISE
+ // Deterministic per-label color (hash of the label string) so a given label keeps the
+ // SAME color across embeds / subsets / sessions — independent of data order.
+ return `hsl(${hashHue(String(label))}, 62%, 55%)`
+}
+function ramp(t) {
+ const x = Math.max(0, Math.min(1, t)) * (RAMP.length - 1)
+ const i = Math.floor(x), f = x - i, a = RAMP[i], b = RAMP[Math.min(RAMP.length - 1, i + 1)]
+ return `rgb(${Math.round(a[0] + (b[0] - a[0]) * f)},${Math.round(a[1] + (b[1] - a[1]) * f)},${Math.round(a[2] + (b[2] - a[2]) * f)})`
+}
+
+function parseCustom(text) {
+ const items = []
+ if (text.trim().startsWith('>')) {
+ // FASTA: >name|label \n SEQ...
+ let name = null, label = null, seq = []
+ const flush = () => { if (name) items.push({ symbol: name, label, sequence: seq.join('') }) }
+ for (const line of text.split('\n')) {
+ if (line.startsWith('>')) {
+ flush(); seq = []
+ const h = line.slice(1).trim().split('|')
+ name = h[0]?.trim() || `seq${items.length}`; label = h[1]?.trim() || null
+ } else seq.push(line.trim())
+ }
+ flush()
+ } else {
+ // TSV: name label sequence
+ for (const line of text.split('\n')) {
+ const p = line.split('\t')
+ if (p.length >= 3 && p[2].trim()) items.push({ symbol: p[0].trim(), label: p[1].trim() || null, sequence: p[2].trim() })
+ }
+ }
+ return items.filter((s) => (s.sequence || '').replace(/[^ACGTNacgtn]/g, '').length >= 3)
+}
+
+export default function SequenceUMAPView({ height = 600 }) {
+ const [mode, setMode] = useState('preset')
+ const [library, setLibrary] = useState([])
+ const [picked, setPicked] = useState(new Set())
+ const [customText, setCustomText] = useState('')
+ const [organism, setOrganism] = useState('None (raw DNA)')
+ const [organisms, setOrganisms] = useState(['None (raw DNA)'])
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ const [bundle, setBundle] = useState(null) // {G(active), Gmean, Gmax, nf, ng, meta, items:[{name,label,species,x,y}], stats}
+ const [pooling, setPooling] = useState('mean') // 'mean' | 'max' — toggled client-side, no re-forward
+ const [selectedFeature, setSelectedFeature] = useState(null)
+ const [editingFeature, setEditingFeature] = useState(null) // feature_id whose label is being edited
+ const [editText, setEditText] = useState('')
+ const [reorgCoords, setReorgCoords] = useState(null)
+ const [anim, setAnim] = useState(1)
+ const [hover, setHover] = useState(null)
+ const [backendReady, setBackendReady] = useState(null) // null=unknown, false=loading, true=ready
+ const canvasRef = useRef(null)
+ const plotRef = useRef(null)
+ const [size, setSize] = useState({ w: 720, h: 480 })
+
+ useEffect(() => {
+ fetch('/sequence_library.json').then((r) => (r.ok ? r.json() : [])).then(setLibrary).catch(() => setLibrary([]))
+ let stop = false, timer
+ const poll = () => {
+ fetch(`${BACKEND}/health`).then((r) => r.json()).then((h) => {
+ if (stop) return
+ setOrganisms(h.organisms || ['None (raw DNA)'])
+ setBackendReady(!!h.ready)
+ if (!h.ready) timer = setTimeout(poll, 3000) // keep polling until model+SAE finish loading
+ }).catch(() => { if (!stop) timer = setTimeout(poll, 3000) })
+ }
+ poll()
+ return () => { stop = true; clearTimeout(timer) }
+ }, [])
+
+ // Keep the canvas sized to its container (responsive to window/panel resize).
+ useEffect(() => {
+ const el = plotRef.current
+ if (!el) return
+ const ro = new ResizeObserver(() => {
+ const r = el.getBoundingClientRect()
+ setSize({ w: Math.max(80, Math.floor(r.width)), h: Math.max(80, Math.floor(r.height)) })
+ })
+ ro.observe(el)
+ return () => ro.disconnect()
+ }, [bundle])
+
+ async function embed() {
+ const genes = mode === 'preset' ? library.filter((_, i) => picked.has(i)) : parseCustom(customText)
+ if (!genes.length) { setError('Pick or paste at least one sequence (>=3 nt).'); return }
+ setBusy(true); setError(null); setBundle(null); setSelectedFeature(null); setReorgCoords(null); setPooling('mean')
+ try {
+ const resp = await fetch(`${BACKEND}/gene_embed`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ genes, organism }),
+ })
+ if (!resp.ok) throw new Error(`${resp.status}: ${(await resp.text()).slice(0, 200)}`)
+ const r = await resp.json()
+ const dec = (b64) => new Float32Array(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer)
+ const Gmean = dec(r.G_b64)
+ const Gmax = r.Gmax_b64 ? dec(r.Gmax_b64) : Gmean // back-compat if server only sends mean
+ const nf = r.n_features, ng = r.n_genes
+ const items = buildItems(Gmean, nf, ng, r.genes) // default pooling = mean
+ setBundle({ G: Gmean, Gmean, Gmax, nf, ng, meta: r.genes, items, stats: r.feature_stats, saeId: r.sae_id })
+ } catch (e) { setError(String(e.message || e)) } finally { setBusy(false) }
+ }
+
+ // Switch mean<->max pooling instantly (both came from the same forward); just
+ // re-lay-out client-side from the stored matrix — no re-running the model.
+ async function setPool(p) {
+ if (!bundle || p === pooling) return
+ setBusy(true); setError(null); setReorgCoords(null)
+ try {
+ const G = p === 'max' ? bundle.Gmax : bundle.Gmean
+ await new Promise((r) => setTimeout(r, 16))
+ const items = buildItems(G, bundle.nf, bundle.ng, bundle.meta)
+ setBundle({ ...bundle, G, items })
+ setPooling(p)
+ } catch (e) { setError('re-pool failed: ' + (e.message || e)) } finally { setBusy(false) }
+ }
+
+ const colorInfo = useMemo(() => {
+ if (!bundle) return null
+ const { G, nf, items } = bundle
+ if (selectedFeature == null) {
+ const cats = [...new Set(items.map((it) => it.label))]
+ return { mode: 'label', colors: items.map((it) => colorForLabel(it.label)), firing: null, cats }
+ }
+ // feature mode: split firing vs silent; scale by the 95th pct of FIRING values
+ // (SAE activations are heavy-tailed, so a plain /max washes everything to one end)
+ // and sqrt-spread so low-but-nonzero points stay distinguishable.
+ const vals = items.map((_, i) => G[i * nf + selectedFeature])
+ const firing = vals.map((v) => v > 0)
+ const pos = vals.filter((v) => v > 0).sort((a, b) => a - b)
+ const vmax = Math.max(...vals, 1e-9)
+ const p95 = pos.length ? pos[Math.min(pos.length - 1, Math.floor(0.95 * pos.length))] : vmax
+ const colors = vals.map((v) => (v > 0 ? ramp(Math.sqrt(Math.min(1, v / (p95 || vmax)))) : null))
+ return { mode: 'feature', colors, firing, vals, vmin: pos[0] ?? 0, vmax, nFiring: pos.length }
+ }, [bundle, selectedFeature])
+
+ const coords = useMemo(() => {
+ if (!bundle) return null
+ const base = bundle.items.map((it) => [it.x, it.y])
+ if (!reorgCoords) return base
+ return base.map((b, i) => [b[0] + (reorgCoords[i][0] - b[0]) * anim, b[1] + (reorgCoords[i][1] - b[1]) * anim])
+ }, [bundle, reorgCoords, anim])
+
+ useEffect(() => {
+ if (!bundle || !coords || !colorInfo) return
+ const cv = canvasRef.current; if (!cv) return
+ const dpr = window.devicePixelRatio || 1
+ const w = size.w, h = size.h
+ cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr)
+ const ctx = cv.getContext('2d')
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // draw in CSS pixels, render at device resolution
+ ctx.clearRect(0, 0, w, h)
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity
+ for (const [x, y] of coords) { mnx = Math.min(mnx, x); mxx = Math.max(mxx, x); mny = Math.min(mny, y); mxy = Math.max(mxy, y) }
+ const pad = 30, s = Math.min((w - 2 * pad) / Math.max(1e-9, mxx - mnx), (h - 2 * pad) / Math.max(1e-9, mxy - mny))
+ const X = (i) => pad + (coords[i][0] - mnx) * s, Y = (i) => pad + (coords[i][1] - mny) * s
+ // draw silent (non-firing) points first, hottest last, so peaks sit on top
+ const order = [...coords.keys()]
+ if (colorInfo.mode === 'feature') order.sort((a, b) => colorInfo.vals[a] - colorInfo.vals[b])
+ for (const i of order) {
+ const silent = colorInfo.mode === 'feature' && !colorInfo.firing[i]
+ ctx.globalAlpha = hover != null && i !== hover ? 0.3 : 1
+ ctx.beginPath(); ctx.arc(X(i), Y(i), hover === i ? 7 : 4.5, 0, 6.2832)
+ if (silent) { ctx.strokeStyle = NOISE; ctx.lineWidth = 1.2; ctx.stroke() }
+ else { ctx.fillStyle = colorInfo.colors[i]; ctx.fill() }
+ }
+ ctx.globalAlpha = 1
+ }, [bundle, coords, colorInfo, hover, size])
+
+ useEffect(() => {
+ if (!reorgCoords) { setAnim(1); return }
+ let raf; const t0 = performance.now()
+ const tick = (now) => {
+ const t = Math.min(1, (now - t0) / ANIM_MS); setAnim(t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
+ if (t < 1) raf = requestAnimationFrame(tick)
+ }
+ setAnim(0); raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf)
+ }, [reorgCoords])
+
+ async function reorganize() {
+ if (!bundle || selectedFeature == null) return
+ setBusy(true)
+ try {
+ const { G, nf, ng } = bundle
+ // Amplifying one column among 65k by a small factor is invisible. Instead
+ // z-score the selected feature across sequences and scale it to ~the typical
+ // row norm so it DOMINATES the layout -> sequences pull together by that feature.
+ let normSum = 0
+ const col = new Float64Array(ng)
+ for (let i = 0; i < ng; i++) {
+ let nrm = 0; const b = i * nf
+ for (let f = 0; f < nf; f++) nrm += G[b + f] * G[b + f]
+ normSum += Math.sqrt(nrm); col[i] = G[b + selectedFeature]
+ }
+ const meanNorm = normSum / ng || 1
+ const cmean = col.reduce((a, b) => a + b, 0) / ng
+ let cv = 0; for (let i = 0; i < ng; i++) cv += (col[i] - cmean) ** 2
+ const cstd = Math.sqrt(cv / ng) || 1
+ const W = LAMBDA * meanNorm // feature dim becomes ~LAMBDA x the whole-vector scale
+ const vecs = Array.from({ length: ng }, (_, i) => {
+ const row = Array.from(G.subarray(i * nf, (i + 1) * nf))
+ row[selectedFeature] = ((col[i] - cmean) / cstd) * W
+ return row
+ })
+ await new Promise((r) => setTimeout(r, 16))
+ const coords2 = new UMAP({ nComponents: 2, nNeighbors: Math.min(15, Math.max(2, ng - 1)), minDist: 0.1 }).fit(vecs)
+ setReorgCoords(coords2)
+ } catch (e) {
+ console.error('reorganize failed:', e)
+ setError('reorganize failed: ' + (e.message || e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ // Biologist-contributed label: persist via the backend (scoped to this SAE), reflect locally.
+ async function saveLabel(fid, text) {
+ const label = (text || '').trim()
+ setEditingFeature(null)
+ try {
+ const resp = await fetch(`${BACKEND}/label`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ feature_id: fid, label, sae_id: bundle?.saeId }),
+ })
+ if (!resp.ok) throw new Error(`${resp.status}: ${(await resp.text()).slice(0, 160)}`)
+ const r = await resp.json()
+ setBundle((b) => (b ? { ...b, stats: b.stats.map((s) => (s.feature_id === fid ? { ...s, label: r.label } : s)) } : b))
+ } catch (e) { setError('label save failed: ' + (e.message || e)) }
+ }
+
+ const onMove = (e) => {
+ if (!bundle || !coords) return
+ const cv = canvasRef.current, rect = cv.getBoundingClientRect()
+ const w = rect.width, h = rect.height // CSS pixels — matches the DPR-scaled draw
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity
+ for (const [x, y] of coords) { mnx = Math.min(mnx, x); mxx = Math.max(mxx, x); mny = Math.min(mny, y); mxy = Math.max(mxy, y) }
+ const pad = 30, s = Math.min((w - 2 * pad) / Math.max(1e-9, mxx - mnx), (h - 2 * pad) / Math.max(1e-9, mxy - mny))
+ let best = null, bd = 144
+ for (let i = 0; i < coords.length; i++) {
+ const px = pad + (coords[i][0] - mnx) * s, py = pad + (coords[i][1] - mny) * s
+ const d = (px - mx) ** 2 + (py - my) ** 2; if (d < bd) { bd = d; best = i }
+ }
+ setHover(best)
+ }
+
+ return (
+
+
Sequence UMAP — embed sequences live, color or reorganize by an SAE feature
+ {!bundle && (
+
+ )}
+
+ {bundle && (
+
+
+
+ {bundle.ng} sequences × {bundle.nf} features · color: {selectedFeature == null ? 'label' : `feature #${selectedFeature}`}
+
+ {['mean', 'max'].map((p) => (
+ setPool(p)} disabled={busy}
+ style={{ padding: '5px 10px', border: 'none', cursor: 'pointer', fontSize: 12, background: pooling === p ? '#76b900' : 'transparent', color: pooling === p ? '#000' : 'var(--text)', fontWeight: pooling === p ? 600 : 400 }}>
+ {p}-pool
+
+ ))}
+
+
+ {busy ? 'Working…' : 'Reorganize by feature'}
+
+ {reorgCoords && setReorgCoords(null)} style={tabStyle(false)}>Reset layout }
+ { setBundle(null); setReorgCoords(null) }} style={tabStyle(false)}>New set
+
+
+
setHover(null)}
+ style={{ border: '1px solid var(--border,#333)', borderRadius: 8, width: '100%', height: '100%', display: 'block' }} />
+ {hover != null && (
+
+ {bundle.items[hover].name} label: {bundle.items[hover].label ?? '—'}
+ {selectedFeature != null && <>feat #{selectedFeature}: {bundle.G[hover * bundle.nf + selectedFeature].toFixed(3)}>}
+
+ )}
+
+ {colorInfo && (
+
+ {colorInfo.mode === 'label'
+ ? colorInfo.cats.map((c) => (
+
+ {c ?? '—'}
+
+ ))
+ : (
+ <>
+ {colorInfo.nFiring}/{bundle.ng} firing
+
+ silent
+
+ low {(colorInfo.vmin || 0).toFixed(2)}
+
+ {colorInfo.vmax.toFixed(2)} act.
+ >
+ )}
+
+ )}
+
+
+
{bundle.stats.length} active SAE features
+
click to color the map · then “Reorganize” · n = sequences it fires in
+ {bundle.saeId && (
+
+ SAE: {bundle.saeId}
+
+ )}
+ {bundle.stats.slice(0, 200).map((s) => (
+
editingFeature !== s.feature_id && setSelectedFeature(s.feature_id === selectedFeature ? null : s.feature_id)}
+ style={{ cursor: 'pointer', padding: '2px 4px', borderRadius: 4, display: 'flex', gap: 6, alignItems: 'center', background: s.feature_id === selectedFeature ? 'rgba(118,185,0,0.25)' : 'transparent' }}>
+ #{s.feature_id}
+ {editingFeature === s.feature_id ? (
+ e.stopPropagation()}
+ onChange={(e) => setEditText(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') saveLabel(s.feature_id, editText); else if (e.key === 'Escape') setEditingFeature(null) }}
+ onBlur={() => saveLabel(s.feature_id, editText)}
+ placeholder="label…" style={{ flex: 1, minWidth: 0, fontSize: 11 }} />
+ ) : (
+ <>
+
+ {s.label || 'add label…'}
+
+ { e.stopPropagation(); setEditingFeature(s.feature_id); setEditText(s.label || '') }}
+ style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text)', opacity: 0.5, fontSize: 11, padding: 0 }}>✎
+ >
+ )}
+ {s.n_firing}/{bundle.ng}
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+// Client-side base UMAP from a pooled matrix G (Float32Array, [ng*nf]) -> items with x/y.
+function buildItems(G, nf, ng, meta) {
+ const vecs = Array.from({ length: ng }, (_, i) => Array.from(G.subarray(i * nf, (i + 1) * nf)))
+ const coords = new UMAP({ nComponents: 2, nNeighbors: Math.min(15, Math.max(2, ng - 1)), minDist: 0.1 }).fit(vecs)
+ return meta.map((g, i) => ({ name: g.gene_symbol, label: g.label, species: g.species, x: coords[i][0], y: coords[i][1] }))
+}
+
+function tabStyle(on) {
+ return {
+ padding: '6px 12px', borderRadius: 6, border: '1px solid var(--border,#444)', cursor: 'pointer',
+ background: on ? '#76b900' : 'transparent', color: on ? '#000' : 'var(--text)', fontWeight: on ? 600 : 400,
+ }
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceView.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceView.jsx
new file mode 100644
index 0000000000..5583180fc5
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/SequenceView.jsx
@@ -0,0 +1,267 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { parseBases } from './utils'
+
+function activationColorHex(value, maxValue) {
+ if (maxValue <= 0 || value <= 0) return 'transparent'
+ const n = Math.min(value / maxValue, 1)
+ const r = Math.round(255 - n * 137)
+ const g = Math.round(255 - n * 70)
+ const b = Math.round(255 * (1 - n))
+ const toHex = (c) => c.toString(16).padStart(2, '0')
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
+}
+
+const BASE_WIDTH = 12
+
+const styles = {
+ container: {
+ fontFamily: 'Monaco, Menlo, "Courier New", monospace',
+ fontSize: '11px',
+ lineHeight: '1.2',
+ overflowX: 'auto',
+ position: 'relative',
+ },
+ baseRow: {
+ display: 'inline-flex',
+ whiteSpace: 'nowrap',
+ },
+ baseBlock: {
+ display: 'inline-flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ cursor: 'default',
+ borderRadius: '2px',
+ padding: '1px 1px',
+ marginRight: '0px',
+ minWidth: `${BASE_WIDTH}px`,
+ },
+ padBlock: {
+ display: 'inline-flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ borderRadius: '2px',
+ padding: '1px 1px',
+ marginRight: '0px',
+ minWidth: `${BASE_WIDTH}px`,
+ background: 'var(--density-bar-bg)',
+ },
+ padText: {
+ fontSize: '10px',
+ color: 'var(--text-muted)',
+ },
+ baseText: {
+ fontSize: '10px',
+ letterSpacing: '0.5px',
+ color: 'var(--text)',
+ },
+ idxText: {
+ fontSize: '7px',
+ color: 'var(--text-tertiary)',
+ marginTop: '0px',
+ lineHeight: '1',
+ },
+ tooltip: {
+ position: 'fixed',
+ background: 'var(--bg-card)',
+ color: 'var(--text)',
+ border: '1px solid var(--border)',
+ padding: '4px 8px',
+ borderRadius: '4px',
+ fontSize: '10px',
+ fontFamily: 'monospace',
+ zIndex: 1000,
+ pointerEvents: 'none',
+ whiteSpace: 'nowrap',
+ },
+}
+
+// Show index under every Nth base to keep the row scannable
+const INDEX_INTERVAL = 10
+
+export default function SequenceView({
+ sequence, activations, maxActivation,
+ alignMode, alignAnchor, totalLength,
+ scrollGroupRef,
+}) {
+ const [tooltip, setTooltip] = useState(null)
+ const scrollRef = useRef(null)
+ const anchorRef = useRef(null)
+
+ const bases = parseBases(sequence)
+ const acts = activations ? activations.slice(0, bases.length) : []
+ const maxAct = maxActivation || Math.max(...acts, 0.001)
+
+ // Compute local anchor index
+ let localAnchor = 0
+ if (alignMode === 'first_activation') {
+ localAnchor = acts.findIndex(a => a > 0)
+ if (localAnchor < 0) localAnchor = 0
+ } else if (alignMode === 'max_activation') {
+ let maxVal = -1
+ acts.forEach((a, i) => { if (a > maxVal) { maxVal = a; localAnchor = i } })
+ }
+
+ // Padding
+ const isAligned = alignMode && alignMode !== 'start' && alignAnchor != null
+ const leftPad = isAligned ? Math.max(0, alignAnchor - localAnchor) : 0
+ const rightPad = (totalLength != null)
+ ? Math.max(0, totalLength - leftPad - bases.length)
+ : 0
+
+ // Scroll to anchor when alignMode changes
+ useEffect(() => {
+ if (isAligned && anchorRef.current && scrollRef.current) {
+ anchorRef.current.scrollIntoView({ behavior: 'instant', inline: 'center', block: 'nearest' })
+ }
+ }, [alignMode, alignAnchor])
+
+ // Synchronized scrolling across sequences in the same card
+ useEffect(() => {
+ const el = scrollRef.current
+ if (!el || !scrollGroupRef) return
+
+ if (!scrollGroupRef.current) scrollGroupRef.current = []
+ const group = scrollGroupRef.current
+ if (!group.includes(el)) group.push(el)
+
+ let isSyncing = false
+ const handleScroll = () => {
+ if (isSyncing) return
+ isSyncing = true
+ const scrollLeft = el.scrollLeft
+ for (const other of group) {
+ if (other !== el) other.scrollLeft = scrollLeft
+ }
+ isSyncing = false
+ }
+
+ el.addEventListener('scroll', handleScroll)
+ return () => {
+ el.removeEventListener('scroll', handleScroll)
+ const idx = group.indexOf(el)
+ if (idx !== -1) group.splice(idx, 1)
+ }
+ }, [scrollGroupRef])
+
+ if (!sequence || sequence.length === 0) {
+ return No sequence
+ }
+
+ const handleMouseEnter = (e, base, idx, act) => {
+ setTooltip({
+ x: e.clientX + 10,
+ y: e.clientY - 25,
+ text: `${base} pos ${idx + 1} — activation: ${act.toFixed(4)}`,
+ })
+ }
+
+ const handleMouseMove = (e) => {
+ if (tooltip) {
+ setTooltip((prev) => prev ? { ...prev, x: e.clientX + 10, y: e.clientY - 25 } : null)
+ }
+ }
+
+ const handleMouseLeave = () => {
+ setTooltip(null)
+ }
+
+ const shouldShowIdx = (idx) => (idx + 1) % INDEX_INTERVAL === 0 || idx === 0
+
+ return (
+
+
+ {/* Left padding */}
+ {Array.from({ length: leftPad }, (_, i) => (
+
+ ·
+
+
+ ))}
+
+ {/* Actual bases */}
+ {bases.map((base, idx) => {
+ const act = acts[idx] || 0
+ const bg = activationColorHex(act, maxAct)
+ const isAnchor = isAligned && idx === localAnchor
+ const hasActivation = act > 0
+ const activeTextColor = hasActivation ? '#000' : undefined
+ return (
+ handleMouseEnter(e, base, idx, act)}
+ onMouseMove={handleMouseMove}
+ onMouseLeave={handleMouseLeave}
+ >
+ {base}
+ {shouldShowIdx(idx) ? idx + 1 : ' '}
+
+ )
+ })}
+
+ {/* Right padding */}
+ {Array.from({ length: rightPad }, (_, i) => (
+
+ ·
+
+
+ ))}
+
+ {tooltip && (
+
+ {tooltip.text}
+
+ )}
+
+ )
+}
+
+/**
+ * Compute alignment info for a set of examples — same logic as the codonfm
+ * version, just operating on per-base activation arrays rather than per-codon.
+ */
+export function computeAlignInfo(examples, alignMode) {
+ if (!examples || examples.length === 0) return { anchor: 0, totalLength: 0 }
+
+ if (alignMode === 'start') {
+ const maxLen = Math.max(...examples.map(ex => (ex.activations || []).length))
+ return { anchor: 0, totalLength: maxLen }
+ }
+
+ let maxAnchor = 0
+ for (const ex of examples) {
+ const acts = ex.activations || []
+ let anchor = 0
+ if (alignMode === 'first_activation') {
+ anchor = acts.findIndex(a => a > 0)
+ if (anchor < 0) anchor = 0
+ } else if (alignMode === 'max_activation') {
+ let maxVal = -1
+ acts.forEach((a, i) => { if (a > maxVal) { maxVal = a; anchor = i } })
+ }
+ if (anchor > maxAnchor) maxAnchor = anchor
+ }
+
+ let totalLength = 0
+ for (const ex of examples) {
+ const acts = ex.activations || []
+ let anchor = 0
+ if (alignMode === 'first_activation') {
+ anchor = acts.findIndex(a => a > 0)
+ if (anchor < 0) anchor = 0
+ } else if (alignMode === 'max_activation') {
+ let maxVal = -1
+ acts.forEach((a, i) => { if (a > maxVal) { maxVal = a; anchor = i } })
+ }
+ const leftPad = maxAnchor - anchor
+ const thisTotal = leftPad + acts.length
+ if (thisTotal > totalLength) totalLength = thisTotal
+ }
+
+ return { anchor: maxAnchor, totalLength }
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/backend.js b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/backend.js
new file mode 100644
index 0000000000..1d90699f76
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/backend.js
@@ -0,0 +1,98 @@
+// Shared helpers for the live backend (server.py).
+//
+// All calls go through the Vite dev-server proxy (/api -> http://localhost:8001),
+// so only the Vite port needs to be tunneled. Override with VITE_BACKEND.
+import { useEffect, useRef, useState } from 'react'
+
+export const BACKEND = (import.meta.env && import.meta.env.VITE_BACKEND) || '/api'
+
+// Per-nucleotide letter colors (shared with the steering strips).
+export const BASE_COLORS = { A: '#59A14F', C: '#4E79A7', G: '#F28E2B', T: '#E15759', N: '#888', U: '#E15759' }
+
+// Poll /health so each tab can show a live banner and react when the model/SAE
+// finish loading. status: 'loading' | 'ready' | 'offline'.
+export function useHealth(pollMs = 4000) {
+ const [health, setHealth] = useState({ status: 'loading' })
+ const timer = useRef(null)
+ useEffect(() => {
+ let alive = true
+ const tick = async () => {
+ try {
+ const r = await fetch(`${BACKEND}/health`, { cache: 'no-store' })
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
+ const info = await r.json()
+ if (alive) setHealth({ status: info.ready ? 'ready' : 'loading', info })
+ } catch (e) {
+ if (alive) setHealth({ status: 'offline', error: String(e) })
+ }
+ }
+ tick()
+ timer.current = setInterval(tick, pollMs)
+ return () => {
+ alive = false
+ clearInterval(timer.current)
+ }
+ }, [pollMs])
+ return health
+}
+
+// Viridis — the de-facto perceptually-uniform scientific colormap (matplotlib default).
+const VIRIDIS = [[68, 1, 84], [59, 82, 139], [33, 145, 140], [94, 201, 98], [253, 231, 37]]
+const _l = (a, b, t) => Math.round(a + (b - a) * t)
+
+export function viridis(t) {
+ t = Math.max(0, Math.min(1, t))
+ const n = VIRIDIS.length - 1
+ const x = t * n
+ const i = Math.min(n - 1, Math.floor(x))
+ const f = x - i
+ const a = VIRIDIS[i]
+ const b = VIRIDIS[i + 1]
+ return [_l(a[0], b[0], f), _l(a[1], b[1], f), _l(a[2], b[2], f)]
+}
+
+// CSS gradient for the legend bar.
+export function legendGradient() {
+ return (
+ 'linear-gradient(90deg,' +
+ VIRIDIS.map((c, i) => `rgb(${c[0]},${c[1]},${c[2]}) ${Math.round((100 * i) / (VIRIDIS.length - 1))}%`).join(',') +
+ ')'
+ )
+}
+
+// Activation -> Viridis color, absolute 0->max. Alpha ramps in so zero activation
+// is fully clear (no fill) and intensity rises toward `max`.
+export function activationColor(value, max) {
+ if (!(max > 0) || value <= 0) return 'transparent'
+ const t = Math.max(0, Math.min(1, value / max))
+ if (t < 0.02) return 'transparent'
+ const [r, g, b] = viridis(t)
+ return `rgba(${r}, ${g}, ${b}, ${(0.22 + 0.78 * t).toFixed(3)})`
+}
+
+export function cleanDNA(raw) {
+ return (raw || '').toUpperCase().replace(/[^ACGTN]/g, '')
+}
+
+export async function postJSON(path, body) {
+ const r = await fetch(`${BACKEND}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!r.ok) {
+ let detail = `HTTP ${r.status}`
+ try {
+ const j = await r.json()
+ detail = j.detail || detail
+ } catch (_) {}
+ throw new Error(detail)
+ }
+ return r.json()
+}
+
+export async function getJSON(path) {
+ const r = await fetch(`${BACKEND}${path}`, { cache: 'no-store' })
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
+ return r.json()
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/index.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/index.jsx
new file mode 100644
index 0000000000..c7a8ef7642
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/index.jsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import Dashboard from './Dashboard'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/styles.js b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/styles.js
new file mode 100644
index 0000000000..40d324fc6b
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/styles.js
@@ -0,0 +1,166 @@
+// Shared inline styles for the feature explorer (lifted out of App.jsx).
+
+export const styles = {
+ container: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '16px',
+ gap: '4px',
+ overflow: 'hidden',
+ background: 'var(--bg)',
+ color: 'var(--text)',
+ },
+ header: {
+ flexShrink: 0,
+ },
+ title: {
+ fontSize: '22px',
+ fontWeight: '600',
+ marginBottom: '2px',
+ color: 'var(--text-heading)',
+ },
+ subtitle: {
+ color: 'var(--text-secondary)',
+ fontSize: '13px',
+ margin: 0,
+ },
+ mainContent: {
+ flex: 1,
+ display: 'grid',
+ gridTemplateColumns: '3fr 2fr',
+ gap: '16px',
+ minHeight: 0,
+ overflow: 'hidden',
+ },
+ leftPanel: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '12px',
+ minHeight: 0,
+ minWidth: 0,
+ overflow: 'hidden',
+ },
+ embeddingPanel: {
+ flex: 1,
+ background: 'var(--bg-card)',
+ borderRadius: '8px',
+ border: '1px solid var(--border)',
+ padding: '12px',
+ display: 'flex',
+ flexDirection: 'column',
+ minHeight: '300px',
+ minWidth: 0,
+ overflow: 'hidden',
+ },
+ embeddingContainer: {
+ flex: 1,
+ minHeight: 0,
+ overflow: 'hidden',
+ },
+ histogramRow: {
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr 1fr',
+ gap: '12px',
+ flexShrink: 0,
+ height: '100px',
+ marginBottom: '4px',
+ },
+ histogramPanel: {
+ background: 'var(--bg-card)',
+ borderRadius: '8px',
+ border: '1px solid var(--border)',
+ padding: '8px',
+ overflow: 'hidden',
+ },
+ panelHeader: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '8px',
+ flexShrink: 0,
+ },
+ panelTitle: {
+ fontSize: '14px',
+ fontWeight: '600',
+ color: 'var(--text-heading)',
+ },
+ rightPanel: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ minHeight: 0,
+ minWidth: 0,
+ height: '100%',
+ overflow: 'hidden',
+ },
+ searchBar: {
+ display: 'flex',
+ gap: '8px',
+ flexShrink: 0,
+ },
+ searchInput: {
+ flex: 0.81,
+ padding: '8px 12px',
+ fontSize: '13px',
+ border: '1px solid var(--border-input)',
+ borderRadius: '6px',
+ outline: 'none',
+ background: 'var(--bg-input)',
+ color: 'var(--text)',
+ },
+ sortSelect: {
+ padding: '8px 12px',
+ fontSize: '13px',
+ border: '1px solid var(--border-input)',
+ borderRadius: '6px',
+ background: 'var(--bg-input)',
+ color: 'var(--text)',
+ cursor: 'pointer',
+ },
+ stats: {
+ padding: '4px 0',
+ fontSize: '12px',
+ color: 'var(--text-secondary)',
+ flexShrink: 0,
+ },
+ featureList: {
+ flex: 1,
+ overflowY: 'auto',
+ overflowX: 'hidden',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ paddingRight: '8px',
+ minHeight: 0,
+ },
+ loading: {
+ textAlign: 'center',
+ padding: '40px',
+ color: 'var(--text-secondary)',
+ },
+ error: {
+ textAlign: 'center',
+ padding: '40px',
+ color: '#c00',
+ },
+ colorSelect: {
+ padding: '4px 8px',
+ fontSize: '12px',
+ border: '1px solid var(--border-input)',
+ borderRadius: '4px',
+ background: 'var(--bg-input)',
+ color: 'var(--text)',
+ cursor: 'pointer',
+ },
+ clearButton: {
+ padding: '4px 12px',
+ fontSize: '12px',
+ border: '2px solid var(--accent)',
+ borderRadius: '4px',
+ background: 'transparent',
+ color: 'var(--accent)',
+ fontWeight: '600',
+ cursor: 'pointer',
+ },
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/utils.js b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/utils.js
new file mode 100644
index 0000000000..dbe8e410cb
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/src/utils.js
@@ -0,0 +1,23 @@
+/**
+ * Build a human-readable label for a genomic region example.
+ * Expects an object with sequence_id, start, end fields. Falls back
+ * gracefully if any of those are missing.
+ */
+export function getRegionLabel(example) {
+ if (!example) return ''
+ const sid = example.sequence_id || example.protein_id || ''
+ if (example.start != null && example.end != null) {
+ const range = `${example.start}-${example.end}`
+ return sid ? `${sid}:${range}` : range
+ }
+ return sid
+}
+
+/**
+ * Parse a DNA sequence into an array of single-base tokens.
+ * No codon framing — each base is rendered independently.
+ */
+export function parseBases(sequence) {
+ if (!sequence) return []
+ return sequence.split('')
+}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/vite.config.js b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/vite.config.js
new file mode 100644
index 0000000000..04c58f50ff
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/feature_explorer/vite.config.js
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ root: '.',
+ build: {
+ outDir: 'dist',
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 5176,
+ strictPort: true,
+ // The live backend (server.py) runs on :8001. Proxying it under
+ // /api means only the Vite port needs to be tunneled — the browser talks
+ // to Vite, Vite talks to the backend, both on the pod.
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8001',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, ''),
+ },
+ },
+ },
+})
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/pyproject.toml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/pyproject.toml
index f4a6a32c40..a7bca64016 100644
--- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/pyproject.toml
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/pyproject.toml
@@ -13,11 +13,18 @@ dependencies = [
"torch>=2.0",
"numpy>=1.20",
"pyarrow>=23.0.0",
+ "scikit-learn>=1.3",
"fastapi>=0.110",
"uvicorn>=0.29",
"pandas>=1.5",
]
+# Optional: nicer feature-atlas layout. umap-learn pulls numba (needs NumPy <= 2.3), so it
+# can't live in the megatron venv (NumPy 2.5); `dashboard.py atlas --layout auto` uses it
+# when present and falls back to the numba-free pca/tsne otherwise.
+[project.optional-dependencies]
+umap = ["umap-learn>=0.5"]
+
# The `evo2_sae` package (src/) holds the live inference engine + server + CLI;
# scripts/ (extract, train) are standalone entry points alongside it.
[tool.setuptools.packages.find]
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/dashboard.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/dashboard.py
new file mode 100644
index 0000000000..8abf104119
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/dashboard.py
@@ -0,0 +1,406 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: LicenseRef-Apache2
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Generate the Feature-atlas dashboard parquets via two subcommands, split by cost.
+
+ atlas features_atlas.parquet + feature_metadata.parquet
+ Stats from a RANDOM SAMPLE of cached layer activations (an extract.py store)
+ run through the SAE; UMAP x/y from the SAE decoder. Loads ONLY the SAE — no
+ Evo2 7B, no megatron. This is the same activation store the SAE trained on.
+
+ examples feature_examples.parquet
+ Top-activating sequences + per-base tracks. Needs sequence-aligned activations
+ (which the anonymous token-level cache can't give), so this one loads the full
+ Evo2SAE engine (7B -> SAE) over a SMALL --examples-fasta.
+
+Why the split: the expensive 7B forward pass already ran once (extract.py, reused for SAE
+training). The atlas only needs feature firing-rates + decoder geometry, so it samples that
+cache through the SAE — cheap and representative. Only the example cards inherently need a
+fresh forward pass, and that's a small, bounded job.
+
+Feature *labels* are produced elsewhere — by the feature-probing / label-producer pipeline
+(PR #1630), read from --feature-annotations and joined into `label`; unlabeled -> "Feature N".
+
+Example:
+ # atlas — point at the SAE's training activation store (no 7B):
+ python scripts/dashboard.py atlas --sae-ckpt-path $SAE_CKPT_PATH \
+ --feature-annotations $FEATURE_ANNOTATIONS \
+ --activations-dir /path/to/activation_store --output-dir dashboard_data
+
+ # examples — small corpus through the engine (loads the 7B):
+ python scripts/dashboard.py examples --evo2-ckpt-dir $EVO2_CKPT_DIR \
+ --sae-ckpt-path $SAE_CKPT_PATH --feature-annotations $FEATURE_ANNOTATIONS \
+ --examples-fasta small_corpus.fa --output-dir dashboard_data
+
+ python scripts/launch_dashboard.py --data-dir dashboard_data # then view
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import random
+from pathlib import Path
+
+import torch
+
+
+def _add_sae_args(p):
+ """Args common to both modes: the SAE, labels, layer, device, output, UMAP knobs."""
+ p.add_argument("--sae-ckpt-path", default=os.environ.get("SAE_CKPT_PATH"))
+ p.add_argument("--feature-annotations", default=os.environ.get("FEATURE_ANNOTATIONS"))
+ p.add_argument("--layer", type=int, default=int(os.environ.get("EMBEDDING_LAYER", "26")))
+ p.add_argument("--device", default=os.environ.get("DEVICE", "cuda"))
+ p.add_argument("--output-dir", required=True, help="Directory to write the parquet(s) into")
+ p.add_argument("--umap-n-neighbors", type=int, default=15)
+ p.add_argument("--umap-min-dist", type=float, default=0.1)
+
+
+def parse_args():
+ """Parse the `atlas` / `examples` subcommand and its options."""
+ ap = argparse.ArgumentParser(description="Generate Feature-atlas dashboard parquets (atlas | examples)")
+ sub = ap.add_subparsers(dest="cmd", required=True)
+
+ pa_ = sub.add_parser("atlas", help="features_atlas + feature_metadata from cached activations (no 7B)")
+ _add_sae_args(pa_)
+ pa_.add_argument("--activations-dir", required=True, help="extract.py activation store (shard_*.parquet)")
+ pa_.add_argument("--sample-tokens", type=int, default=2_000_000, help="random tokens to sample from the store")
+ pa_.add_argument("--batch-size", type=int, default=8192, help="SAE-encode batch (rows)")
+ pa_.add_argument("--seed", type=int, default=0)
+ pa_.add_argument(
+ "--layout",
+ choices=["auto", "umap", "pca", "tsne"],
+ default="auto",
+ help="2-D layout: auto = umap if importable else pca (umap needs NumPy<=2.3; pca/tsne are numba-free)",
+ )
+
+ pe = sub.add_parser("examples", help="feature_examples from a small FASTA (loads the 7B)")
+ _add_sae_args(pe)
+ pe.add_argument("--evo2-ckpt-dir", default=os.environ.get("EVO2_CKPT_DIR"))
+ pe.add_argument("--max-seq-len", type=int, default=int(os.environ.get("MAX_SEQ_LEN", "8192")))
+ pe.add_argument("--examples-fasta", required=True, help="SMALL representative FASTA (a few hundred seqs)")
+ pe.add_argument("--max-sequences", type=int, default=1000, help="Cap sequences read (keep it small)")
+ pe.add_argument("--organism", default="None (raw DNA)", help="Phylo-tag preset to prepend")
+ pe.add_argument("--batch-size", type=int, default=4)
+ pe.add_argument("--n-examples", type=int, default=6, help="Top examples per feature")
+ pe.add_argument("--max-example-bp", type=int, default=256, help="Window each example to N bp around its peak")
+ return ap.parse_args()
+
+
+# --------------------------------------------------------------------------- shared
+def _load_sae_only(args):
+ """Load just the SAE (+ labels) by reusing the engine's loaders — no 7B / megatron.
+
+ ``Evo2SAE.__init__`` only records config; ``_load_sae``/``_load_feature_meta`` touch the
+ SAE checkpoint and annotation parquet but never ``bionemo.evo2``, so this stays light.
+ """
+ from evo2_sae.core import Evo2SAE
+
+ eng = Evo2SAE(
+ evo2_ckpt_dir="",
+ sae_ckpt_path=args.sae_ckpt_path,
+ layer=args.layer,
+ device=args.device,
+ feature_annotations=args.feature_annotations,
+ )
+ sae, n_features = eng._load_sae()
+ labels, _ = eng._load_feature_meta()
+ return sae, n_features, labels
+
+
+def _write_label_columns(n_features, labels):
+ """(feature_ids, label_list) with the #1630 labels joined in, 'Feature N' otherwise."""
+ fids = list(range(n_features))
+ return fids, [labels.get(f, f"Feature {f}") for f in fids]
+
+
+# --------------------------------------------------------------------------- atlas mode
+def _iter_sampled_activations(shards, sample_tokens, batch_size):
+ """Yield ``[<=batch_size, hidden_dim]`` CPU tensors sampled from random activation shards."""
+ import numpy as np
+ import pyarrow.parquet as pq
+
+ seen = 0
+ for path in shards:
+ if seen >= sample_tokens:
+ return
+ tbl = pq.read_table(path)
+ dims = [c for c in tbl.column_names if c.startswith("dim_")]
+ arr = np.stack([tbl.column(c).to_numpy(zero_copy_only=False) for c in dims], axis=1)
+ for i in range(0, arr.shape[0], batch_size):
+ if seen >= sample_tokens:
+ return
+ chunk = arr[i : i + batch_size]
+ seen += chunk.shape[0]
+ yield torch.from_numpy(chunk).float()
+
+
+def _compute_layout(sae, args):
+ """2-D feature layout from the SAE decoder directions -> (x, y, method_used).
+
+ `--layout auto` uses UMAP when importable (codonfm-style, best clusters) and otherwise
+ falls back to PCA — so the atlas runs single-env: UMAP needs numba (NumPy <= 2.3), while
+ PCA/TSNE are numba-free and run in the megatron venv (NumPy 2.5). UMAP reads the decoder
+ itself; PCA/TSNE operate on the L2-normalized decoder columns.
+ """
+ import numpy as np
+
+ method = args.layout
+ if method == "auto":
+ try:
+ import umap # noqa: F401
+
+ method = "umap"
+ except Exception:
+ method = "pca"
+ print("[atlas] umap-learn unavailable (NumPy/numba) — falling back to --layout pca")
+
+ if method == "umap":
+ from sae.analysis import compute_feature_umap
+
+ geom = compute_feature_umap(
+ sae, n_neighbors=args.umap_n_neighbors, min_dist=args.umap_min_dist, compute_clusters=False
+ )
+ return np.asarray(geom.umap_x), np.asarray(geom.umap_y), "umap"
+
+ # Numba-free paths operate on L2-normalized decoder columns (one per feature).
+ w = sae.decoder.weight.detach().cpu().float().numpy().T # [n_features, dim]
+ w = w / (np.linalg.norm(w, axis=1, keepdims=True) + 1e-8)
+ if method == "tsne":
+ from sklearn.decomposition import PCA
+ from sklearn.manifold import TSNE
+
+ reduced = PCA(n_components=min(50, w.shape[1]), random_state=args.seed).fit_transform(w)
+ xy = TSNE(n_components=2, init="pca", random_state=args.seed).fit_transform(reduced)
+ return xy[:, 0], xy[:, 1], "tsne"
+
+ # pca (default fallback) — top-2 principal directions via torch.
+ _, _, v = torch.pca_lowrank(torch.from_numpy(w), q=2)
+ xy = (torch.from_numpy(w) @ v).numpy()
+ return xy[:, 0], xy[:, 1], "pca"
+
+
+def run_atlas(args):
+ """features_atlas + feature_metadata from a random sample of the cached activation store."""
+ import math
+
+ import pyarrow as pa
+ import pyarrow.parquet as pq
+
+ sae, n_features, labels = _load_sae_only(args)
+ shards = sorted(Path(args.activations_dir).glob("shard_*.parquet"))
+ if not shards:
+ raise SystemExit(
+ f"No activation store in {args.activations_dir!r}. Generate one with extract.py "
+ f"(the same activations your SAE trained on):\n"
+ f" torchrun --nproc_per_node 8 scripts/extract.py --ckpt-dir $EVO2_CKPT_DIR "
+ f"--embedding-layer {args.layer} --fasta corpus.fa --activation-store-dir {args.activations_dir}"
+ )
+ random.Random(args.seed).shuffle(shards)
+
+ # Streaming, vectorized firing-rate + peak over a random token sample (no 7B). We hand-roll
+ # these instead of sae.analysis.compute_feature_stats: we need only freq/max, while that
+ # helper also builds per-feature top-example heaps over anonymous token indices we can't use.
+ device = args.device
+ sae.eval().to(device)
+ fire = torch.zeros(n_features, device=device)
+ peak = torch.zeros(n_features, device=device)
+ total = 0
+ with torch.no_grad():
+ for batch in _iter_sampled_activations(shards, args.sample_tokens, args.batch_size):
+ codes = sae.encode(batch.to(device))
+ fire += (codes > 0).sum(dim=0).float()
+ peak = torch.maximum(peak, codes.max(dim=0).values)
+ total += codes.shape[0]
+ print(f" atlas: sampled {total:,}/{args.sample_tokens:,} tokens", end="\r")
+ print()
+ if total == 0:
+ raise SystemExit("Sampled 0 tokens — is the activation store empty?")
+ freq = (fire / total).cpu()
+ peak = peak.cpu()
+
+ x, y, used = _compute_layout(sae, args)
+ print(f"[atlas] layout: {used}")
+
+ out = Path(args.output_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ fids, lbls = _write_label_columns(n_features, labels)
+ freq_l = [float(v) for v in freq.tolist()]
+ peak_l = [float(v) for v in peak.tolist()]
+ cols = {
+ "feature_id": pa.array(fids, type=pa.int32()),
+ "label": pa.array(lbls),
+ "activation_freq": pa.array(freq_l, type=pa.float32()),
+ "max_activation": pa.array(peak_l, type=pa.float32()),
+ }
+ atlas = pa.table(
+ {
+ **cols,
+ "x": pa.array([float(v) for v in x], type=pa.float32()),
+ "y": pa.array([float(v) for v in y], type=pa.float32()),
+ "log_frequency": pa.array([math.log10(v) if v > 0 else -10.0 for v in freq_l], type=pa.float32()),
+ }
+ )
+ pq.write_table(atlas, out / "features_atlas.parquet", compression="snappy")
+ pq.write_table(pa.table(cols), out / "feature_metadata.parquet", compression="snappy")
+ live = int((peak > 0).sum())
+ print(
+ f"[atlas] wrote features_atlas + feature_metadata ({n_features} features, {live} live, {total:,} tokens) -> {out}"
+ )
+
+
+# --------------------------------------------------------------------------- examples mode
+def _pass1_max_acts(eng, seqs, tag, tag_len, batch_size):
+ """Per-(sequence, feature) max activation -> [n_seq, n_features], reducing each batch eagerly."""
+ n_seq, n_features = len(seqs), eng.n_features
+ max_acts = torch.zeros(n_seq, n_features, dtype=torch.float32)
+ for start in range(0, n_seq, batch_size):
+ chunk = seqs[start : start + batch_size]
+ codes_list = eng.encode_batch([tag + s for s in chunk], batch_size=batch_size)
+ for j, codes in enumerate(codes_list):
+ region = codes[tag_len:] if codes.shape[0] > tag_len else codes
+ if region.shape[0]:
+ max_acts[start + j] = region.max(dim=0).values
+ print(f" examples pass 1: {min(start + batch_size, n_seq)}/{n_seq} sequences", end="\r")
+ print()
+ return max_acts
+
+
+def _pass2_examples(eng, seqs, ids, tag, tag_len, top_idx, peak, labels, max_example_bp, batch_size):
+ """Re-encode only the winning sequences and pull each example's windowed per-base track.
+
+ Memory-bounded: per (seq, feature) we extract just that feature's column and immediately
+ materialize a short windowed list (``.tolist()`` detaches it), so the full ``[S, n_features]``
+ code tensor is freed each batch — never accumulated. Dead features (peak == 0) are skipped.
+ """
+ n_examples, n_features = top_idx.shape
+ alive = [f for f in range(n_features) if peak[f] > 0]
+ needed: dict[int, set[int]] = {}
+ for f in alive:
+ for r in range(n_examples):
+ needed.setdefault(int(top_idx[r, f]), set()).add(f)
+
+ win: dict[tuple, tuple] = {} # (seq_idx, feat) -> (lo, hi, [activations])
+ need_ids = sorted(needed)
+ for start in range(0, len(need_ids), batch_size):
+ batch_ids = need_ids[start : start + batch_size]
+ codes_list = eng.encode_batch([tag + seqs[i] for i in batch_ids], batch_size=batch_size)
+ for i, codes in zip(batch_ids, codes_list):
+ region = codes[tag_len:] if codes.shape[0] > tag_len else codes
+ for f in needed[i]:
+ track = region[:, f]
+ lo, hi = 0, track.shape[0]
+ if max_example_bp and hi > max_example_bp:
+ pk = int(track.argmax())
+ lo = max(0, pk - max_example_bp // 2)
+ hi = min(track.shape[0], lo + max_example_bp)
+ lo = max(0, hi - max_example_bp)
+ win[(i, f)] = (lo, hi, [round(float(v), 4) for v in track[lo:hi].tolist()])
+ print(f" examples pass 2: re-encoded {min(start + batch_size, len(need_ids))}/{len(need_ids)} seqs", end="\r")
+ print()
+
+ rows = []
+ for f in alive:
+ for rank in range(n_examples):
+ si = int(top_idx[rank, f])
+ w = win.get((si, f))
+ if w is None:
+ continue
+ lo, hi, acts = w
+ if not acts or max(acts) <= 0:
+ continue
+ rows.append(
+ {
+ "feature_id": f,
+ "example_rank": rank,
+ "sequence_id": ids[si],
+ "start": lo,
+ "end": hi,
+ "sequence": seqs[si][lo:hi],
+ "activations": acts,
+ "max_activation": max(acts),
+ "best_annotation": labels.get(f, ""),
+ }
+ )
+ rows.sort(key=lambda r: (r["feature_id"], r["example_rank"]))
+ return rows
+
+
+def run_examples(args):
+ """feature_examples from a small corpus run through the full Evo2SAE engine (loads the 7B)."""
+ import pyarrow as pa
+ import pyarrow.parquet as pq
+ from evo2_sae.core import Evo2SAE, clean_dna
+ from evo2_sae.fasta import read_fasta
+
+ eng = Evo2SAE(
+ evo2_ckpt_dir=args.evo2_ckpt_dir,
+ sae_ckpt_path=args.sae_ckpt_path,
+ layer=args.layer,
+ device=args.device,
+ max_seq_len=args.max_seq_len,
+ feature_annotations=args.feature_annotations,
+ ).load()
+
+ ids, seqs = [], []
+ for sid, seq in read_fasta(args.examples_fasta):
+ if len(seqs) >= args.max_sequences:
+ break
+ ids.append(sid)
+ seqs.append(clean_dna(seq))
+ if not seqs:
+ raise SystemExit(f"No sequences read from {args.examples_fasta}")
+ tag = eng.resolve_tag(args.organism, None) or ""
+ tag_len = len(tag) if tag else 0
+ print(f"[examples] {len(seqs)} sequences, {eng.n_features} features, organism={args.organism!r}")
+
+ max_acts = _pass1_max_acts(eng, seqs, tag, tag_len, args.batch_size)
+ peak = max_acts.max(dim=0).values
+ k = min(args.n_examples, len(seqs))
+ top_idx = torch.topk(max_acts, k=k, dim=0).indices
+ rows = _pass2_examples(
+ eng, seqs, ids, tag, tag_len, top_idx, peak, eng.labels, args.max_example_bp, args.batch_size
+ )
+
+ out = Path(args.output_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ tbl = pa.table(
+ {
+ "feature_id": pa.array([r["feature_id"] for r in rows], type=pa.int32()),
+ "example_rank": pa.array([r["example_rank"] for r in rows], type=pa.int8()),
+ "sequence_id": pa.array([r["sequence_id"] for r in rows]),
+ "start": pa.array([r["start"] for r in rows], type=pa.int32()),
+ "end": pa.array([r["end"] for r in rows], type=pa.int32()),
+ "sequence": pa.array([r["sequence"] for r in rows]),
+ "activations": pa.array([r["activations"] for r in rows], type=pa.list_(pa.float32())),
+ "max_activation": pa.array([r["max_activation"] for r in rows], type=pa.float32()),
+ "best_annotation": pa.array([r["best_annotation"] for r in rows]),
+ }
+ )
+ pq.write_table(tbl, out / "feature_examples.parquet", row_group_size=600, compression="snappy")
+ print(f"[examples] wrote {len(rows)} example rows for {int((peak > 0).sum())} live features -> {out}")
+
+
+def main():
+ """Dispatch to the atlas / examples subcommand."""
+ args = parse_args() # before heavy imports so --help works without the model stack
+ if args.cmd == "atlas":
+ run_atlas(args)
+ else:
+ run_examples(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_dashboard.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_dashboard.py
new file mode 100644
index 0000000000..1f5aff44a6
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/scripts/launch_dashboard.py
@@ -0,0 +1,105 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: LicenseRef-Apache2
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Launch the evo2 SAE feature-explorer dashboard on data you provide.
+
+Starts Vite for the dashboard. The Sequence-inspector and Generative-steering tabs call the
+live backend (start it separately: `scripts/launch_inference.sh serve`) and need no data here.
+The Feature-atlas tab is static — pass --data-dir with precomputed atlas parquets to populate
+it (this script does NOT generate them; that is a separate offline step):
+
+ python scripts/launch_dashboard.py # inspector + steering only
+ python scripts/launch_dashboard.py --data-dir /path/to/data # + Feature atlas
+"""
+
+import argparse
+import shutil
+import subprocess
+import sys
+import time
+import webbrowser
+from pathlib import Path
+
+
+REQUIRED_PARQUETS = ("features_atlas.parquet", "feature_metadata.parquet", "feature_examples.parquet")
+DASHBOARD_DIR = Path(__file__).resolve().parent.parent / "feature_explorer"
+
+
+def stage_dashboard_data(data_dir, public_dir) -> list[str]:
+ """Validate the user-provided atlas parquets in `data_dir` and copy them into `public_dir`.
+
+ Checks each required parquet exists and has a `feature_id` column (so a wrong directory fails
+ fast rather than rendering an empty dashboard). Returns the staged filenames.
+ """
+ import pyarrow.parquet as pq
+
+ data_dir, public_dir = Path(data_dir), Path(public_dir)
+ missing = [f for f in REQUIRED_PARQUETS if not (data_dir / f).exists()]
+ if missing:
+ raise FileNotFoundError(f"--data-dir {data_dir} is missing required parquet(s): {', '.join(missing)}")
+ for f in REQUIRED_PARQUETS:
+ cols = pq.read_schema(data_dir / f).names
+ if "feature_id" not in cols:
+ raise ValueError(f"{f} has no 'feature_id' column (got {cols}) — wrong file?")
+ public_dir.mkdir(parents=True, exist_ok=True)
+ for f in REQUIRED_PARQUETS:
+ shutil.copy2(data_dir / f, public_dir / f)
+ return list(REQUIRED_PARQUETS)
+
+
+def main():
+ """Stage the provided dashboard data and start the Vite dev server."""
+ ap = argparse.ArgumentParser(description="Launch the evo2 SAE feature-explorer dashboard")
+ ap.add_argument(
+ "--data-dir",
+ help=f"Directory with {', '.join(REQUIRED_PARQUETS)} for the Feature-atlas tab. "
+ "Omit to launch with the inspector + steering tabs only (which use the live backend).",
+ )
+ ap.add_argument("--port", type=int, default=5176)
+ ap.add_argument("--no-open", action="store_true", help="Don't open a browser")
+ args = ap.parse_args()
+
+ if not (DASHBOARD_DIR / "package.json").exists():
+ sys.exit(f"dashboard not found at {DASHBOARD_DIR}")
+
+ if args.data_dir:
+ staged = stage_dashboard_data(args.data_dir, DASHBOARD_DIR / "public")
+ print(f"staged {len(staged)} parquet(s) -> {DASHBOARD_DIR / 'public'}")
+ else:
+ print("no --data-dir: Feature-atlas tab will be empty; inspector + steering use the backend.")
+
+ if not (DASHBOARD_DIR / "node_modules").exists():
+ print("installing dashboard dependencies (npm install)...")
+ subprocess.run(["npm", "install"], cwd=DASHBOARD_DIR, check=True)
+
+ print(f"\ndashboard: http://localhost:{args.port}")
+ print("inspector + steering tabs need the backend: scripts/launch_inference.sh serve\n")
+ proc = subprocess.Popen(["npx", "vite", "--port", str(args.port)], cwd=DASHBOARD_DIR)
+ if not args.no_open:
+ time.sleep(2)
+ webbrowser.open(f"http://localhost:{args.port}")
+ try:
+ input("dashboard running — press Enter to stop.\n")
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.py
index 00035d9b7f..6cc72cc80b 100644
--- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.py
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/core.py
@@ -353,6 +353,7 @@ def generate(
comp = self._ensure_engine()
hook_layer = unwrap_model(comp.model).decoder.layers[self.layer]
from sae.steering import clamp_hook
+
feat_meta = [{"id": fid, "label": self.labels.get(fid), "strength": s} for fid, s in clamps.items()]
def _run(steer: bool) -> str:
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py
index bb7e3b391d..8f6c0a86bb 100644
--- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py
@@ -69,6 +69,15 @@ class GenerateRequest(BaseModel):
compare_baseline: bool = False
+class GeneEmbedRequest(BaseModel):
+ """Request body for /gene_embed (embed many sequences into per-feature vectors for UMAP)."""
+
+ genes: list[dict] # [{symbol, sequence, label?, species?}, ...]
+ organism: str = "None (raw DNA)"
+ tag: Optional[str] = None
+ min_firing: int = 10 # feature_stats keeps features firing in >= this many sequences
+
+
def build_app(engine: Evo2SAE) -> FastAPI:
"""Build the FastAPI app; the engine is loaded once in the lifespan handler."""
@@ -175,4 +184,77 @@ def generate(req: GenerateRequest):
except ValueError as e:
raise HTTPException(400, str(e))
+ @app.post("/gene_embed")
+ def gene_embed(req: GeneEmbedRequest):
+ """Embed sequences for the Sequence-UMAP tab.
+
+ Each sequence -> Evo2 layer-L -> SAE -> pool over the DNA region into a per-feature
+ vector. One encode per sequence yields both mean- and max-pooled vectors (base64
+ float32 [n x n_features]) so the client can toggle pooling without re-running the model;
+ UMAP runs client-side. Also returns per-sequence metadata + feature stats.
+ """
+ if not engine.ready:
+ raise HTTPException(503, "Backend not ready")
+ import base64
+
+ import numpy as np
+
+ tag = engine.resolve_tag(req.organism, req.tag)
+ if tag is None:
+ raise HTTPException(400, f"Unknown organism '{req.organism}' and no custom tag")
+ tag_len = len(tag)
+ seqs, meta = [], []
+ for g in req.genes[:1000]:
+ dna = clean_dna(str(g.get("sequence", "")))
+ if len(dna) < 3:
+ continue
+ seqs.append(tag + dna)
+ meta.append(
+ {
+ "gene_symbol": g.get("symbol") or g.get("gene_symbol") or f"gene{len(meta)}",
+ "label": g.get("label"),
+ "species": g.get("species"),
+ }
+ )
+ if not seqs:
+ raise HTTPException(400, "No valid gene sequences")
+
+ rows_mean, rows_max, meta_out = [], [], []
+ for codes, m in zip(engine.encode_batch(seqs), meta): # codes: [S, n_features]
+ tl = tag_len if codes.shape[0] > tag_len else 0
+ seg = codes[tl:] # DNA region only (drop the phylo-tag tokens)
+ if seg.shape[0] == 0:
+ continue
+ rows_mean.append(seg.mean(dim=0).numpy().astype(np.float32))
+ rows_max.append(seg.max(dim=0).values.numpy().astype(np.float32))
+ meta_out.append(m)
+ if not rows_mean:
+ raise HTTPException(400, "No valid gene sequences")
+
+ gmean = np.stack(rows_mean).astype(np.float32) # [n_genes, n_features]
+ gmax = np.stack(rows_max).astype(np.float32)
+ n_firing = (gmax > 0).sum(0) # TopK/ReLU codes >= 0 -> firing set is pooling-invariant
+ stats = []
+ for fid in np.nonzero(n_firing >= req.min_firing)[0]:
+ fid = int(fid)
+ col = gmean[:, fid]
+ stats.append(
+ {
+ "feature_id": fid,
+ "n_firing": int(n_firing[fid]),
+ "mean_act_when_firing": float(col[col > 0].mean()) if (col > 0).any() else 0.0,
+ "max_act": float(gmax[:, fid].max()),
+ "label": engine.labels.get(fid),
+ }
+ )
+ stats.sort(key=lambda s: -s["n_firing"])
+ return {
+ "G_b64": base64.b64encode(gmean.tobytes()).decode(),
+ "Gmax_b64": base64.b64encode(gmax.tobytes()).decode(),
+ "n_features": int(gmean.shape[1]),
+ "n_genes": int(gmean.shape[0]),
+ "genes": meta_out,
+ "feature_stats": stats,
+ }
+
return app
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_launch_dashboard.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_launch_dashboard.py
new file mode 100644
index 0000000000..b48845a61f
--- /dev/null
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_launch_dashboard.py
@@ -0,0 +1,59 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: LicenseRef-Apache2
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CPU tests for the dashboard launcher's data staging (no npm/vite, no model)."""
+
+import sys
+from pathlib import Path
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+import launch_dashboard as L
+
+
+def _write(path, with_feature_id=True):
+ cols = {"feature_id": [0, 1], "x": [0.1, 0.2]} if with_feature_id else {"x": [0.1, 0.2]}
+ pq.write_table(pa.table(cols), path)
+
+
+def test_stage_copies_required_parquets(tmp_path):
+ data, public = tmp_path / "data", tmp_path / "public"
+ data.mkdir()
+ for f in L.REQUIRED_PARQUETS:
+ _write(data / f)
+ staged = L.stage_dashboard_data(data, public)
+ assert set(staged) == set(L.REQUIRED_PARQUETS)
+ assert all((public / f).exists() for f in L.REQUIRED_PARQUETS)
+
+
+def test_stage_missing_parquet_fails_fast(tmp_path):
+ data = tmp_path / "data"
+ data.mkdir()
+ _write(data / "features_atlas.parquet") # only one of three
+ with pytest.raises(FileNotFoundError):
+ L.stage_dashboard_data(data, tmp_path / "public")
+
+
+def test_stage_rejects_wrong_schema(tmp_path):
+ data = tmp_path / "data"
+ data.mkdir()
+ for f in L.REQUIRED_PARQUETS:
+ _write(data / f, with_feature_id=False) # no feature_id column
+ with pytest.raises(ValueError):
+ L.stage_dashboard_data(data, tmp_path / "public")
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py
index 82b5b0726b..b0d807193c 100644
--- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py
+++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py
@@ -50,6 +50,9 @@ def encode(self, full):
codes[:, 0] = 1.0 # feature 0 fires everywhere
return codes
+ def encode_batch(self, seqs, batch_size=8):
+ return [self.encode(s) for s in seqs]
+
def top_features(self, codes, tag_len=0, k=8):
return [{"feature_id": 0, "label": self.labels.get(0), "max_activation": 1.0}]
@@ -96,6 +99,19 @@ def test_generate_returns_sequence(client):
assert b["generation"]["sequence"]
+def test_gene_embed_returns_decodable_matrix(client):
+ import base64
+
+ import numpy as np
+
+ genes = [{"symbol": "g1", "sequence": "ACGTACGT"}, {"symbol": "g2", "sequence": "TTTTGGGG"}]
+ b = client.post("/gene_embed", json={"genes": genes, "min_firing": 1}).json()
+ assert {"G_b64", "Gmax_b64", "n_features", "n_genes", "genes"} <= set(b)
+ assert b["n_genes"] == 2 and len(b["genes"]) == 2
+ g = np.frombuffer(base64.b64decode(b["G_b64"]), dtype=np.float32)
+ assert g.size == b["n_genes"] * b["n_features"] # [n_genes x n_features], the matrix the client UMAPs
+
+
def test_endpoints_503_until_ready():
eng = FakeEngine()
eng.ready = False