diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/.gitignore b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/.gitignore new file mode 100644 index 0000000000..5f3aa07063 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/.gitignore @@ -0,0 +1,22 @@ +.cache/ +outputs/ +wandb/ + +# Generated dashboard data +**/public/ +**/dist/ +**/feature_analysis.json +**/feature_labels.json +**/vocab_logits.json +**/node_modules/ +dash/ +dash2/ +dash108k/ +dash_438k/ +dash_438k_auto/ +dash_438k_clinvar/ +dash_ef64/ +gtc_dash/ +nndash/ +nndash2/ +olddash/ diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/1b.sh b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/1b.sh new file mode 100755 index 0000000000..2b70d369d8 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/1b.sh @@ -0,0 +1,89 @@ +#!/bin/bash +set -e + +# CodonFM Encodon-1B SAE Pipeline + +MODEL_PATH=checkpoints/NV-CodonFM-Encodon-TE-Cdwt-1B-v1/model.safetensors +CSV_PATH=/data/jwilber/codonfm/data/sample_108k.csv +LAYER=16 +NUM_SEQUENCES=10000 +OUTPUT_DIR=./outputs/1b_layer16 + +echo "============================================================" +echo "STEP 1: Extract activations from Encodon-1B" +echo "============================================================" + +torchrun --nproc_per_node=4 scripts/extract.py \ + --csv-path $CSV_PATH \ + --model-path $MODEL_PATH \ + --layer $LAYER \ + --num-sequences $NUM_SEQUENCES \ + --batch-size 8 \ + --context-length 2048 \ + --shard-size 100000 \ + --output .cache/activations/primates_${NUM_SEQUENCES}_1b_layer${LAYER} + +echo "" +echo "============================================================" +echo "STEP 2: Train SAE on cached activations" +echo "============================================================" + +torchrun --nproc_per_node=4 scripts/train.py \ + --cache-dir .cache/activations/primates_${NUM_SEQUENCES}_1b_layer${LAYER} \ + --model-path $MODEL_PATH \ + --layer $LAYER \ + --model-type topk \ + --expansion-factor 16 \ + --top-k 32 \ + --auxk 512 \ + --auxk-coef 0.03125 \ + --dead-tokens-threshold 500000 \ + --n-epochs 40 \ + --batch-size 4096 \ + --lr 3e-4 \ + --log-interval 50 \ + --dp-size 4 \ + --seed 42 \ + --wandb \ + --wandb-project sae_codonfm_recipe \ + --wandb-run-name "1b_layer${LAYER}_ef16_k32" \ + --output-dir ${OUTPUT_DIR} \ + --checkpoint-dir ${OUTPUT_DIR}/checkpoints + +echo "" +echo "============================================================" +echo "STEP 3: Analyze features (vocab logits + codon annotations)" +echo "============================================================" + +python scripts/analyze.py \ + --checkpoint ${OUTPUT_DIR}/checkpoints/checkpoint_final.pt \ + --model-path $MODEL_PATH \ + --csv-path $CSV_PATH \ + --layer $LAYER \ + --num-sequences $NUM_SEQUENCES \ + --batch-size 8 \ + --output-dir ${OUTPUT_DIR}/analysis \ + --dashboard-dir ${OUTPUT_DIR}/dashboard + +echo "" +echo "============================================================" +echo "STEP 4: Build dashboard" +echo "============================================================" + +python scripts/dashboard.py \ + --checkpoint ${OUTPUT_DIR}/checkpoints/checkpoint_final.pt \ + --model-path $MODEL_PATH \ + --csv-path $CSV_PATH \ + --layer $LAYER \ + --num-sequences $NUM_SEQUENCES \ + --batch-size 8 \ + --n-examples 6 \ + --umap-n-neighbors 15 \ + --umap-min-dist 0.1 \ + --hdbscan-min-cluster-size 20 \ + --output-dir ${OUTPUT_DIR}/dashboard + +echo "" +echo "============================================================" +echo "DONE — Dashboard output: ${OUTPUT_DIR}/dashboard" +echo "============================================================" diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/1b_swissprot.sh b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/1b_swissprot.sh new file mode 100755 index 0000000000..fca8207aed --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/1b_swissprot.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +# CodonFM Encodon-1B SwissProt F1 Evaluation Pipeline +# Evaluates whether CodoNFM SAE features align with protein-level SwissProt annotations + +MODEL_PATH=checkpoints/NV-CodonFM-Encodon-TE-Cdwt-1B-v1/model.safetensors +LAYER=16 +OUTPUT_DIR=./outputs/1b_layer16 + +echo "============================================================" +echo "STEP 1: Download SwissProt proteins with CDS sequences" +echo "============================================================" + +python scripts/download_codonfm_swissprot.py \ + --output-dir ./data/codonfm_swissprot \ + --max-proteins 8000 \ + --max-length 512 \ + --annotation-score 5 \ + --workers 8 + +echo "" +echo "============================================================" +echo "STEP 2: F1 evaluation against SwissProt annotations" +echo "============================================================" + +python scripts/eval_swissprot_f1.py \ + --checkpoint ${OUTPUT_DIR}/checkpoints/checkpoint_final.pt \ + --model-path $MODEL_PATH \ + --layer $LAYER \ + --batch-size 8 \ + --context-length 2048 \ + --swissprot-tsv ./data/codonfm_swissprot/codonfm_swissprot.tsv.gz \ + --f1-max-proteins 8000 \ + --f1-min-positives 10 \ + --f1-threshold 0.3 \ + --normalization-n-proteins 2000 \ + --output-dir ${OUTPUT_DIR}/swissprot_eval + +echo "" +echo "============================================================" +echo "DONE — SwissProt F1 results: ${OUTPUT_DIR}/swissprot_eval" +echo "============================================================" diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/README.md b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/README.md new file mode 100644 index 0000000000..ecd271e54d --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/README.md @@ -0,0 +1,172 @@ +# CodonFM SAE Recipe + +Train and analyze sparse autoencoders on [CodonFM](https://huggingface.co/nvidia/NV-CodonFM-Encodon-1B-v1) Encodon codon language models. The pipeline extracts residual stream activations, trains a TopK SAE, evaluates reconstruction quality, and optionally generates an interactive feature dashboard. + +## Pipeline + +``` +Extract activations -> Train SAE -> Evaluate -> Analyze (optional) -> Dashboard (optional) +``` + +**Extract** runs the Encodon model over DNA coding sequences, saving per-codon hidden states from a target layer to sharded Parquet files. **Train** fits a TopK SAE (8x expansion, top-32 sparsity by default) on those activations. **Evaluate** measures loss recovered by comparing model logits with and without the SAE bottleneck. **Analyze** computes per-feature interpretability annotations (codon usage bias, amino acid identity, wobble position, CpG content) and optionally generates LLM-based feature labels. **Dashboard** builds UMAP embeddings and exports data for a React-based interactive feature explorer. + +## Prerequisites + +1. Encodon checkpoint (`.safetensors` or `.ckpt` with accompanying `config.json`): + + ```bash + huggingface-cli download nvidia/NV-CodonFM-Encodon-1B-v1 --local-dir ./checkpoints/encodon_1b + ``` + +2. DNA sequence data as a CSV with a coding sequence column (`cds`, `seq`, or `sequence` -- auto-detected). + +3. Install dependencies: + + ```bash + # From repo root (UV workspace) + uv sync + ``` + +## Quick Start + +```bash +# Full pipeline: extract -> train -> eval +python run.py model=1b csv_path=path/to/Primates.csv + +# Skip extraction if activations are already cached +python run.py model=1b csv_path=path/to/data.csv steps.extract=false + +# Smoke test +python run.py model=1b csv_path=path/to/data.csv num_sequences=100 train.n_epochs=1 nproc=1 dp_size=1 +``` + +## Step-by-Step + +### 1. Extract Activations + +```bash +# Single GPU +python scripts/extract.py \ + --csv-path path/to/Primates.csv \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 \ + --num-sequences 50000 \ + --output .cache/activations/encodon_1b_layer-2 + +# Multi-GPU +torchrun --nproc_per_node=4 scripts/extract.py \ + --csv-path path/to/Primates.csv \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 \ + --output .cache/activations/encodon_1b_layer-2 +``` + +Outputs sharded Parquet files + `metadata.json` to the cache directory. CLS and SEP tokens are stripped; only codon-position activations are saved. + +### 2. Train SAE + +```bash +python scripts/train.py \ + --cache-dir .cache/activations/encodon_1b_layer-2 \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 \ + --expansion-factor 8 --top-k 32 \ + --batch-size 4096 --n-epochs 3 \ + --output-dir ./outputs/encodon_1b + +# Multi-GPU +torchrun --nproc_per_node=4 scripts/train.py \ + --cache-dir .cache/activations/encodon_1b_layer-2 \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 --dp-size 4 \ + --expansion-factor 8 --top-k 32 \ + --batch-size 4096 --n-epochs 3 \ + --output-dir ./outputs/encodon_1b +``` + +Saves checkpoint to `./outputs/encodon_1b/checkpoints/checkpoint_final.pt`. + +### 3. Evaluate + +```bash +python scripts/eval.py \ + --checkpoint ./outputs/encodon_1b/checkpoints/checkpoint_final.pt \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 --top-k 32 \ + --csv-path path/to/data.csv \ + --output-dir ./outputs/encodon_1b/eval +``` + +### 4. Analyze Features (optional) + +```bash +python scripts/analyze.py \ + --checkpoint ./outputs/encodon_1b/checkpoints/checkpoint_final.pt \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 --top-k 32 \ + --csv-path path/to/Primates.csv \ + --output-dir ./outputs/encodon_1b/analysis \ + --auto-interp --max-auto-interp-features 500 +``` + +Produces `vocab_logits.json`, `feature_analysis.json`, and `feature_labels.json`. + +### 5. Dashboard (optional) + +```bash +# Generate dashboard data +python scripts/dashboard.py \ + --checkpoint ./outputs/encodon_1b/checkpoints/checkpoint_final.pt \ + --model-path path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 --top-k 32 \ + --csv-path path/to/Primates.csv \ + --output-dir ./outputs/encodon_1b/dashboard + +# Launch web UI +python scripts/launch_dashboard.py --data-dir ./outputs/encodon_1b/dashboard +``` + +## Model Sizes + +| Model | Params | Layers | Hidden Dim | Batch Size | Config | +| ------------ | ------ | ------ | ---------- | ---------- | ------------ | +| Encodon 80M | 80M | 6 | 1024 | 32 | `model=80m` | +| Encodon 600M | 600M | 12 | 2048 | 16 | `model=600m` | +| Encodon 1B | 1B | 18 | 2048 | 8 | `model=1b` | +| Encodon 5B | 5B | 24 | 4096 | 2 | `model=5b` | + +## Configuration + +Hydra configs live in `run_configs/`. The base config (`config.yaml`) sets defaults for all steps. Model-specific configs in `run_configs/model/` override `model_path`, `run_name`, `num_sequences`, and `batch_size`. + +Override any parameter on the command line: + +```bash +python run.py model=1b csv_path=data.csv train.n_epochs=5 train.lr=1e-4 nproc=8 +``` + +Key training defaults: `expansion_factor=8`, `top_k=32`, `lr=3e-4`, `n_epochs=3`, `batch_size=4096`, `layer=-2`. + +## Project Structure + +``` +recipes/codonfm/ + run.py Hydra pipeline orchestrator + run_configs/ Hydra configs (config.yaml, model/*.yaml) + scripts/ + extract.py Extract layer activations (multi-GPU) + train.py Train TopK SAE (multi-GPU) + eval.py Loss recovered evaluation + analyze.py Feature interpretability annotations + dashboard.py UMAP + dashboard data export + launch_dashboard.py Serve interactive web UI + mutation_features.py Mutation-site feature analysis + src/codonfm_sae/ Recipe-specific code (CSV loader, eval) + codon-fm/ CodonFM model code (tokenizer, inference, models) + codon_dashboard/ React/Vite interactive dashboard + notebooks/ Jupyter notebooks (UMAP exploration) +``` + +## Data Format + +CSV with a DNA coding sequence column. The loader auto-detects columns named `cds`, `seq`, or `sequence`. Each sequence should be a string of nucleotides whose length is divisible by 3 (codons). The tokenizer splits into 3-mer codons from a 69-token vocabulary (5 special + 64 DNA codons). diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/README.md b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/README.md new file mode 100644 index 0000000000..a720f6525d --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/README.md @@ -0,0 +1,89 @@ +# SAE Feature Dashboard + +Interactive dashboard for exploring Sparse Autoencoder (SAE) features with UMAP embedding visualization and crossfiltering. + +## Features + +- **UMAP Embedding View**: Interactive scatter plot of feature embeddings with pan/zoom +- **Crossfiltering**: Brush selection on UMAP and histograms filters the feature list +- **Feature Cards**: Expandable cards showing: + - Feature description/label + - Activation frequency and max activation stats + - Top positive/negative logits (tokens the feature promotes/suppresses) + - Top activating examples with token highlighting +- **Search**: Filter features by description text +- **Color by Category**: Color points by categorical or sequential columns + +## Usage + +### From Python (via `sae.launch_dashboard`) + +```python +from sae import launch_dashboard + +# Launch dashboard with your data +launch_dashboard( + features_json="path/to/features.json", + atlas_parquet="path/to/features_atlas.parquet", + port=5173, +) +``` + +### Manual Setup + +1. Copy your data files to the `public/` directory: + + - `features.json` - Feature metadata with examples + - `features_atlas.parquet` - UMAP coordinates and stats + +2. Install dependencies and run: + + ```bash + npm install + npm run dev + ``` + +3. Open http://localhost:5173 + +## Data Format + +### features.json + +```json +{ + "features": [ + { + "feature_id": 0, + "description": "Feature description", + "activation_freq": 0.05, + "max_activation": 12.5, + "top_positive_logits": [["token1", 2.5], ["token2", 2.1]], + "top_negative_logits": [["token3", -1.8], ["token4", -1.5]], + "examples": [ + { + "max_activation": 10.2, + "tokens": [ + {"token": "hello", "activation": 0.0}, + {"token": " world", "activation": 10.2} + ] + } + ] + } + ] +} +``` + +### features_atlas.parquet + +Required columns: + +- `feature_id`: Integer feature ID +- `x`, `y`: UMAP coordinates +- `label` or `best_annotation`: Feature label for display +- `log_frequency`: Log of activation frequency +- `max_activation`: Maximum activation value + +Optional columns for coloring: + +- Any VARCHAR column with \<= 50 unique values (categorical) +- `cluster`, `category`, `group` integer columns (categorical) diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/index.html b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/index.html new file mode 100644 index 0000000000..1cf8502082 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/index.html @@ -0,0 +1,104 @@ + + + + + + SAE Feature Dashboard + + + +
+ + + diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/package-lock.json b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/package-lock.json new file mode 100644 index 0000000000..8bb23148d4 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/package-lock.json @@ -0,0 +1,6366 @@ +{ + "name": "protein-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "protein-dashboard", + "version": "0.1.0", + "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", + "molstar": "^4.8.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@duckdb/duckdb-wasm": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.30.0.tgz", + "integrity": "sha512-9aWrm+4ayl4sTlvGtl/b+LxrUyXaac3yyVqkoJ3F7Vkd62PoS8PcQIRJ/KjXBW36LP1CnPY5jjvFyIcTFLtcXA==", + "license": "MIT", + "dependencies": { + "apache-arrow": "^17.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@observablehq/plot": { + "version": "0.6.17", + "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", + "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", + "license": "ISC", + "dependencies": { + "d3": "^7.9.0", + "interval-tree-1d": "^1.0.0", + "isoformat": "^0.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/argparse": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-2.0.17.tgz", + "integrity": "sha512-fueJssTf+4dW4HODshEGkIZbkLKHzgu1FvCI4cTc/MKum/534Euo3SrN+ilq8xgyHnOjtmg33/hee8iXLRg1XA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/benchmark": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/benchmark/-/benchmark-2.1.5.tgz", + "integrity": "sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/swagger-ui-dist": { + "version": "3.30.5", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.5.tgz", + "integrity": "sha512-SrXhD9L8qeIxJzN+o1kmf3wXeVf/+Km3jIdRM1+Yq3I5b/dlF5TcGr5WCVM7I/cBYpgf43/gCPIucQ13AhICiw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@uwdata/flechette": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@uwdata/flechette/-/flechette-2.3.0.tgz", + "integrity": "sha512-FdTqVEJZL4MwTv+vY1jOUMW2i5pr/G5S4BGdmQ/7wSOCtA0I74UP117kzXiKe1FrB5+ydM4tFxrCdF8Dq9WgNA==", + "license": "BSD-3-Clause" + }, + "node_modules/@uwdata/mosaic-core": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@uwdata/mosaic-core/-/mosaic-core-0.21.1.tgz", + "integrity": "sha512-nRh93+A7U/06x/6boSLUUaSCo4pkNAjOgV8P2Zl6ZZeF5pWynLcSqT30WLQPx+VewTvFgJGCymocymdbirFxYw==", + "license": "BSD-3-Clause", + "dependencies": { + "@duckdb/duckdb-wasm": "1.30.0", + "@uwdata/flechette": "^2.2.5", + "@uwdata/mosaic-sql": "^0.21.1" + } + }, + "node_modules/@uwdata/mosaic-inputs": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@uwdata/mosaic-inputs/-/mosaic-inputs-0.21.1.tgz", + "integrity": "sha512-9h/PFk71QL5+Nhqsai9pqVdnHUQb8OAemZrPossbfvb3q62o7RpU+jMwxcAId4uB/qRTAFxr0LgRfoPxrjfZYg==", + "license": "BSD-3-Clause", + "dependencies": { + "@uwdata/mosaic-core": "^0.21.1", + "@uwdata/mosaic-sql": "^0.21.1" + } + }, + "node_modules/@uwdata/mosaic-plot": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@uwdata/mosaic-plot/-/mosaic-plot-0.21.1.tgz", + "integrity": "sha512-ZPBD0Km44VIexZ7l88n1yWh8QpVJakJnmkyq6zr8aujw2i2+MkrcI8Abc2aPJ74o2YiSb9hMKxno83nB/Mfy7A==", + "license": "BSD-3-Clause", + "dependencies": { + "@observablehq/plot": "^0.6.17", + "@uwdata/mosaic-core": "^0.21.1", + "@uwdata/mosaic-sql": "^0.21.1", + "d3": "^7.9.0" + } + }, + "node_modules/@uwdata/mosaic-spec": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@uwdata/mosaic-spec/-/mosaic-spec-0.21.1.tgz", + "integrity": "sha512-DSc1Cgg5WdYaVzZIDE5mUlNtDEwus84iCfgLfEf3v69X31/8tD03y1Aii8K8sbjzqP5kkgOVV8GA419THn7N2w==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@uwdata/mosaic-core": "^0.21.1", + "@uwdata/mosaic-sql": "^0.21.1", + "@uwdata/vgplot": "^0.21.1", + "ts-json-schema-generator": "^2.4.0" + } + }, + "node_modules/@uwdata/mosaic-sql": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@uwdata/mosaic-sql/-/mosaic-sql-0.21.1.tgz", + "integrity": "sha512-2B4Dle4odyxIaBaDVRfQchebH/CUZvUV8kIwKF3V2GksQoF8KYY/Q6zTLTJhYrDEdUkt8M0OIy4U/Ntw93CV1A==", + "license": "BSD-3-Clause" + }, + "node_modules/@uwdata/vgplot": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@uwdata/vgplot/-/vgplot-0.21.1.tgz", + "integrity": "sha512-R+CFYeTPdNoMzMAxNwt7coTqcWWY6aOKUj28SqkQ4dbL3Ig37RjCnRDcpFzCqvxaSeOwZRXQ1Ld3WhnagqDh/Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@uwdata/mosaic-core": "^0.21.1", + "@uwdata/mosaic-inputs": "^0.21.1", + "@uwdata/mosaic-plot": "^0.21.1", + "@uwdata/mosaic-sql": "^0.21.1" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/apache-arrow": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz", + "integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^20.13.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^24.3.25", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.cjs" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.reduce": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz", + "integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "is-string": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/embedding-atlas": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/embedding-atlas/-/embedding-atlas-0.16.1.tgz", + "integrity": "sha512-ab4LsqiW+YMiFkf5LIv/e0fATGZh9AloScfRvTB39eOPp+MwS7ncOV3Xd4EdLS0RyYP3ZlBHR2tuGFYwN9yayg==", + "license": "MIT", + "peerDependencies": { + "@uwdata/mosaic-core": ">=0.19.0", + "@uwdata/mosaic-spec": ">=0.19.0", + "@uwdata/mosaic-sql": ">=0.19.0", + "@uwdata/vgplot": ">=0.19.0", + "react": ">=17.0.0", + "svelte": ">=5.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "svelte": { + "optional": true + } + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/flatbuffers": { + "version": "24.12.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz", + "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==", + "license": "Apache-2.0" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fp-ts": { + "version": "2.16.11", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", + "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", + "license": "MIT", + "peer": true + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/h264-mp4-encoder": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/h264-mp4-encoder/-/h264-mp4-encoder-1.0.12.tgz", + "integrity": "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ==", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/interval-tree-1d": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz", + "integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==", + "license": "MIT", + "dependencies": { + "binary-search-bounds": "^2.0.0" + } + }, + "node_modules/io-ts": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.22.tgz", + "integrity": "sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==", + "license": "MIT", + "peerDependencies": { + "fp-ts": "^2.5.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isoformat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz", + "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/molstar": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/molstar/-/molstar-4.18.0.tgz", + "integrity": "sha512-mU2da9laqdFtGKGCqOyFywCAxuvRYevOMFjrX/6RwIUd+HB5yOpbLXXRA5ErVadHXLTlEYOutCzNv+AwvmrfmA==", + "license": "MIT", + "dependencies": { + "@types/argparse": "^2.0.17", + "@types/benchmark": "^2.1.5", + "@types/compression": "1.8.1", + "@types/express": "^5.0.3", + "@types/node": "^18.19.111", + "@types/node-fetch": "^2.6.12", + "@types/swagger-ui-dist": "3.30.5", + "argparse": "^2.0.1", + "compression": "^1.8.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "h264-mp4-encoder": "^1.0.12", + "immer": "^10.1.1", + "immutable": "^5.1.2", + "io-ts": "^2.2.22", + "node-fetch": "^2.7.0", + "react-markdown": "^10.1.0", + "rxjs": "^7.8.2", + "swagger-ui-dist": "^5.24.0", + "tslib": "^2.8.1", + "util.promisify": "^1.1.3" + }, + "bin": { + "cif2bcif": "lib/commonjs/cli/cif2bcif/index.js", + "cifschema": "lib/commonjs/cli/cifschema/index.js", + "model-server": "lib/commonjs/servers/model/server.js", + "model-server-preprocess": "lib/commonjs/servers/model/preprocess.js", + "model-server-query": "lib/commonjs/servers/model/query.js", + "mvs-print-schema": "lib/commonjs/cli/mvs/mvs-print-schema.js", + "mvs-render": "lib/commonjs/cli/mvs/mvs-render.js", + "mvs-validate": "lib/commonjs/cli/mvs/mvs-validate.js", + "volume-server": "lib/commonjs/servers/volume/server.js", + "volume-server-pack": "lib/commonjs/servers/volume/pack.js", + "volume-server-query": "lib/commonjs/servers/volume/query.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@google-cloud/storage": "^7.14.0", + "canvas": "^2.11.2", + "gl": "^6.0.2", + "jpeg-js": "^0.4.4", + "pngjs": "^6.0.0", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@google-cloud/storage": { + "optional": true + }, + "canvas": { + "optional": true + }, + "gl": { + "optional": true + }, + "jpeg-js": { + "optional": true + }, + "pngjs": { + "optional": true + } + } + }, + "node_modules/molstar/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/molstar/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.9.tgz", + "integrity": "sha512-mt8YM6XwsTTovI+kdZdHSxoyF2DI59up034orlC9NfweclcWOt7CVascNNLp6U+bjFVCVCIh9PwS76tDM/rH8g==", + "license": "MIT", + "dependencies": { + "array.prototype.reduce": "^1.0.8", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "gopd": "^1.2.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-json-schema-generator": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.5.0.tgz", + "integrity": "sha512-sYY7AInozRbtj9OD3ynJJuMDWZ5lGxzxTevtmH3W9Hnd2J2szBC0HdPqSyuIirXnQ6g8KDJxS/HENoypUwBrlg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15", + "commander": "^14.0.2", + "json5": "^2.2.3", + "normalize-path": "^3.0.0", + "safe-stable-stringify": "^2.5.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3" + }, + "bin": { + "ts-json-schema-generator": "bin/ts-json-schema-generator.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ts-json-schema-generator/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util.promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.1.3.tgz", + "integrity": "sha512-GIEaZ6o86fj09Wtf0VfZ5XP7tmd4t3jM5aZCgmBi231D0DB1AEBa3Aa6MP48DMsAIi96WkpWLimIWVwOjbDMOw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "for-each": "^0.3.3", + "get-intrinsic": "^1.2.6", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "object.getownpropertydescriptors": "^2.1.8", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/package.json b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/package.json similarity index 58% rename from bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/package.json rename to bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/package.json index bfbf9aa488..24183304b5 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/package.json +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/package.json @@ -1,5 +1,5 @@ { - "name": "sae-steering-ui", + "name": "protein-dashboard", "version": "0.1.0", "private": true, "type": "module", @@ -9,6 +9,12 @@ "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", + "molstar": "^4.8.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/App.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/App.jsx new file mode 100644 index 0000000000..2488ba2870 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/App.jsx @@ -0,0 +1,1455 @@ +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' + +const styles = { + container: { + height: '100vh', + 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', + }, +} + +export default function App({ title = "SAE Feature Explorer", subtitle = "Explore sparse autoencoder features with UMAP embedding and crossfiltering" }) { + 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('mean_variant_1bcdwt') + 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('mean_variant_1bcdwt') // 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 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 => ({ + protein_id: row.protein_id, + 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 + } + }, []) + + // Easing functions + const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4) + const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2 + const easeInOutQuad = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2 + + // Smooth zoom-in with "fly-to" trajectory (zoom out -> pan -> zoom in) + const zoomToPoint = useCallback((x, y) => { + if (x == null || y == null) return + + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + + const start = currentViewportRef.current || initialViewportRef.current || { x: 0, y: 0, scale: 1 } + const targetScale = 4 // capped below max zoom of 5 + const duration = 800 + const startTime = performance.now() + + // Calculate how far we need to pan (in data space) + const panDistance = Math.sqrt(Math.pow(x - start.x, 2) + Math.pow(y - start.y, 2)) + + // Determine the "cruise altitude" - how much to zoom out during the pan + // Zoom out more for longer distances, less for short distances + const minScale = Math.min(start.scale, 0.8) // Never zoom out below 0.8 + const maxZoomOut = Math.max(0, start.scale - minScale) + const zoomOutAmount = Math.min(maxZoomOut, panDistance * 0.1) // Scale zoom-out with distance + const cruiseScale = start.scale - zoomOutAmount + + const animate = (currentTime) => { + const elapsed = currentTime - startTime + const t = Math.min(elapsed / duration, 1) + + // Use smooth ease-in-out for the overall progress + const smoothT = easeInOutCubic(t) + + // Pan follows the smooth progress + const panT = smoothT + + // Zoom follows a "U-shaped" profile: + // - First half: ease from start.scale down to cruiseScale (or stay flat if already low) + // - Second half: ease from cruiseScale up to targetScale + let zoomScale + if (t < 0.4) { + // First 40%: zoom out slightly (ease-out) + const zoomOutT = t / 0.4 + const easeOut = 1 - Math.pow(1 - zoomOutT, 2) + zoomScale = start.scale + (cruiseScale - start.scale) * easeOut + } else if (t < 0.6) { + // Middle 20%: hold at cruise altitude + zoomScale = cruiseScale + } else { + // Last 40%: zoom in to target (ease-in then ease-out) + const zoomInT = (t - 0.6) / 0.4 + const easeInOut = easeInOutQuad(zoomInT) + zoomScale = cruiseScale + (targetScale - cruiseScale) * easeInOut + } + + const newViewport = { + x: start.x + (x - start.x) * panT, + y: start.y + (y - start.y) * panT, + scale: zoomScale + } + + setViewportState(newViewport) + + if (t < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + currentViewportRef.current = { x, y, scale: targetScale } + animationRef.current = null + } + } + + animationRef.current = requestAnimationFrame(animate) + }, []) + + // Smooth zoom-out: zoom out first, then pan back + const resetViewport = useCallback(() => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + + const start = currentViewportRef.current || { x: 0, y: 0, scale: 1 } + const target = initialViewportRef.current || { x: 0, y: 0, scale: 1 } + const duration = 600 + const startTime = performance.now() + + const animate = (currentTime) => { + const elapsed = currentTime - startTime + const t = Math.min(elapsed / duration, 1) + + // Zoom out fast at start (ease-out) + const zoomT = easeOutQuart(t) + + // Pan eases in-out + const panT = easeInOutCubic(t) + + const newViewport = { + x: start.x + (target.x - start.x) * panT, + y: start.y + (target.y - start.y) * panT, + scale: start.scale + (target.scale - start.scale) * zoomT + } + + setViewportState(newViewport) + + if (t < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + currentViewportRef.current = { ...target } + animationRef.current = null + } + } + + animationRef.current = requestAnimationFrame(animate) + }, []) + + // 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 + + + await vg.coordinator().exec(` + CREATE TABLE features AS + SELECT * FROM read_parquet('${parquetUrl}') + `) + + // 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'].includes(col.name)) continue + + if (col.type === 'VARCHAR') { + 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: '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', + '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) + + // 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) + const featuresResult = await vg.coordinator().query(` + SELECT + feature_id, + label, + activation_freq, + max_activation, + x, + y + FROM features + ORDER BY feature_id + `) + const loadedFeatures = featuresResult.toArray().map(row => ({ + 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, + })) + 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) + // 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]) + + // 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) + ) + } + + // 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 ( +
+
+
+

CodonFM Sparse AutoEncoder

+
+
+ + +
+
+ +
+
+
+
+ + Decoder UMAP + +
+ + + 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 + +
+
+
+ {mosaicReady && ( + + )} + {selectedCategory && selectedCategory !== 'none' && (() => { + const colInfo = categoryColumns.find(c => c.name === selectedCategory) + if (!colInfo || colInfo.type !== 'sequential') return null + 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, ' ')} + +
+ ) + })()} +
+
+ +
+ {[ + { value: histMetric1, setter: setHistMetric1 }, + { value: histMetric2, setter: setHistMetric2 }, + { value: histMetric3, setter: setHistMetric3 }, + ].map(({ value, setter }, i) => ( +
+
+ +
+ {mosaicReady && value && value !== 'none' && ( + + )} +
+ ))} +
+
+ +
+
+ + + +
+ +
+ + 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 +
+ + +
+
+ + {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/codonfm/codon_dashboard/src/EmbeddingView.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/EmbeddingView.jsx new file mode 100644 index 0000000000..ecb9ee0e02 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/EmbeddingView.jsx @@ -0,0 +1,322 @@ +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 }) { + 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 + let colors = Array(50).fill(DEFAULT_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)) + } 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]) + + // 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/codonfm/codon_dashboard/src/FeatureCard.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureCard.jsx new file mode 100644 index 0000000000..a432993bbf --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureCard.jsx @@ -0,0 +1,752 @@ +import React, { useState, useEffect, useRef, forwardRef } from 'react' +import ProteinSequence, { computeAlignInfo } from './ProteinSequence' +import FeatureDetailPage from './FeatureDetailPage' +import { getAccession, uniprotUrl } 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, vocabLogits, featureAnalysis }, 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 [hoveredCodon, setHoveredCodon] = useState(null) + 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 highScoreFrac = feature.high_score_fraction + const variantDelta = feature.mean_variant_delta + const siteDelta = feature.mean_site_delta + const localDelta = feature.mean_local_delta + const clinvarFrac = feature.clinvar_fraction + const phylop = feature.mean_phylop + const gcMean = feature.gc_mean + const trinucEntropy = feature.trinuc_entropy + const geneEntropy = feature.gene_entropy + const geneNUnique = feature.gene_n_unique + 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('') + + // Vocab logits section + const logits = vocabLogits?.[String(feature.feature_id)] + if (logits) { + lines.push('=== TOP PROMOTED CODONS ===') + lines.push('Codon,Amino Acid,Logit Value') + 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', + } + ;(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('') + } + + // Codon annotations section + const analysis = featureAnalysis?.[String(feature.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 Enriched,Yes`) + } + if (ann.position) { + lines.push(`Position,${ann.position.label}`) + } + lines.push('') + } + + // Examples section + if (examples && examples.length > 0) { + lines.push('=== ACTIVATION EXAMPLES ===') + lines.push('Rank,Protein ID,Max Activation,Sequence') + examples.forEach((ex, i) => { + lines.push(`${i + 1},${ex.protein_id || ''},${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, + }} + /> + + +
+ ) : ( +
+
{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)} +
+ {highScoreFrac != null && !isNaN(highScoreFrac) && ( +
+ Hi-Score + 0.6 ? '#d32f2f' : highScoreFrac < 0.4 ? '#388e3c' : '#666' }}> + {(highScoreFrac * 100).toFixed(0)}% + +
+ )} + {variantDelta != null && !isNaN(variantDelta) && ( +
+ Δ Var + 0.5 ? '#1565c0' : '#666' }}> + {variantDelta > 0 ? '+' : ''}{variantDelta.toFixed(2)} + +
+ )} + {siteDelta != null && !isNaN(siteDelta) && ( +
+ Δ Site + 0.5 ? '#7b1fa2' : '#666' }}> + {siteDelta > 0 ? '+' : ''}{siteDelta.toFixed(2)} + +
+ )} + {localDelta != null && !isNaN(localDelta) && ( +
+ Δ Local + 0.5 ? '#00695c' : '#666' }}> + {localDelta > 0 ? '+' : ''}{localDelta.toFixed(2)} + +
+ )} + {clinvarFrac != null && !isNaN(clinvarFrac) && ( +
+ ClinVar + {(clinvarFrac * 100).toFixed(0)}% +
+ )} + {phylop != null && !isNaN(phylop) && ( +
+ PhyloP + {phylop.toFixed(1)} +
+ )} + {gcMean != null && !isNaN(gcMean) && ( +
+ GC + 0.1 ? '#e65100' : '#666' }}> + {(gcMean * 100).toFixed(0)}% + +
+ )} + {trinucEntropy != null && !isNaN(trinucEntropy) && ( +
+ Trinuc H + + {trinucEntropy.toFixed(1)} + +
+ )} + {geneNUnique != null && geneNUnique > 0 && ( +
+ Genes + + {geneNUnique} + +
+ )} + {expanded ? '▼' : '▶'} +
+
+ + {/* Details and export buttons - shown when expanded */} + {expanded && ( +
+ + +
+ )} + + {expanded && ( +
+ {/* Vocabulary logits - all codons grouped by amino acid */} + {vocabLogits && vocabLogits[String(feature.feature_id)] && (() => { + const logits = vocabLogits[String(feature.feature_id)] + const CODON_AA = { + 'TTT':'F','TTC':'F','TTA':'L','TTG':'L','CTT':'L','CTC':'L','CTA':'L','CTG':'L', + 'ATT':'I','ATC':'I','ATA':'I','ATG':'M','GTT':'V','GTC':'V','GTA':'V','GTG':'V', + 'TCT':'S','TCC':'S','TCA':'S','TCG':'S','CCT':'P','CCC':'P','CCA':'P','CCG':'P', + 'ACT':'T','ACC':'T','ACA':'T','ACG':'T','GCT':'A','GCC':'A','GCA':'A','GCG':'A', + 'TAT':'Y','TAC':'Y','TAA':'*','TAG':'*','CAT':'H','CAC':'H','CAA':'Q','CAG':'Q', + 'AAT':'N','AAC':'N','AAA':'K','AAG':'K','GAT':'D','GAC':'D','GAA':'E','GAG':'E', + 'TGT':'C','TGC':'C','TGA':'*','TGG':'W','CGT':'R','CGC':'R','CGA':'R','CGG':'R', + 'AGT':'S','AGC':'S','AGA':'R','AGG':'R','GGT':'G','GGC':'G','GGA':'G','GGG':'G', + } + // Build codon logit map from all entries, excluding stop codons + const codonLogitMap = {} + for (const [codon, val] of (logits.top_positive || [])) { + if (CODON_AA[codon] !== '*') codonLogitMap[codon] = val + } + for (const [codon, val] of (logits.top_negative || [])) { + if (CODON_AA[codon] !== '*') codonLogitMap[codon] = val + } + const maxAbs = Math.max(...Object.values(codonLogitMap).map(Math.abs), 0.001) + // Group by AA, excluding stop codons + const aaGroups = {} + for (const [codon, aa] of Object.entries(CODON_AA)) { + if (aa === '*') continue + if (!aaGroups[aa]) aaGroups[aa] = [] + aaGroups[aa].push(codon) + } + const aaOrder = Object.keys(aaGroups).sort() + return ( +
+
Decoder Logits
+
+ {hoveredCodon && (() => { + const val = codonLogitMap[hoveredCodon] || 0 + const aa = CODON_AA[hoveredCodon] + return ( +
+ {hoveredCodon} ({aa}): {val.toFixed(3)} +
+ ) + })()} +
+ {aaOrder.map(aa => { + const codons = aaGroups[aa] || [] + return ( +
+
{aa}
+
+ {codons.sort().map(codon => { + const val = codonLogitMap[codon] || 0 + const h = Math.max(1, (Math.abs(val) / maxAbs) * 24) + const isHovered = hoveredCodon === codon + const barColor = val === 0 ? '#ccc' : val > 0 ? '#76b900' : '#e57373' + return ( +
setHoveredCodon(codon)} + onMouseLeave={() => setHoveredCodon(null)} + > +
+
+ ) + })} +
+
+ ) + })} +
+
+
+ ) + })()} + + {/* Analysis summary tags */} + {featureAnalysis && featureAnalysis[String(feature.feature_id)] && (() => { + const analysis = featureAnalysis[String(feature.feature_id)] + const tags = [] + const ann = analysis.codon_annotations || {} + + if (ann.amino_acid) tags.push({ label: `AA: ${ann.amino_acid.aa} (${(ann.amino_acid.fraction * 100).toFixed(0)}%)`, color: '#e3f2fd' }) + if (ann.codon_usage) tags.push({ label: `${ann.codon_usage.bias} codons`, color: '#fff3e0' }) + if (ann.wobble) tags.push({ label: `wobble ${ann.wobble.preference}`, color: '#f3e5f5' }) + if (ann.cpg) tags.push({ label: `CpG enriched`, color: '#fce4ec' }) + if (ann.position) tags.push({ label: `N-terminal`, color: '#e8f5e9' }) + + if (tags.length === 0) return null + return ( +
+ {tags.map((t, i) => ( + {t.label} + ))} +
+ ) + })()} + + {/* Sequence examples */} +
+
Top Activating Sequences
+
+ Align by: + {['start', 'first_activation', 'max_activation'].map(mode => ( + + ))} +
+
+ {loadingExamples ? ( +
+ Loading examples... +
+ ) : examples.length > 0 ? ( + <> + {(() => { + const visibleExamples = examples.slice(0, 6) + const { anchor: alignAnchor, totalLength } = computeAlignInfo(visibleExamples, alignMode) + return visibleExamples.map((ex, i) => ( +
+
+ + {ex.protein_id} + e.stopPropagation()} + title="View on UniProt" + > + ↗ + + {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/codonfm/codon_dashboard/src/FeatureDetailPage.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureDetailPage.jsx new file mode 100644 index 0000000000..695b9181ab --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureDetailPage.jsx @@ -0,0 +1,442 @@ +import React, { useState, useEffect, useRef } from 'react' +import ProteinSequence, { computeAlignInfo } from './ProteinSequence' +import { getAccession, uniprotUrl, codonToAA } from './utils' + +// ── Codon-to-AA lookup for logit chart ────────────────────────────── +const CODON_AA = { + 'TTT':'F','TTC':'F','TTA':'L','TTG':'L','CTT':'L','CTC':'L','CTA':'L','CTG':'L', + 'ATT':'I','ATC':'I','ATA':'I','ATG':'M','GTT':'V','GTC':'V','GTA':'V','GTG':'V', + 'TCT':'S','TCC':'S','TCA':'S','TCG':'S','CCT':'P','CCC':'P','CCA':'P','CCG':'P', + 'ACT':'T','ACC':'T','ACA':'T','ACG':'T','GCT':'A','GCC':'A','GCA':'A','GCG':'A', + 'TAT':'Y','TAC':'Y','TAA':'*','TAG':'*','CAT':'H','CAC':'H','CAA':'Q','CAG':'Q', + 'AAT':'N','AAC':'N','AAA':'K','AAG':'K','GAT':'D','GAC':'D','GAA':'E','GAG':'E', + 'TGT':'C','TGC':'C','TGA':'*','TGG':'W','CGT':'R','CGC':'R','CGA':'R','CGG':'R', + 'AGT':'S','AGC':'S','AGA':'R','AGG':'R','GGT':'G','GGC':'G','GGA':'G','GGG':'G', +} + +// Group codons by amino acid, excluding stop codons +const AA_GROUPS = {} +for (const [codon, aa] of Object.entries(CODON_AA)) { + if (aa === '*') continue + if (!AA_GROUPS[aa]) AA_GROUPS[aa] = [] + AA_GROUPS[aa].push(codon) +} +const AA_ORDER = Object.keys(AA_GROUPS).sort() + +// Color palette for amino acid groups +const AA_COLORS = { + // Nonpolar + 'G': '#e8e8e8', 'A': '#c8c8c8', 'V': '#b0b0b0', 'L': '#a0a0a0', 'I': '#909090', + 'P': '#d0d0a0', 'F': '#c0b0a0', 'W': '#b0a090', 'M': '#a09080', + // Polar + 'S': '#b0d0ff', 'T': '#a0c0f0', 'C': '#90b0e0', 'Y': '#80a0d0', + 'N': '#a0d0b0', 'Q': '#90c0a0', + // Charged + 'D': '#ffb0b0', 'E': '#ffa0a0', 'K': '#b0b0ff', 'R': '#a0a0ff', 'H': '#c0b0ff', +} + +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)', + }, + subtitle: { + fontSize: '11px', + color: 'var(--text-secondary)', + }, + 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', + }, + sectionSubtitle: { + fontSize: '9px', + color: 'var(--text-muted)', + marginBottom: '6px', + }, + statsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(4, 1fr)', + gap: '8px', + }, + statCard: { + background: 'var(--bg-card-expanded)', + borderRadius: '4px', + padding: '6px 8px', + textAlign: 'center', + }, + statNumber: { + fontSize: '14px', + fontWeight: '700', + color: 'var(--text-heading)', + }, + statLabel: { + fontSize: '8px', + color: 'var(--text-tertiary)', + textTransform: 'uppercase', + marginTop: '2px', + }, + tag: { + display: 'inline-block', + padding: '3px 8px', + borderRadius: '4px', + fontSize: '11px', + fontWeight: '500', + marginRight: '6px', + marginBottom: '4px', + }, + 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', + }, +} + + +// ── Vocab Logit Chart Component ───────────────────────────────────── + +function VocabLogitChart({ logits }) { + if (!logits) return null + + // Build codon logit map, excluding stop codons (universally suppressed, not informative) + const codonLogitMap = {} + for (const [codon, val] of logits.top_positive) { + if (CODON_AA[codon] !== '*') codonLogitMap[codon] = val + } + for (const [codon, val] of logits.top_negative) { + if (CODON_AA[codon] !== '*') codonLogitMap[codon] = val + } + + const maxAbs = Math.max(...Object.values(codonLogitMap).map(Math.abs), 0.001) + + return ( +
+
+ {AA_ORDER.map(aa => { + const codons = AA_GROUPS[aa] || [] + const aaColor = AA_COLORS[aa] || 'var(--border-light)' + return ( +
+
+ {aa} +
+
+ {codons.sort().map(codon => { + const val = codonLogitMap[codon] || 0 + const h = Math.max(2, (Math.abs(val) / maxAbs) * 34) + const isPos = val > 0 + return ( +
+
+ + {codon} + +
+ ) + })} +
+
+ ) + })} +
+
+ Promoted + Suppressed +
+
+ ) +} + + +// ── Codon Annotations Component ───────────────────────────────────── + +function CodonAnnotations({ annotations }) { + if (!annotations || Object.keys(annotations).length === 0) { + return
No significant annotations detected
+ } + + const items = [] + + if (annotations.amino_acid) { + items.push({ + label: 'Amino Acid', + value: `${annotations.amino_acid.aa} (${(annotations.amino_acid.fraction * 100).toFixed(0)}% of activations)`, + color: '#e3f2fd', + }) + } + if (annotations.codon_usage) { + items.push({ + label: 'Codon Usage', + value: `Prefers ${annotations.codon_usage.bias} codons (${(annotations.codon_usage.fraction * 100).toFixed(0)}%)`, + color: '#fff3e0', + }) + } + if (annotations.wobble) { + items.push({ + label: 'Wobble Position', + value: `${annotations.wobble.preference} preference (${(annotations.wobble.fraction * 100).toFixed(0)}%)`, + color: '#f3e5f5', + }) + } + if (annotations.cpg) { + items.push({ + label: 'CpG Context', + value: `Enriched (${(annotations.cpg.enrichment * 100).toFixed(0)}% of activations at CpG boundaries)`, + color: '#fce4ec', + }) + } + if (annotations.position) { + items.push({ + label: 'Gene Position', + value: `N-terminal enriched (${annotations.position.enrichment.toFixed(1)}x over expected)`, + color: '#e8f5e9', + }) + } + + return ( +
+ {items.map((item, i) => ( +
+ + {item.label}: + {' '} + {item.value} +
+ ))} +
+ ) +} + + +// ── Feature Metrics Component ─────────────────────────────────────── + +function FeatureMetrics({ feature }) { + const metrics = [ + { key: 'mean_variant_1bcdwt', label: 'Mean Variant (1B CDWT)' }, + { key: 'high_score_fraction', label: 'High Score Fraction' }, + { key: 'clinvar_fraction', label: 'ClinVar Fraction' }, + { key: 'mean_phylop', label: 'Mean PhyloP' }, + { key: 'mean_variant_delta', label: 'Mean Variant Delta' }, + { key: 'mean_site_delta', label: 'Mean Site Delta' }, + { key: 'mean_local_delta', label: 'Mean Local Delta' }, + { key: 'gc_mean', label: 'GC Mean' }, + { key: 'gc_std', label: 'GC Std' }, + { key: 'trinuc_entropy', label: 'Trinuc Entropy' }, + { key: 'gene_entropy', label: 'Gene Entropy' }, + { key: 'gene_n_unique', label: 'Gene N Unique' }, + ] + + const available = metrics.filter(m => feature[m.key] != null && !isNaN(feature[m.key])) + if (available.length === 0) return null + + return ( +
+ {available.map(m => ( +
+
+ {typeof feature[m.key] === 'number' + ? Math.abs(feature[m.key]) >= 100 ? feature[m.key].toFixed(0) + : Math.abs(feature[m.key]) >= 1 ? feature[m.key].toFixed(2) + : feature[m.key].toFixed(4) + : feature[m.key]} +
+
{m.label}
+
+ ))} +
+ ) +} + + +// ── Main Detail Page ──────────────────────────────────────────────── + +export default function FeatureDetailPage({ feature, examples, vocabLogits, featureAnalysis, onClose }) { + const [alignMode, setAlignMode] = useState('max_activation') + const scrollGroupRef = useRef(null) + + const fid = String(feature.feature_id) + const logits = vocabLogits ? vocabLogits[fid] : null + const analysis = featureAnalysis ? featureAnalysis[fid] : null + + const freq = feature.activation_freq || 0 + const maxAct = feature.max_activation || 0 + const description = feature.description || `Feature ${feature.feature_id}` + + // Close on Escape + useEffect(() => { + const handleKey = (e) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onClose]) + + const visibleExamples = (examples || []).slice(0, 6) + const { anchor: alignAnchor, totalLength } = computeAlignInfo(visibleExamples, alignMode) + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+ + {/* Header + stats in one row */} +
+
+
Feature #{feature.feature_id} {description}
+
+
+
+ freq: {(freq * 100).toFixed(1)}% + max: {maxAct.toFixed(1)} +
+ +
+
+ + {/* Feature Metrics from color-by columns */} +
+
Feature Metrics
+
+ Variant analysis and sequence composition metrics for this feature. +
+ +
+ + {/* Vocabulary Logits */} +
+
Decoder Logits — Promoted / Suppressed Codons
+
+ Projection of this feature's decoder weight through the Encodon LM head, with the mean logit vector subtracted. + This mean-centering removes the model's shared baseline bias (e.g. toward common codons like GCC), so values reflect what this feature specifically promotes (green) or suppresses (red) relative to the average feature. + Stop codons (TAA/TAG/TGA) are excluded — they are uniformly suppressed across all features since the model was trained on coding sequences. +
+ +
+ + {/* Codon Annotations */} +
+
Codon-Level Annotations
+
+ Computed per-codon properties correlated with this feature's activations. +
+ +
+ + {/* Top Activating Sequences */} +
+
+
Top Activating Sequences
+
+ {['start', 'first_activation', 'max_activation'].map(mode => ( + + ))} +
+
+ + {visibleExamples.length > 0 ? ( + <> + {visibleExamples.map((ex, i) => ( +
+
+ + {ex.protein_id} + + UniProt + + + max: {ex.max_activation?.toFixed(3)} +
+ +
+ ))} + + ) : ( +
No examples loaded
+ )} +
+ +
+
+ ) +} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureList.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureList.jsx new file mode 100644 index 0000000000..26cd6c2457 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/FeatureList.jsx @@ -0,0 +1,83 @@ +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, +}) { + 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 && ( +
+ Scroll to load more... ({visibleFeatures.length} of {filteredFeatures.length}) +
+ )} + {filteredFeatures.length === 0 && clickedFeatureId == null && ( +
+ No features match your selection. +
+ )} +
+ ) +} + +export default memo(FeatureListComponent) diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/Histogram.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/Histogram.jsx new file mode 100644 index 0000000000..553330862d --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/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/codonfm/codon_dashboard/src/InfoButton.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/InfoButton.jsx new file mode 100644 index 0000000000..40184d0ef6 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/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/codonfm/codon_dashboard/src/MolstarThumbnail.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/MolstarThumbnail.jsx new file mode 100644 index 0000000000..4a694d2ec7 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/MolstarThumbnail.jsx @@ -0,0 +1,288 @@ +import React, { useEffect, useRef, useState } from 'react' +import { getAccession } from './utils' + +const styles = { + container: { + background: '#fafafa', + borderRadius: '6px', + border: '1px solid #eee', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + position: 'relative', + }, + viewer: { + position: 'relative', + height: '180px', + width: '100%', + }, + expandBtn: { + position: 'absolute', + top: '6px', + right: '6px', + zIndex: 20, + background: 'rgba(255,255,255,0.85)', + border: '1px solid #ddd', + borderRadius: '4px', + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + fontSize: '12px', + color: '#555', + opacity: 0, + transition: 'opacity 0.15s', + pointerEvents: 'auto', + }, + label: { + padding: '4px 8px', + fontSize: '10px', + fontFamily: 'monospace', + color: '#555', + borderTop: '1px solid #eee', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + proteinId: { + fontWeight: '600', + color: '#2563eb', + }, + activation: { + color: '#999', + }, + loading: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: '#f9f9f9', + zIndex: 10, + pointerEvents: 'none', + fontSize: '10px', + color: '#aaa', + }, + error: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: '#f9f9f9', + zIndex: 10, + fontSize: '10px', + color: '#e57373', + }, +} + +export default function MolstarThumbnail({ proteinId, sequence, activations, maxActivation, onExpand }) { + const wrapperRef = useRef(null) + const molContainerRef = useRef(null) + const pluginRef = useRef(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [hovered, setHovered] = useState(false) + + const uniprotId = getAccession(proteinId) + + // Trim activations to sequence length + const acts = activations ? activations.slice(0, (sequence || '').length) : [] + + useEffect(() => { + let disposed = false + + async function init() { + if (!wrapperRef.current) return + setLoading(true) + setError(null) + + // Clean up previous Mol* container + if (molContainerRef.current) { + try { + wrapperRef.current.removeChild(molContainerRef.current) + } catch { /* already removed */ } + molContainerRef.current = null + } + + // Create imperative container (outside React's DOM tree) + const molDiv = document.createElement('div') + molDiv.style.width = '100%' + molDiv.style.height = '100%' + molDiv.style.position = 'relative' + wrapperRef.current.appendChild(molDiv) + molContainerRef.current = molDiv + + try { + const molPlugin = await import('molstar/lib/mol-plugin/context') + const molSpec = await import('molstar/lib/mol-plugin/spec') + const molConfig = await import('molstar/lib/mol-plugin/config') + const molColor = await import('molstar/lib/mol-util/color') + + if (disposed) return + + const spec = molSpec.DefaultPluginSpec() + spec.config = spec.config || [] + spec.config.push([molConfig.PluginConfig.Viewport.ShowExpand, false]) + spec.config.push([molConfig.PluginConfig.Viewport.ShowControls, false]) + spec.config.push([molConfig.PluginConfig.Viewport.ShowSelectionMode, false]) + spec.config.push([molConfig.PluginConfig.Viewport.ShowAnimation, false]) + + const plugin = new molPlugin.PluginContext(spec) + await plugin.init() + + if (disposed) { + plugin.dispose() + return + } + + const canvas = document.createElement('canvas') + canvas.style.width = '100%' + canvas.style.height = '100%' + molDiv.appendChild(canvas) + + try { + plugin.initViewer(canvas, molDiv) + } catch { /* fallback for different Mol* versions */ } + + pluginRef.current = plugin + + // Custom activation color theme + const themeName = `activation-thumb-${proteinId}` + const Color = molColor.Color + + const colorFn = (location) => { + try { + if (location && location.unit && location.element !== undefined) { + const unit = location.unit + if (unit.model && unit.model.atomicHierarchy) { + const { residueAtomSegments, residues } = unit.model.atomicHierarchy + const rI = residueAtomSegments.index[location.element] + const seqId = residues.auth_seq_id.value(rI) + const idx = seqId - 1 + if (idx >= 0 && idx < acts.length) { + const act = acts[idx] + const n = maxActivation > 0 ? Math.min(act / maxActivation, 1) : 0 + const r = Math.round(255 - n * 137) // 255 -> 118 + const g = Math.round(255 - n * 70) // 255 -> 185 + const b = Math.round(255 * (1 - n)) // 255 -> 0 + return Color.fromRgb(r, g, b) + } + } + } + } catch { /* fallback */ } + return Color.fromRgb(200, 200, 200) + } + + const colorThemeProvider = { + name: themeName, + label: themeName, + category: 'Custom', + factory: (_ctx, props) => ({ + factory: colorThemeProvider, + granularity: 'group', + props, + description: '', + color: colorFn, + legend: undefined, + }), + getParams: () => ({}), + defaultValues: {}, + isApplicable: () => true, + } + + plugin.representation.structure.themes.colorThemeRegistry.add(colorThemeProvider) + + // Load AlphaFold CIF with version fallback + let data = null + for (const version of [6, 4, 3]) { + const cifUrl = `https://alphafold.ebi.ac.uk/files/AF-${uniprotId}-F1-model_v${version}.cif` + try { + data = await plugin.builders.data.download( + { url: cifUrl, isBinary: false }, + { state: { isGhost: true } }, + ) + break + } catch { /* try next version */ } + } + + if (!data) { + throw new Error('Structure not found') + } + + const trajectory = await plugin.builders.structure.parseTrajectory(data, 'mmcif') + await plugin.builders.structure.hierarchy.applyPreset(trajectory, 'default') + + // Apply activation color theme + const structures = plugin.managers.structure.hierarchy.current.structures + for (const s of structures) { + for (const c of s.components) { + await plugin.managers.structure.component.updateRepresentationsTheme( + [c], + { color: themeName }, + ) + } + } + + if (disposed) { + plugin.dispose() + return + } + setLoading(false) + } catch (err) { + if (!disposed) { + setError(err instanceof Error ? err.message : 'Failed to load') + setLoading(false) + } + } + } + + init() + + return () => { + disposed = true + if (pluginRef.current) { + pluginRef.current.dispose() + pluginRef.current = null + } + if (molContainerRef.current && wrapperRef.current) { + try { + wrapperRef.current.removeChild(molContainerRef.current) + } catch { /* already removed */ } + molContainerRef.current = null + } + } + }, [proteinId, uniprotId]) + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {onExpand && ( +
{ e.stopPropagation(); onExpand() }} + title="View detail" + > + ↗ +
+ )} +
+ {loading && ( +
Loading...
+ )} + {error && ( +
No structure
+ )} +
+
+ {proteinId} + max: {(maxActivation || 0).toFixed(3)} +
+
+ ) +} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/ProteinDetailModal.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/ProteinDetailModal.jsx new file mode 100644 index 0000000000..cb8ff7ede6 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/ProteinDetailModal.jsx @@ -0,0 +1,372 @@ +import React, { useEffect, useRef, useState } from 'react' +import ReactDOM from 'react-dom' +import ProteinSequence from './ProteinSequence' +import { getAccession, uniprotUrl } 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: '1200px', + height: '80vh', + maxHeight: '800px', + display: 'flex', + 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', + }, + leftPanel: { + flex: '0 0 60%', + position: 'relative', + background: '#f5f5f5', + borderRight: '1px solid #eee', + }, + viewer: { + width: '100%', + height: '100%', + position: 'relative', + }, + viewerLoading: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#aaa', + fontSize: '13px', + }, + viewerError: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#e57373', + fontSize: '13px', + }, + rightPanel: { + flex: 1, + padding: '24px', + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '20px', + }, + header: { + display: 'flex', + alignItems: 'center', + gap: '8px', + flexWrap: 'wrap', + }, + proteinId: { + fontSize: '18px', + fontWeight: '700', + fontFamily: 'monospace', + color: '#222', + }, + uniprotBtn: { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '4px 10px', + fontSize: '12px', + color: '#2563eb', + background: '#eff6ff', + border: '1px solid #bfdbfe', + borderRadius: '6px', + textDecoration: 'none', + fontWeight: '500', + }, + statsRow: { + display: 'flex', + gap: '20px', + }, + 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 ProteinDetailModal({ protein, onClose }) { + const wrapperRef = useRef(null) + const molContainerRef = useRef(null) + const pluginRef = useRef(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const accession = getAccession(protein.protein_id) + const acts = protein.activations ? protein.activations.slice(0, (protein.sequence || '').length) : [] + + // Close on ESC + useEffect(() => { + const handleKey = (e) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onClose]) + + // Init Mol* viewer + useEffect(() => { + let disposed = false + + async function init() { + if (!wrapperRef.current) return + setLoading(true) + setError(null) + + if (molContainerRef.current) { + try { wrapperRef.current.removeChild(molContainerRef.current) } catch {} + molContainerRef.current = null + } + + const molDiv = document.createElement('div') + molDiv.style.width = '100%' + molDiv.style.height = '100%' + molDiv.style.position = 'relative' + wrapperRef.current.appendChild(molDiv) + molContainerRef.current = molDiv + + try { + const molPlugin = await import('molstar/lib/mol-plugin/context') + const molSpec = await import('molstar/lib/mol-plugin/spec') + const molConfig = await import('molstar/lib/mol-plugin/config') + const molColor = await import('molstar/lib/mol-util/color') + + if (disposed) return + + const spec = molSpec.DefaultPluginSpec() + spec.config = spec.config || [] + spec.config.push([molConfig.PluginConfig.Viewport.ShowExpand, false]) + spec.config.push([molConfig.PluginConfig.Viewport.ShowControls, true]) + spec.config.push([molConfig.PluginConfig.Viewport.ShowSelectionMode, false]) + spec.config.push([molConfig.PluginConfig.Viewport.ShowAnimation, false]) + + const plugin = new molPlugin.PluginContext(spec) + await plugin.init() + + if (disposed) { plugin.dispose(); return } + + const canvas = document.createElement('canvas') + canvas.style.width = '100%' + canvas.style.height = '100%' + molDiv.appendChild(canvas) + + try { plugin.initViewer(canvas, molDiv) } catch {} + + pluginRef.current = plugin + + // Custom activation color theme + const themeName = `activation-detail-${protein.protein_id}` + const Color = molColor.Color + const maxAct = protein.max_activation || 0 + + const colorFn = (location) => { + try { + if (location && location.unit && location.element !== undefined) { + const unit = location.unit + if (unit.model && unit.model.atomicHierarchy) { + const { residueAtomSegments, residues } = unit.model.atomicHierarchy + const rI = residueAtomSegments.index[location.element] + const seqId = residues.auth_seq_id.value(rI) + const idx = seqId - 1 + if (idx >= 0 && idx < acts.length) { + const act = acts[idx] + const n = maxAct > 0 ? Math.min(act / maxAct, 1) : 0 + const r = Math.round(255 - n * 137) // 255 -> 118 + const g = Math.round(255 - n * 70) // 255 -> 185 + const b = Math.round(255 * (1 - n)) // 255 -> 0 + return Color.fromRgb(r, g, b) + } + } + } + } catch {} + return Color.fromRgb(200, 200, 200) + } + + const colorThemeProvider = { + name: themeName, + label: themeName, + category: 'Custom', + factory: (_ctx, props) => ({ + factory: colorThemeProvider, + granularity: 'group', + props, + description: '', + color: colorFn, + legend: undefined, + }), + getParams: () => ({}), + defaultValues: {}, + isApplicable: () => true, + } + + plugin.representation.structure.themes.colorThemeRegistry.add(colorThemeProvider) + + // Load AlphaFold CIF with version fallback + let data = null + for (const version of [6, 4, 3]) { + const cifUrl = `https://alphafold.ebi.ac.uk/files/AF-${accession}-F1-model_v${version}.cif` + try { + data = await plugin.builders.data.download( + { url: cifUrl, isBinary: false }, + { state: { isGhost: true } }, + ) + break + } catch {} + } + + if (!data) throw new Error('Structure not found') + + const trajectory = await plugin.builders.structure.parseTrajectory(data, 'mmcif') + await plugin.builders.structure.hierarchy.applyPreset(trajectory, 'default') + + const structures = plugin.managers.structure.hierarchy.current.structures + for (const s of structures) { + for (const c of s.components) { + await plugin.managers.structure.component.updateRepresentationsTheme( + [c], + { color: themeName }, + ) + } + } + + if (disposed) { plugin.dispose(); return } + setLoading(false) + } catch (err) { + if (!disposed) { + setError(err instanceof Error ? err.message : 'Failed to load') + setLoading(false) + } + } + } + + init() + + return () => { + disposed = true + if (pluginRef.current) { + pluginRef.current.dispose() + pluginRef.current = null + } + if (molContainerRef.current && wrapperRef.current) { + try { wrapperRef.current.removeChild(molContainerRef.current) } catch {} + molContainerRef.current = null + } + } + }, [protein.protein_id, accession]) + + const modal = ( +
+
e.stopPropagation()}> +
x
+ + {/* Left: Mol* viewer */} +
+
+ {loading &&
Loading structure...
} + {error &&
{error}
} +
+
+ + {/* Right: Protein info */} +
+
+ {protein.protein_id} + + UniProt ↗ + +
+ +
+
+
Max Activation
+
{(protein.max_activation || 0).toFixed(4)}
+
+
+
Sequence Length
+
{(protein.sequence || '').length}
+
+ {protein.best_annotation && ( +
+
Annotation
+
{protein.best_annotation}
+
+ )} +
+ +
+
Sequence (activation highlighted)
+
+ +
+
+
+
+
+ ) + + return ReactDOM.createPortal(modal, document.body) +} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/ProteinSequence.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/ProteinSequence.jsx new file mode 100644 index 0000000000..5511a61c70 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/ProteinSequence.jsx @@ -0,0 +1,278 @@ +import React, { useState, useEffect, useRef } from 'react' +import { codonToAA, parseCodons } 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 CODON_WIDTH = 30 + +const styles = { + container: { + fontFamily: 'Monaco, Menlo, "Courier New", monospace', + fontSize: '11px', + lineHeight: '1.2', + overflowX: 'auto', + position: 'relative', + }, + codonRow: { + display: 'inline-flex', + whiteSpace: 'nowrap', + }, + codonBlock: { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'center', + cursor: 'default', + borderRadius: '2px', + padding: '1px 2px', + marginRight: '1px', + minWidth: `${CODON_WIDTH - 1}px`, + }, + padBlock: { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'center', + borderRadius: '2px', + padding: '1px 2px', + marginRight: '1px', + minWidth: `${CODON_WIDTH - 1}px`, + background: 'var(--density-bar-bg)', + }, + padText: { + fontSize: '10px', + color: 'var(--text-muted)', + }, + codonText: { + fontSize: '10px', + letterSpacing: '0.5px', + color: 'var(--text)', + }, + aaText: { + fontSize: '9px', + color: 'var(--text-secondary)', + marginTop: '1px', + }, + 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 codon +const INDEX_INTERVAL = 1 + +export default function ProteinSequence({ + sequence, activations, maxActivation, + alignMode, alignAnchor, totalLength, + scrollGroupRef, +}) { + const [tooltip, setTooltip] = useState(null) + const scrollRef = useRef(null) + const anchorRef = useRef(null) + + const codons = parseCodons(sequence) + const acts = activations ? activations.slice(0, codons.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 - codons.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 + + // Register this element in the group + 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, codon, aa, idx, act) => { + setTooltip({ + x: e.clientX + 10, + y: e.clientY - 25, + text: `${codon} (${aa}) 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 = () => true + + return ( +
+
+ {/* Left padding */} + {Array.from({ length: leftPad }, (_, i) => ( + + · + · +   + + ))} + + {/* Actual codons */} + {codons.map((codon, idx) => { + const act = acts[idx] || 0 + const bg = activationColorHex(act, maxAct) + const aa = codonToAA(codon) + const isAnchor = isAligned && idx === localAnchor + const hasActivation = act > 0 + const activeTextColor = hasActivation ? '#000' : undefined + return ( + handleMouseEnter(e, codon, aa, idx, act)} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + > + {codon} + {aa} + {shouldShowIdx(idx) ? idx + 1 : '\u00A0'} + + ) + })} + + {/* Right padding */} + {Array.from({ length: rightPad }, (_, i) => ( + + · + · +   + + ))} +
+ {tooltip && ( + + {tooltip.text} + + )} +
+ ) +} + +/** + * Compute alignment info for a set of examples. + */ +export function computeAlignInfo(examples, alignMode) { + if (!examples || examples.length === 0) return { anchor: 0, totalLength: 0 } + + if (alignMode === 'start') { + // No alignment padding, just pad to longest sequence + 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 + } + + // Compute totalLength with final maxAnchor + 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/sae/src/sae/steering_ui/src/index.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/index.jsx similarity index 100% rename from bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/index.jsx rename to bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/index.jsx diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/utils.js b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/utils.js new file mode 100644 index 0000000000..3a2042af3d --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/src/utils.js @@ -0,0 +1,65 @@ +/** + * Extract UniProt accession from a protein ID. + */ +export function getAccession(proteinId) { + if (proteinId && proteinId.includes('|')) return proteinId.split('|')[1] + // Extract gene name from mutation IDs like "FLT3_2438_GCC-GCA" or "ABL1_ref" + if (proteinId && proteinId.includes('_')) return proteinId.split('_')[0] + return proteinId || '' +} + +export function uniprotUrl(accession) { + // If it looks like a UniProt accession (e.g., P36888), link directly + if (/^[A-Z][0-9][A-Z0-9]{3}[0-9]$/.test(accession) || /^[A-Z][0-9][A-Z0-9]{3}[0-9]{2}$/.test(accession)) { + return `https://www.uniprot.org/uniprotkb/${accession}` + } + // Otherwise search by gene name + return `https://www.uniprot.org/uniprotkb?query=${accession}` +} + +/** + * Standard genetic code: DNA codon -> amino acid (1-letter code). + */ +const CODON_TO_AA = { + 'TTT': 'F', 'TTC': 'F', 'TTA': 'L', 'TTG': 'L', + 'CTT': 'L', 'CTC': 'L', 'CTA': 'L', 'CTG': 'L', + 'ATT': 'I', 'ATC': 'I', 'ATA': 'I', 'ATG': 'M', + 'GTT': 'V', 'GTC': 'V', 'GTA': 'V', 'GTG': 'V', + 'TCT': 'S', 'TCC': 'S', 'TCA': 'S', 'TCG': 'S', + 'CCT': 'P', 'CCC': 'P', 'CCA': 'P', 'CCG': 'P', + 'ACT': 'T', 'ACC': 'T', 'ACA': 'T', 'ACG': 'T', + 'GCT': 'A', 'GCC': 'A', 'GCA': 'A', 'GCG': 'A', + 'TAT': 'Y', 'TAC': 'Y', 'TAA': '*', 'TAG': '*', + 'CAT': 'H', 'CAC': 'H', 'CAA': 'Q', 'CAG': 'Q', + 'AAT': 'N', 'AAC': 'N', 'AAA': 'K', 'AAG': 'K', + 'GAT': 'D', 'GAC': 'D', 'GAA': 'E', 'GAG': 'E', + 'TGT': 'C', 'TGC': 'C', 'TGA': '*', 'TGG': 'W', + 'CGT': 'R', 'CGC': 'R', 'CGA': 'R', 'CGG': 'R', + 'AGT': 'S', 'AGC': 'S', 'AGA': 'R', 'AGG': 'R', + 'GGT': 'G', 'GGC': 'G', 'GGA': 'G', 'GGG': 'G', +} + +/** + * Translate a DNA codon to its amino acid. + * Returns '?' for unknown codons. + */ +export function codonToAA(codon) { + return CODON_TO_AA[codon.toUpperCase()] || '?' +} + +/** + * Parse a codon sequence string into an array of codon triplets. + * Handles both space-separated ("ATG AAA GCC") and concatenated ("ATGAAAGCC") formats. + */ +export function parseCodons(sequence) { + if (!sequence) return [] + if (sequence.includes(' ')) { + return sequence.split(' ').filter(c => c.length > 0) + } + // Concatenated: split into triplets + const codons = [] + for (let i = 0; i < sequence.length; i += 3) { + codons.push(sequence.slice(i, i + 3)) + } + return codons +} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/vite.config.js b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/vite.config.js similarity index 51% rename from bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/vite.config.js rename to bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/vite.config.js index 07e2061d5d..6df23efee3 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/vite.config.js +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/codon_dashboard/vite.config.js @@ -8,13 +8,6 @@ export default defineConfig({ outDir: 'dist', }, server: { - port: 5174, - proxy: { - '/api': { - target: 'http://127.0.0.1:8000', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), - }, - }, + port: 5176, }, }) diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/pyproject.toml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/pyproject.toml new file mode 100644 index 0000000000..e4c7b193ca --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "codonfm-sae" +version = "0.1.0" +description = "Sparse Autoencoders for CodonFM codon language models" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name="Jared Wilber", email="jwilber@nvidia.com" }, +] +keywords = ["machine learning", "biology", "codons", "dna", "interpretability"] + +dependencies = [ + "sae", + "torch>=2.0", + "numpy>=1.20", + "tqdm>=4.60", + "pandas>=1.5", + "hydra-core>=1.3", + "omegaconf>=2.3", + "safetensors>=0.3", + "gseapy>=1.0", + "goatools>=1.3", +] + +[project.optional-dependencies] +export = [ + "pyarrow>=10.0", + "duckdb>=0.9", + "pandas>=1.5", +] +viz = [ + "umap-learn>=0.5", + "hdbscan>=0.8", + "pyarrow>=10.0", + "duckdb>=0.9", +] +gsea = [ + "gseapy>=1.0", + "goatools>=1.3", +] +tracking = [ + "wandb>=0.15", +] +dev = [ + "pytest>=7.0", + "ruff>=0.1", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.uv.sources] +sae = { workspace = true } diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run.py new file mode 100644 index 0000000000..da1db78626 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run.py @@ -0,0 +1,202 @@ +# 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. + +"""Unified CodonFM SAE pipeline: extract -> train -> eval. + +Usage: + # Full pipeline for 1B model: + python run.py model=1b csv_path=path/to/Primates.csv + + # Skip extraction (already cached): + python run.py model=1b csv_path=path/to/data.csv steps.extract=false + + # Override any param: + python run.py model=1b csv_path=data.csv train.n_epochs=5 nproc=8 dp_size=8 + + # Quick smoke test: + python run.py model=1b csv_path=data.csv num_sequences=100 train.n_epochs=1 nproc=1 dp_size=1 +""" + +import os +import subprocess +import sys +from pathlib import Path + +import hydra +from omegaconf import DictConfig, OmegaConf + + +SCRIPTS_DIR = Path(__file__).parent / "scripts" + + +def _run(cmd: list, description: str) -> None: + print(f"\n{'=' * 60}") + print(f" {description}") + print(f"{'=' * 60}") + print(f" CMD: {' '.join(str(c) for c in cmd)}\n") + subprocess.run([str(c) for c in cmd], check=True) + + +def _torchrun_prefix(nproc: int) -> list: + if nproc > 1: + return ["torchrun", f"--nproc_per_node={nproc}"] + return [sys.executable] + + +def run_extract(cfg: DictConfig, cache_dir: Path) -> None: # noqa: D103 + cmd = [ + *_torchrun_prefix(cfg.nproc), + str(SCRIPTS_DIR / "extract.py"), + "--csv-path", + cfg.csv_path, + "--num-sequences", + str(cfg.num_sequences), + "--layer", + str(cfg.layer), + "--model-path", + cfg.model_path, + "--batch-size", + str(cfg.batch_size), + "--context-length", + str(cfg.context_length), + "--seed", + str(cfg.seed), + "--output", + str(cache_dir), + ] + if cfg.extract.get("shard_size"): + cmd.extend(["--shard-size", str(cfg.extract.shard_size)]) + + _run(cmd, f"STEP 1: Extract activations from {cfg.model_path}") + + +def run_train(cfg: DictConfig, cache_dir: Path, output_dir: Path) -> None: # noqa: D103 + checkpoint_dir = output_dir / "checkpoints" + t = cfg.train + + cmd = [ + *_torchrun_prefix(cfg.nproc), + str(SCRIPTS_DIR / "train.py"), + "--cache-dir", + str(cache_dir), + "--model-path", + cfg.model_path, + "--layer", + str(cfg.layer), + "--model-type", + t.model_type, + "--expansion-factor", + str(t.expansion_factor), + "--top-k", + str(t.top_k), + "--lr", + str(t.lr), + "--n-epochs", + str(t.n_epochs), + "--batch-size", + str(t.batch_size), + "--log-interval", + str(t.log_interval), + "--dp-size", + str(cfg.dp_size), + "--checkpoint-dir", + str(checkpoint_dir), + "--checkpoint-steps", + str(t.checkpoint_steps), + "--output-dir", + str(output_dir), + "--seed", + str(cfg.seed), + "--num-sequences", + str(cfg.num_sequences), + ] + + if t.auxk: + cmd.extend(["--auxk", str(t.auxk)]) + cmd.extend(["--auxk-coef", str(t.auxk_coef)]) + cmd.extend(["--dead-tokens-threshold", str(t.dead_tokens_threshold)]) + if t.init_pre_bias: + cmd.append("--init-pre-bias") + if t.normalize_input: + cmd.append("--normalize-input") + if t.get("max_grad_norm"): + cmd.extend(["--max-grad-norm", str(t.max_grad_norm)]) + + if t.wandb_enabled: + cmd.append("--wandb") + cmd.extend(["--wandb-project", t.wandb_project]) + else: + cmd.append("--no-wandb") + + _run(cmd, "STEP 2: Train SAE") + + +def run_eval(cfg: DictConfig, output_dir: Path) -> None: # noqa: D103 + checkpoint = output_dir / "checkpoints" / "checkpoint_final.pt" + eval_dir = output_dir / "eval" + + cmd = [ + sys.executable, + str(SCRIPTS_DIR / "eval.py"), + "--checkpoint", + str(checkpoint), + "--top-k", + str(cfg.train.top_k), + "--model-path", + cfg.model_path, + "--layer", + str(cfg.layer), + "--context-length", + str(cfg.context_length), + "--batch-size", + str(cfg.batch_size), + "--csv-path", + cfg.eval.csv_path, + "--num-sequences", + str(cfg.eval.num_sequences), + "--output-dir", + str(eval_dir), + "--seed", + str(cfg.seed), + ] + + _run(cmd, "STEP 3: Evaluate SAE (loss recovered)") + + +@hydra.main(version_base=None, config_path="run_configs", config_name="config") +def main(cfg: DictConfig) -> None: # noqa: D103 + os.chdir(hydra.utils.get_original_cwd()) + + print(OmegaConf.to_yaml(cfg)) + + cache_dir = Path(f".cache/activations/{cfg.run_name}_layer{cfg.layer}") + output_dir = Path(cfg.output_base) / cfg.run_name + + if cfg.steps.extract: + run_extract(cfg, cache_dir) + + if cfg.steps.train: + run_train(cfg, cache_dir, output_dir) + + if cfg.steps.eval: + run_eval(cfg, output_dir) + + print(f"\n{'=' * 60}") + print(f" DONE: {cfg.run_name}") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/config.yaml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/config.yaml new file mode 100644 index 0000000000..ec350a9764 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/config.yaml @@ -0,0 +1,57 @@ +defaults: + - model: 1b + - _self_ + +steps: + extract: true + train: true + eval: true + +# Model-specific (overridden by model/*.yaml) +model_path: ??? +run_name: ??? +num_sequences: ??? +batch_size: ??? + +# Shared +seed: 42 +layer: -2 +csv_path: ??? +context_length: 2048 +output_base: ./outputs + +# Extraction +extract: + shard_size: 100000 + +# Training +train: + model_type: topk + expansion_factor: 8 + top_k: 32 + auxk: 64 + auxk_coef: 0.03125 + init_pre_bias: true + normalize_input: false + dead_tokens_threshold: 10000000 + lr: 3e-4 + n_epochs: 3 + batch_size: 4096 + log_interval: 50 + checkpoint_steps: 999999 + wandb_enabled: false + wandb_project: sae_codonfm_recipe + max_grad_norm: null + +# Eval +eval: + num_sequences: 100 + csv_path: ${csv_path} + +# Infrastructure +nproc: 4 +dp_size: 4 + +hydra: + run: + dir: . diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/1b.yaml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/1b.yaml new file mode 100644 index 0000000000..d0133d5391 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/1b.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# CodonFM Encodon 1B: 18 layers, hidden_size=2048 +model_path: ../../data/jwilber/checkpoints/encodon_1b +run_name: encodon_1b +num_sequences: 50000 +batch_size: 8 diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/5b.yaml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/5b.yaml new file mode 100644 index 0000000000..d62c57ed38 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/5b.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# CodonFM Encodon 5B: 24 layers, hidden_size=4096 +model_path: ../../data/jwilber/checkpoints/encodon_5b +run_name: encodon_5b +num_sequences: 50000 +batch_size: 2 diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/600m.yaml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/600m.yaml new file mode 100644 index 0000000000..3f8eac2a43 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/600m.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# CodonFM Encodon 600M: 12 layers, hidden_size=2048 +model_path: ../../data/jwilber/checkpoints/encodon_600m +run_name: encodon_600m +num_sequences: 50000 +batch_size: 16 diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/80m.yaml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/80m.yaml new file mode 100644 index 0000000000..7e1adb900e --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/run_configs/model/80m.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# CodonFM Encodon 80M: 6 layers, hidden_size=1024 +model_path: ../../data/jwilber/checkpoints/encodon_80m +run_name: encodon_80m +num_sequences: 5000 +batch_size: 32 diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/analyze.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/analyze.py new file mode 100644 index 0000000000..06b05fe982 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/analyze.py @@ -0,0 +1,1042 @@ +# 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. + +"""Compute interpretability analysis for CodonFM SAE features. + +Generates: + - Vocabulary logit analysis (which codons each feature promotes/suppresses) + - Codon-level computed annotations (usage bias, CpG, wobble, amino acid identity) + - Auto-interp LLM-generated feature labels (optional) + +Usage: + python scripts/analyze.py \ + --checkpoint ./outputs/merged_1b/checkpoints/checkpoint_final.pt \ + --model-path /path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 --top-k 32 \ + --csv-path /path/to/Primates.csv \ + --dashboard-dir ./outputs/merged_1b/dashboard \ + --output-dir ./outputs/merged_1b/analysis +""" + +import argparse +import json +import sys +from pathlib import Path + +import numpy as np +import torch +from tqdm import tqdm + + +# Use codonfm_ptl_te recipe (has TransformerEngine support) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent.parent +_CODONFM_TE_DIR = _REPO_ROOT / "recipes" / "codonfm_ptl_te" +sys.path.insert(0, str(_CODONFM_TE_DIR)) + +from codonfm_sae.data import read_codon_csv # noqa: E402 +from sae.architectures import TopKSAE # noqa: E402 +from sae.utils import get_device, set_seed # noqa: E402 +from src.data.preprocess.codon_sequence import process_item # noqa: E402 +from src.inference.encodon import EncodonInference # noqa: E402 + + +# ── Standard codon usage table (human, per 1000 codons) ────────────── +# Source: Kazusa Codon Usage Database, Homo sapiens +HUMAN_CODON_USAGE = { + "TTT": 17.6, + "TTC": 20.3, + "TTA": 7.7, + "TTG": 12.9, + "CTT": 13.2, + "CTC": 19.6, + "CTA": 7.2, + "CTG": 39.6, + "ATT": 16.0, + "ATC": 20.8, + "ATA": 7.5, + "ATG": 22.0, + "GTT": 11.0, + "GTC": 14.5, + "GTA": 7.1, + "GTG": 28.1, + "TCT": 15.2, + "TCC": 17.7, + "TCA": 12.2, + "TCG": 4.4, + "CCT": 17.5, + "CCC": 19.8, + "CCA": 16.9, + "CCG": 6.9, + "ACT": 13.1, + "ACC": 18.9, + "ACA": 15.1, + "ACG": 6.1, + "GCT": 18.4, + "GCC": 27.7, + "GCA": 15.8, + "GCG": 7.4, + "TAT": 12.2, + "TAC": 15.3, + "TAA": 1.0, + "TAG": 0.8, + "CAT": 10.9, + "CAC": 15.1, + "CAA": 12.3, + "CAG": 34.2, + "AAT": 17.0, + "AAC": 19.1, + "AAA": 24.4, + "AAG": 31.9, + "GAT": 21.8, + "GAC": 25.1, + "GAA": 29.0, + "GAG": 39.6, + "TGT": 10.6, + "TGC": 12.6, + "TGA": 1.6, + "TGG": 13.2, + "CGT": 4.5, + "CGC": 10.4, + "CGA": 6.2, + "CGG": 11.4, + "AGT": 12.1, + "AGC": 19.5, + "AGA": 12.2, + "AGG": 12.0, + "GGT": 10.8, + "GGC": 22.2, + "GGA": 16.5, + "GGG": 16.5, +} + +CODON_TO_AA = { + "TTT": "F", + "TTC": "F", + "TTA": "L", + "TTG": "L", + "CTT": "L", + "CTC": "L", + "CTA": "L", + "CTG": "L", + "ATT": "I", + "ATC": "I", + "ATA": "I", + "ATG": "M", + "GTT": "V", + "GTC": "V", + "GTA": "V", + "GTG": "V", + "TCT": "S", + "TCC": "S", + "TCA": "S", + "TCG": "S", + "CCT": "P", + "CCC": "P", + "CCA": "P", + "CCG": "P", + "ACT": "T", + "ACC": "T", + "ACA": "T", + "ACG": "T", + "GCT": "A", + "GCC": "A", + "GCA": "A", + "GCG": "A", + "TAT": "Y", + "TAC": "Y", + "TAA": "*", + "TAG": "*", + "CAT": "H", + "CAC": "H", + "CAA": "Q", + "CAG": "Q", + "AAT": "N", + "AAC": "N", + "AAA": "K", + "AAG": "K", + "GAT": "D", + "GAC": "D", + "GAA": "E", + "GAG": "E", + "TGT": "C", + "TGC": "C", + "TGA": "*", + "TGG": "W", + "CGT": "R", + "CGC": "R", + "CGA": "R", + "CGG": "R", + "AGT": "S", + "AGC": "S", + "AGA": "R", + "AGG": "R", + "GGT": "G", + "GGC": "G", + "GGA": "G", + "GGG": "G", +} + + +def parse_args(): # noqa: D103 + p = argparse.ArgumentParser(description="Analyze CodonFM SAE features") + p.add_argument("--checkpoint", type=str, required=True) + p.add_argument("--top-k", type=int, default=None, help="Override top-k (default: read from checkpoint)") + p.add_argument("--model-path", type=str, required=True) + p.add_argument("--layer", type=int, default=-2) + p.add_argument("--context-length", type=int, default=2048) + p.add_argument("--batch-size", type=int, default=8) + p.add_argument("--csv-path", type=str, required=True, help="CSV with codon sequences (e.g. Primates.csv)") + p.add_argument("--num-sequences", type=int, default=None, help="Max sequences to analyze (default: all)") + p.add_argument( + "--dashboard-dir", type=str, default=None, help="If provided, updates features_atlas.parquet with labels" + ) + p.add_argument("--output-dir", type=str, default="./outputs/analysis") + p.add_argument("--auto-interp", action="store_true", help="Run LLM auto-interpretation") + p.add_argument( + "--llm-provider", + type=str, + default="anthropic", + choices=["anthropic", "openai", "nim", "nvidia-internal"], + help="LLM provider for auto-interp (default: anthropic)", + ) + p.add_argument( + "--llm-model", + type=str, + default=None, + help="LLM model name (defaults: anthropic=claude-sonnet-4-20250514, openai=gpt-4o, nim=nvidia/llama-3.1-nemotron-70b-instruct, nvidia-internal=aws/anthropic/bedrock-claude-3-7-sonnet-v1)", + ) + p.add_argument("--max-features", type=int, default=None, help="Limit number of features to analyze (for testing)") + p.add_argument( + "--max-auto-interp-features", + type=int, + default=None, + help="Limit auto-interp to top N features by activation frequency (default: all with codon annotations)", + ) + p.add_argument( + "--auto-interp-workers", type=int, default=1, help="Number of parallel workers for LLM calls (default: 1)" + ) + p.add_argument("--seed", type=int, default=42) + p.add_argument("--device", type=str, default=None) + return p.parse_args() + + +def load_sae(checkpoint_path: str, top_k_override: int | None = None) -> TopKSAE: # noqa: D103 + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + state_dict = ckpt["model_state_dict"] + if any(k.startswith("module.") for k in state_dict): + state_dict = {k.removeprefix("module."): v for k, v in state_dict.items()} + w = state_dict["encoder.weight"] + hidden_dim, input_dim = w.shape + model_config = ckpt.get("model_config", {}) + top_k = top_k_override or model_config.get("top_k") + if top_k is None: + raise ValueError("top_k not found in checkpoint. Pass --top-k explicitly.") + if top_k_override and model_config.get("top_k") and top_k_override != model_config["top_k"]: + print(f" WARNING: overriding checkpoint top_k={model_config['top_k']} with --top-k={top_k_override}") + sae = TopKSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + top_k=top_k, + normalize_input=model_config.get("normalize_input", False), + ) + sae.load_state_dict(state_dict) + return sae + + +# ── 1. Vocabulary logit analysis ───────────────────────────────────── + + +def compute_vocab_logits(sae, inference, device="cuda"): + """Project SAE decoder through the Encodon LM head to get per-feature codon logits.""" + encodon = inference.model.model + tokenizer = inference.tokenizer + + # Build vocab list indexed by token ID + vocab = [tokenizer.decoder.get(i, f"<{i}>") for i in range(tokenizer.vocab_size)] + + # Get the LM head (cls module) + lm_head = encodon.cls + + # Decoder weights: (input_dim, n_features) + W_dec = sae.decoder.weight.to(device) + + # Project each feature's decoder column through the LM head + with torch.no_grad(): + # LM head expects (batch, hidden_dim) and outputs (batch, vocab_size) + logits = lm_head(W_dec.T) # (n_features, vocab_size) + + # Subtract mean logit vector (baseline) so that values reflect + # feature-specific effects rather than the LM head's global bias + # toward common codons (e.g. GCC). Without this, most features + # look identical because the shared prior dominates. + mean_logits = logits.mean(dim=0, keepdim=True) # (1, vocab_size) + logits = logits - mean_logits + + # Build per-feature top promoted/suppressed codons + n_features = logits.shape[0] + results = {} + for f in range(n_features): + feat_logits = logits[f].cpu() + top_pos_idx = feat_logits.topk(10).indices.tolist() + top_neg_idx = feat_logits.topk(10, largest=False).indices.tolist() + + top_positive = [(vocab[i], feat_logits[i].item()) for i in top_pos_idx] + top_negative = [(vocab[i], feat_logits[i].item()) for i in top_neg_idx] + + # Group top positive by amino acid + aa_counts = {} + for codon, val in top_positive: + aa = CODON_TO_AA.get(codon, "?") + aa_counts[aa] = aa_counts.get(aa, 0) + 1 + + results[f] = { + "top_positive": top_positive, + "top_negative": top_negative, + "top_aa_counts": aa_counts, + } + + return results + + +# ── 2. Streaming codon annotations + top-K tracking ────────────────── + + +def _summarize_codon_annotations( + n_features, + aa_counts, + rare_counts, + common_counts, + cpg_counts, + non_cpg_counts, + wobble_gc_counts, + wobble_at_counts, + first30_counts, + rest_counts, +): + """Summarize accumulated annotation counts into per-feature dicts.""" + all_aas = sorted(set(CODON_TO_AA.values())) + + results = {} + for f in range(n_features): + annotations = {} + + # Best amino acid + total_fires = int(aa_counts[:, f].sum()) + if total_fires > 0: + best_aa_idx = int(aa_counts[:, f].argmax()) + best_aa_count = int(aa_counts[best_aa_idx, f]) + if best_aa_count / total_fires > 0.3: + annotations["amino_acid"] = { + "aa": all_aas[best_aa_idx], + "fraction": best_aa_count / total_fires, + } + + # Rare vs common codons + n_rare = int(rare_counts[f]) + n_common = int(common_counts[f]) + if n_rare + n_common > 10: + rare_frac = n_rare / (n_rare + n_common) + if rare_frac > 0.6: + annotations["codon_usage"] = {"bias": "rare", "fraction": rare_frac} + elif rare_frac < 0.2: + annotations["codon_usage"] = {"bias": "common", "fraction": 1 - rare_frac} + + # CpG + n_cpg = int(cpg_counts[f]) + n_non = int(non_cpg_counts[f]) + if n_cpg + n_non > 10: + cpg_frac = n_cpg / (n_cpg + n_non) + if cpg_frac > 0.3: + annotations["cpg"] = {"enrichment": cpg_frac} + + # Wobble preference + n_gc = int(wobble_gc_counts[f]) + n_at = int(wobble_at_counts[f]) + if n_gc + n_at > 10: + gc_frac = n_gc / (n_gc + n_at) + if gc_frac > 0.7: + annotations["wobble"] = {"preference": "GC", "fraction": gc_frac} + elif gc_frac < 0.3: + annotations["wobble"] = {"preference": "AT", "fraction": 1 - gc_frac} + + # Position in gene + n_first = int(first30_counts[f]) + n_rest = int(rest_counts[f]) + if n_first + n_rest > 10: + first_frac = n_first / (n_first + n_rest) + expected_frac = 30 / 600 + if first_frac > expected_frac * 3: + annotations["position"] = {"region": "N-terminal", "enrichment": first_frac / expected_frac} + + if annotations: + results[f] = annotations + + return results + + +def stream_annotations_and_topk( + sae, + inference, + sequences, + layer, + context_length, + batch_size, + device="cuda", + n_top_examples=5, +): + """Single-pass streaming: extract activations, run SAE, accumulate codon stats + top-K per feature. + + Never materializes the full [n_sequences, max_len, hidden_dim] tensor. + Memory usage: O(n_features * K) for top-K tracking + O(n_features * n_aa) for codon counts. + + Returns: + codon_annotations: dict per feature + top_acts: np.ndarray [n_features, K] - top activation values per feature + top_indices: np.ndarray [n_features, K] - corresponding sequence indices + """ + n_features = sae.hidden_dim + n_sequences = len(sequences) + K = n_top_examples + + # Codon annotation accumulators + all_aas = sorted(set(CODON_TO_AA.values())) + aa_to_idx = {aa: i for i, aa in enumerate(all_aas)} + n_aa = len(all_aas) + + aa_counts = np.zeros((n_aa, n_features), dtype=np.int64) + rare_counts = np.zeros(n_features, dtype=np.int64) + common_counts = np.zeros(n_features, dtype=np.int64) + cpg_counts = np.zeros(n_features, dtype=np.int64) + non_cpg_counts = np.zeros(n_features, dtype=np.int64) + wobble_gc_counts = np.zeros(n_features, dtype=np.int64) + wobble_at_counts = np.zeros(n_features, dtype=np.int64) + first30_counts = np.zeros(n_features, dtype=np.int64) + rest_counts = np.zeros(n_features, dtype=np.int64) + + # Top-K tracking per feature (vectorized heap replacement) + top_acts = np.full((n_features, K), -np.inf, dtype=np.float32) + top_indices = np.full((n_features, K), -1, dtype=np.int64) + + print(f" Streaming {n_sequences} sequences (batch_size={batch_size})...") + n_batches = (n_sequences + batch_size - 1) // batch_size + + for batch_start in tqdm(range(0, n_sequences, batch_size), total=n_batches, desc=" Streaming"): + batch_seqs = sequences[batch_start : batch_start + batch_size] + items = [process_item(s, context_length=context_length, tokenizer=inference.tokenizer) for s in batch_seqs] + + batch_input = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + + with torch.no_grad(): + out = inference.model(batch_input, return_hidden_states=True) + hidden = out.all_hidden_states[layer].float() # [B, L, D] on GPU + attn = batch_input["attention_mask"] + + # Build mask excluding CLS/SEP + keep = attn.clone() + keep[:, 0] = 0 + lengths = attn.sum(dim=1) + for b in range(keep.shape[0]): + sep = int(lengths[b].item()) - 1 + if sep > 0: + keep[b, sep] = 0 + + # Process each sequence in the batch + for b in range(len(batch_seqs)): + seq_idx = batch_start + b + vl = int(keep[b].sum().item()) + if vl == 0: + continue + + # Match original behavior: take first vl positions + emb = hidden[b, :vl, :] # [vl, D] on GPU + + with torch.no_grad(): + _, codes = sae(emb) # [vl, n_features] + + # ── Top-K tracking (vectorized) ── + max_per_feat = codes.max(dim=0).values.cpu().numpy() # [n_features] + min_vals = top_acts.min(axis=1) + min_positions = top_acts.argmin(axis=1) + update_mask = max_per_feat > min_vals + feat_indices = np.where(update_mask)[0] + top_acts[feat_indices, min_positions[feat_indices]] = max_per_feat[feat_indices] + top_indices[feat_indices, min_positions[feat_indices]] = seq_idx + + # ── Codon annotation stats ── + codes_cpu = codes.cpu().numpy() + seq = batch_seqs[b] + codons = [seq[j * 3 : (j + 1) * 3].upper() for j in range(vl)] + + aa_idx = np.array([aa_to_idx.get(CODON_TO_AA.get(c, "?"), 0) for c in codons], dtype=np.int32) + is_rare = np.array([HUMAN_CODON_USAGE.get(c, 10.0) < 10.0 for c in codons]) + wobble_chars = [c[2] if len(c) == 3 else "?" for c in codons] + is_wobble_gc = np.array([w in ("G", "C") for w in wobble_chars]) + is_first30 = np.arange(vl) < 30 + + is_cpg = np.zeros(vl, dtype=bool) + for j in range(vl - 1): + if len(codons[j]) == 3 and len(codons[j + 1]) >= 1: + is_cpg[j] = codons[j][2] == "C" and codons[j + 1][0] == "G" + + active = codes_cpu > 0 + + for a in range(n_aa): + pos_mask = aa_idx == a + if pos_mask.any(): + aa_counts[a] += active[pos_mask].sum(axis=0) + + rare_counts += active[is_rare].sum(axis=0) if is_rare.any() else 0 + common_counts += active[~is_rare].sum(axis=0) if (~is_rare).any() else 0 + cpg_counts += active[is_cpg].sum(axis=0) if is_cpg.any() else 0 + non_cpg_counts += active[~is_cpg].sum(axis=0) if (~is_cpg).any() else 0 + wobble_gc_counts += active[is_wobble_gc].sum(axis=0) if is_wobble_gc.any() else 0 + wobble_at_counts += active[~is_wobble_gc].sum(axis=0) if (~is_wobble_gc).any() else 0 + first30_counts += active[is_first30].sum(axis=0) if is_first30.any() else 0 + rest_counts += active[~is_first30].sum(axis=0) if (~is_first30).any() else 0 + + del out, batch_input, hidden + torch.cuda.empty_cache() + + # Summarize + print(" Summarizing annotations...") + codon_annotations = _summarize_codon_annotations( + n_features, + aa_counts, + rare_counts, + common_counts, + cpg_counts, + non_cpg_counts, + wobble_gc_counts, + wobble_at_counts, + first30_counts, + rest_counts, + ) + + return codon_annotations, top_acts, top_indices + + +# ── 3. Auto-interpretation ─────────────────────────────────────────── + + +def get_llm_client(provider: str, model: str | None = None): + """Create LLM client based on provider.""" + from sae.autointerp import ( + AnthropicClient, + NIMClient, + NVIDIAInternalClient, + OpenAIClient, + ) + + if provider == "anthropic": + return AnthropicClient(model=model or "claude-sonnet-4-20250514") + elif provider == "openai": + return OpenAIClient(model=model or "gpt-4o") + elif provider == "nim": + return NIMClient(model=model or "nvidia/llama-3.1-nemotron-70b-instruct") + elif provider == "nvidia-internal": + return NVIDIAInternalClient(model=model or "aws/anthropic/bedrock-claude-3-7-sonnet-v1") + else: + raise ValueError(f"Unknown LLM provider: {provider}") + + +def run_auto_interp( + sae, + vocab_logits, + inference, + sequences, + records, + feature_indices, + top_indices, + layer, + context_length, + batch_size, + device="cuda", + llm_provider="anthropic", + llm_model=None, + num_workers=1, +): + """Run LLM auto-interpretation using precomputed top-K indices. + + Streams through needed sequences, extracting only the per-feature activation + columns required. Never caches full [vl, n_features] tensors (which OOM at scale). + """ + from collections import defaultdict + from concurrent.futures import ThreadPoolExecutor, as_completed + + client = get_llm_client(llm_provider, llm_model) + + # Build reverse index: seq_idx -> set of feature_ids that need it + seq_to_features = defaultdict(set) + for f in feature_indices: + for k in range(top_indices.shape[1]): + si = int(top_indices[f, k]) + if si >= 0: + seq_to_features[si].add(f) + + # Storage: feature_id -> list of (max_act, seq_idx, per_codon_acts_numpy) + # Only stores the single feature column per sequence (~200 floats), not all 32k + feature_acts = defaultdict(list) + + unique_indices = sorted(seq_to_features.keys()) + print(f" Re-extracting {len(unique_indices)} unique sequences for {len(feature_indices)} features...") + + n_batches = (len(unique_indices) + batch_size - 1) // batch_size + for batch_start in tqdm(range(0, len(unique_indices), batch_size), total=n_batches, desc=" Re-extracting"): + batch_idx = unique_indices[batch_start : batch_start + batch_size] + batch_seqs = [sequences[i] for i in batch_idx] + items = [process_item(s, context_length=context_length, tokenizer=inference.tokenizer) for s in batch_seqs] + + batch_input = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + + with torch.no_grad(): + out = inference.model(batch_input, return_hidden_states=True) + hidden = out.all_hidden_states[layer].float() + attn = batch_input["attention_mask"] + + keep = attn.clone() + keep[:, 0] = 0 + lengths = attn.sum(dim=1) + for b in range(keep.shape[0]): + sep = int(lengths[b].item()) - 1 + if sep > 0: + keep[b, sep] = 0 + + for b in range(len(batch_idx)): + si = batch_idx[b] + vl = int(keep[b].sum().item()) + if vl == 0: + continue + + emb = hidden[b, :vl, :] + with torch.no_grad(): + _, codes = sae(emb) # [vl, n_features] on GPU + + # Extract only the feature columns needed for this sequence + needed_feats = sorted(seq_to_features[si]) + feat_idx_tensor = torch.tensor(needed_feats, dtype=torch.long, device=codes.device) + selected = codes[:, feat_idx_tensor].cpu().numpy() # [vl, len(needed_feats)] + + for col, f in enumerate(needed_feats): + acts = selected[:, col] # [vl] + feature_acts[f].append((float(acts.max()), si, acts)) + + del codes + + del out, batch_input, hidden + torch.cuda.empty_cache() + + # Build per-feature example strings + print(" Preparing examples for auto-interp...") + feature_examples = {} + for f in tqdm(feature_indices, desc=" Collecting examples"): + entries = sorted(feature_acts.get(f, []), reverse=True, key=lambda x: x[0])[:5] + + examples = [] + for max_act, seq_idx, acts in entries: + seq = sequences[seq_idx] + vl = len(acts) + codons = [seq[j * 3 : (j + 1) * 3] for j in range(vl)] + + # Mark top activating codons + threshold = np.percentile(acts[acts > 0], 80) if (acts > 0).sum() > 0 else 0 + marked = [] + for j, (codon, act) in enumerate(zip(codons, acts)): + aa = CODON_TO_AA.get(codon.upper(), "?") + if act > threshold: + marked.append(f"***{codon}({aa})***") + else: + marked.append(f"{codon}({aa})") + + # Build metadata string + meta_str = "" + if records is not None and seq_idx < len(records): + m = records[seq_idx].metadata + meta_parts = [] + gene = m.get("gene") + if gene: + meta_parts.append(f"Gene: {gene}") + src = m.get("source") + if src: + meta_parts.append(f"Source: {src}") + ip = m.get("is_pathogenic") + if ip and str(ip).lower() not in ("", "unknown"): + meta_parts.append(f"Pathogenic: {ip}") + pp = m.get("phylop") + if pp is not None: + meta_parts.append(f"PhyloP: {pp:.2f}") + ref = m.get("ref_codon") + alt = m.get("alt_codon") + vpo = m.get("var_pos_offset") + if ref and alt: + meta_parts.append(f"Variant: {ref}>{alt} at pos {vpo}") + for score_col in ["1b_cdwt", "5b_cdwt", "1b", "5b"]: + sc = m.get(score_col) + if sc is not None: + meta_parts.append(f"Model score ({score_col}): {float(sc):.3f}") + break + if meta_parts: + meta_str = f" [{', '.join(meta_parts)}]" + + examples.append(f"{' '.join(marked)}{meta_str}") + + feature_examples[f] = examples + + # Build prompts and call LLM in parallel + print(f" Running LLM interpretation with {num_workers} workers...") + + def interpret_feature(f): + logits_info = vocab_logits.get(f, {}) + top_pos = logits_info.get("top_positive", [])[:5] + top_neg = logits_info.get("top_negative", [])[:5] + + pos_str = ", ".join(f"{tok}({CODON_TO_AA.get(tok, '?')}): {v:.2f}" for tok, v in top_pos) + neg_str = ", ".join(f"{tok}({CODON_TO_AA.get(tok, '?')}): {v:.2f}" for tok, v in top_neg) + + examples_str = "\n".join(f" Seq {i + 1}: {ex}" for i, ex in enumerate(feature_examples.get(f, []))) + + prompt = f"""This is a feature from a sparse autoencoder trained on a DNA codon language model (CodonFM). +Each token is a codon (3 nucleotides) that encodes an amino acid. + +Top promoted codons (decoder logits): {pos_str} +Top suppressed codons: {neg_str} + +Top activating sequences (***highlighted*** = high activation): +Each sequence may include metadata in brackets: gene name, data source (ClinVar=germline variants, COSMIC=somatic cancer mutations), pathogenicity label, PhyloP conservation score, variant info (ref>alt codon at position), and model effect score (more negative = higher predicted impact). +{examples_str} + +In 1 short sentence starting with "Fires on", describe what biological pattern this feature detects. +Consider: amino acid identity, specific codon choice, codon usage bias, positional context, CpG sites, wobble position patterns, and any variant/clinical metadata patterns you observe. + +Format your response as: +Label: +Confidence: <0.00 to 1.00>""" + + try: + response = client.generate(prompt) + text = response.text.strip() + + label = None + confidence = 0.0 + + for line in text.split("\n"): + if line.startswith("Label:"): + label = line.replace("Label:", "").strip() + elif line.startswith("Confidence:"): + try: + confidence = float(line.replace("Confidence:", "").strip()) + confidence = max(0.0, min(1.0, confidence)) + except ValueError: + confidence = 0.0 + + if not label: + label = f"Feature {f}" + + return f, label, confidence + except Exception as e: + print(f" Warning: auto-interp failed for feature {f}: {e}") + return f, f"Feature {f}", 0.0 + + interpretations = {} + confidences = {} + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = {executor.submit(interpret_feature, f): f for f in feature_indices} + for future in tqdm(as_completed(futures), total=len(feature_indices), desc=" Auto-interp"): + f, label, confidence = future.result() + interpretations[f] = label + confidences[f] = confidence + + return interpretations, confidences + + +# ── Build summary labels ───────────────────────────────────────────── + + +def build_feature_labels( + n_features, + vocab_logits, + codon_annotations, + auto_interp_labels=None, +): + """Combine all analyses into a single label per feature.""" + labels = {} + details = {} + llm_confidences = {} + + for f in range(n_features): + parts = [] + + # Auto-interp label takes priority + if auto_interp_labels and f in auto_interp_labels: + label_entry = auto_interp_labels[f] + if isinstance(label_entry, dict): + labels[f] = label_entry.get("label", f"Feature {f}") + llm_confidences[f] = label_entry.get("confidence", 0.0) + else: + labels[f] = label_entry + llm_confidences[f] = 0.0 + details[f] = { + "label": labels[f], + "llm_confidence": llm_confidences[f], + "vocab_logits": vocab_logits.get(f, {}), + "codon_annotations": codon_annotations.get(f, {}), + } + continue + + llm_confidences[f] = 0.0 + + ann = codon_annotations.get(f, {}) + if "amino_acid" in ann: + aa = ann["amino_acid"]["aa"] + frac = ann["amino_acid"]["fraction"] + parts.append(f"{aa} ({frac:.0%})") + if "codon_usage" in ann: + parts.append(f"{ann['codon_usage']['bias']} codons") + if "wobble" in ann: + parts.append(f"wobble {ann['wobble']['preference']}") + if "cpg" in ann: + parts.append("CpG enriched") + if "position" in ann: + parts.append("N-terminal") + + if parts: + labels[f] = " | ".join(parts) + else: + labels[f] = f"Feature {f}" + + details[f] = { + "label": labels[f], + "llm_confidence": llm_confidences[f], + "vocab_logits": vocab_logits.get(f, {}), + "codon_annotations": codon_annotations.get(f, {}), + } + + return labels, details, llm_confidences + + +# ── Main ───────────────────────────────────────────────────────────── + + +def main(): # noqa: D103 + args = parse_args() + set_seed(args.seed) + device = args.device or get_device() + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Device: {device}") + + # Load SAE + sae = load_sae(args.checkpoint, top_k_override=args.top_k).eval().to(device) + n_features = sae.hidden_dim + print(f"SAE: {sae.input_dim} -> {n_features} features") + + # Load model + print(f"\nLoading Encodon from {args.model_path}...") + inference = EncodonInference( + model_path=args.model_path, task_type="embedding_prediction", use_transformer_engine=True + ) + inference.configure_model() + inference.model.to(device).eval() + + # Load sequences + max_codons = args.context_length - 2 + records = read_codon_csv( + args.csv_path, + max_sequences=args.num_sequences, + max_codons=max_codons, + ) + sequences = [r.sequence for r in records] + print(f"Loaded {len(sequences)} sequences") + + # ── Analysis ───────────────────────────────────────────────────── + + # 1. Vocabulary logits (only uses SAE decoder, no activations needed) + print("\n[1/3] Vocabulary logit analysis...") + vocab_logits_file = output_dir / "vocab_logits_checkpoint.json" + if vocab_logits_file.exists(): + print(" Loading vocab logits from checkpoint...") + with open(vocab_logits_file) as f: + vocab_logits = json.load(f) + vocab_logits = {int(k): v for k, v in vocab_logits.items()} + else: + vocab_logits = compute_vocab_logits(sae, inference, device) + with open(vocab_logits_file, "w") as f: + json.dump(vocab_logits, f) + print(f" Computed logits for {len(vocab_logits)} features") + + # 2. Streaming codon annotations + top-K tracking (single pass, constant memory) + print("\n[2/3] Streaming codon annotations + top-K tracking...") + codon_annotations_file = output_dir / "codon_annotations_checkpoint.json" + topk_file = output_dir / "topk_checkpoint.npz" + + if codon_annotations_file.exists() and topk_file.exists(): + print(" Loading codon annotations from checkpoint...") + with open(codon_annotations_file) as f: + codon_annotations = json.load(f) + codon_annotations = {int(k): v for k, v in codon_annotations.items()} + topk_data = np.load(topk_file) + top_acts = topk_data["top_acts"] + top_indices = topk_data["top_indices"] + print(f" {len(codon_annotations)} features with codon annotations") + else: + codon_annotations, top_acts, top_indices = stream_annotations_and_topk( + sae, + inference, + sequences, + layer=args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + n_top_examples=max(args.n_examples if hasattr(args, "n_examples") else 5, 5), + ) + with open(codon_annotations_file, "w") as f: + json.dump(codon_annotations, f, default=str) + np.savez_compressed(topk_file, top_acts=top_acts, top_indices=top_indices) + print(f" {len(codon_annotations)} features with codon annotations") + + # 3. Auto-interp (optional) + auto_interp_labels = {} + auto_interp_ckpt = output_dir / "auto_interp_checkpoint.json" + if auto_interp_ckpt.exists(): + print(" Loading auto-interp checkpoint...") + with open(auto_interp_ckpt) as f: + ckpt_data = json.load(f) + for k, v in ckpt_data.items(): + k_int = int(k) + if isinstance(v, dict): + auto_interp_labels[k_int] = v + else: + auto_interp_labels[k_int] = {"label": v, "confidence": 0.0} + print(f" Loaded {len(auto_interp_labels)} existing interpretations") + + if args.auto_interp: + print("\n[3/3] Auto-interpretation (LLM)...") + alive_features = [f for f in range(n_features) if f in codon_annotations] + alive_features_sorted = sorted( + alive_features, + key=lambda f: max([abs(v) for _, v in vocab_logits[f].get("top_positive", [])], default=0), + reverse=True, + ) + if args.max_auto_interp_features: + alive_features_sorted = alive_features_sorted[: args.max_auto_interp_features] + + todo_features = [f for f in alive_features_sorted if f not in auto_interp_labels] + + if todo_features: + print(f" Running auto-interp on {len(todo_features)} features ({len(auto_interp_labels)} already done)") + new_labels, new_confidences = run_auto_interp( + sae, + vocab_logits, + inference, + sequences, + records, + todo_features, + top_indices, + layer=args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + llm_provider=args.llm_provider, + llm_model=args.llm_model, + num_workers=args.auto_interp_workers, + ) + for f in new_labels: + auto_interp_labels[f] = { + "label": new_labels[f], + "confidence": new_confidences[f], + } + with open(auto_interp_ckpt, "w") as f: + json.dump(auto_interp_labels, f, indent=2) + else: + print(f" All {len(auto_interp_labels)} features already interpreted") + else: + print("\n[3/3] Skipping auto-interp (use --auto-interp to enable)") + + # Build labels + print("\nBuilding feature labels...") + labels, details, llm_confidences = build_feature_labels( + n_features, + vocab_logits, + codon_annotations, + auto_interp_labels, + ) + n_labeled = sum(1 for v in labels.values() if not v.startswith("Feature ")) + print(f" {n_labeled}/{n_features} features labeled") + + # Save results + print("\nSaving results...") + + with open(output_dir / "feature_analysis.json", "w") as f: + json.dump(details, f, indent=2, default=str) + + with open(output_dir / "feature_labels.json", "w") as f: + json.dump(labels, f, indent=2) + + with open(output_dir / "llm_confidences.json", "w") as f: + json.dump(llm_confidences, f, indent=2) + + logits_export = {} + for feat_id, data in vocab_logits.items(): + logits_export[str(feat_id)] = { + "top_positive": [[tok, round(val, 3)] for tok, val in data["top_positive"]], + "top_negative": [[tok, round(val, 3)] for tok, val in data["top_negative"]], + } + with open(output_dir / "vocab_logits.json", "w") as f: + json.dump(logits_export, f) + + # Update dashboard atlas if requested + if args.dashboard_dir: + dashboard_dir = Path(args.dashboard_dir) + atlas_path = dashboard_dir / "features_atlas.parquet" + if atlas_path.exists(): + import pyarrow as pa + import pyarrow.parquet as pq + + print(f"\nUpdating {atlas_path} with labels and confidence scores...") + table = pq.read_table(atlas_path) + n = table.num_rows + label_col = [labels.get(i, f"Feature {i}") for i in range(n)] + confidence_col = [llm_confidences.get(i, 0.0) for i in range(n)] + table = table.drop("label") if "label" in table.column_names else table + table = table.drop("llm_confidence") if "llm_confidence" in table.column_names else table + table = table.append_column("label", pa.array(label_col)) + table = table.append_column("llm_confidence", pa.array(confidence_col, type=pa.float32())) + pq.write_table(table, atlas_path, compression="snappy") + print(f" Updated {n} feature labels and confidence scores in atlas") + + # Copy analysis files to dashboard dir + if args.dashboard_dir: + import shutil + + dashboard_dir = Path(args.dashboard_dir) + dashboard_dir.mkdir(parents=True, exist_ok=True) + for fname in ["vocab_logits.json", "feature_labels.json", "feature_analysis.json"]: + src = output_dir / fname + dst = dashboard_dir / fname + if src.exists(): + shutil.copy2(src, dst) + print(f" Copied {fname} to dashboard dir") + + print(f"\nAnalysis complete. Results saved to {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/dashboard.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/dashboard.py new file mode 100644 index 0000000000..e368f24742 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/dashboard.py @@ -0,0 +1,879 @@ +# 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 dashboard data from a trained CodonFM SAE. + +Loads the SAE checkpoint + Encodon model, runs sequences through both, +and exports feature statistics + per-sequence activation examples +to parquet files for the interactive dashboard. + + python scripts/dashboard.py \ + --checkpoint ./outputs/merged_1b/checkpoints/checkpoint_final.pt \ + --model-path /path/to/encodon_1b/NV-CodonFM-Encodon-1B-v1.safetensors \ + --layer -2 --top-k 32 \ + --csv-path /path/to/Primates.csv \ + --output-dir ./outputs/merged_1b/dashboard +""" + +import argparse +import sys +import time +from pathlib import Path +from typing import List, Tuple + +import numpy as np +import torch +from tqdm import tqdm + + +# Use codonfm_ptl_te recipe (has TransformerEngine support) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent.parent +_CODONFM_TE_DIR = _REPO_ROOT / "recipes" / "codonfm_ptl_te" +sys.path.insert(0, str(_CODONFM_TE_DIR)) + +from codonfm_sae.data import read_codon_csv # noqa: E402 +from sae.analysis import compute_feature_stats, compute_feature_umap, save_feature_atlas # noqa: E402 +from sae.architectures import TopKSAE # noqa: E402 +from sae.utils import get_device, set_seed # noqa: E402 +from src.data.preprocess.codon_sequence import process_item # noqa: E402 +from src.inference.encodon import EncodonInference # noqa: E402 + + +def parse_args(): # noqa: D103 + p = argparse.ArgumentParser(description="Generate CodonFM SAE dashboard data") + p.add_argument("--checkpoint", type=str, required=True, help="Path to SAE checkpoint .pt file") + p.add_argument("--top-k", type=int, default=None, help="Override top-k (default: read from checkpoint)") + p.add_argument("--model-path", type=str, required=True, help="Path to Encodon checkpoint (.safetensors)") + p.add_argument("--layer", type=int, default=-2) + p.add_argument("--context-length", type=int, default=2048) + p.add_argument("--batch-size", type=int, default=8) + p.add_argument("--csv-path", type=str, required=True) + p.add_argument("--seq-column", type=str, default=None) + p.add_argument("--num-sequences", type=int, default=2000) + p.add_argument("--n-examples", type=int, default=6, help="Top examples per feature") + p.add_argument("--output-dir", type=str, default="./outputs/dashboard") + p.add_argument("--umap-n-neighbors", type=int, default=15) + p.add_argument("--umap-min-dist", type=float, default=0.1) + p.add_argument("--hdbscan-min-cluster-size", type=int, default=20) + p.add_argument( + "--score-column", type=str, default=None, help="Model score column for variant analysis (auto-detect if None)" + ) + p.add_argument("--seed", type=int, default=42) + p.add_argument("--device", type=str, default=None) + return p.parse_args() + + +def load_sae_from_checkpoint(checkpoint_path: str, top_k_override: int | None = None) -> TopKSAE: # noqa: D103 + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + state_dict = ckpt["model_state_dict"] + if any(k.startswith("module.") for k in state_dict): + state_dict = {k.removeprefix("module."): v for k, v in state_dict.items()} + + input_dim = ckpt.get("input_dim") + hidden_dim = ckpt.get("hidden_dim") + if input_dim is None or hidden_dim is None: + w = state_dict["encoder.weight"] + hidden_dim = hidden_dim or w.shape[0] + input_dim = input_dim or w.shape[1] + + model_config = ckpt.get("model_config", {}) + normalize_input = model_config.get("normalize_input", False) + + # Use checkpoint's top_k by default, allow CLI override + top_k = top_k_override or model_config.get("top_k") + if top_k is None: + raise ValueError("top_k not found in checkpoint model_config. Pass --top-k explicitly.") + if top_k_override and model_config.get("top_k") and top_k_override != model_config["top_k"]: + print(f" WARNING: overriding checkpoint top_k={model_config['top_k']} with --top-k={top_k_override}") + + sae = TopKSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + top_k=top_k, + normalize_input=normalize_input, + ) + sae.load_state_dict(state_dict) + print(f"Loaded SAE: {input_dim} -> {hidden_dim:,} latents (top-{top_k})") + return sae + + +def extract_activations_3d( + inference, + sequences: List[str], + layer: int, + context_length: int = 2048, + batch_size: int = 8, + device: str = "cuda", +) -> Tuple[torch.Tensor, torch.Tensor]: + """Extract 3D activations (n_sequences, max_seq_len, hidden_dim) + masks. + + Returns padded activations and masks with CLS/SEP excluded. + """ + all_embeddings = [] + all_masks = [] + + n_batches = (len(sequences) + batch_size - 1) // batch_size + iterator = tqdm(range(0, len(sequences), batch_size), total=n_batches, desc="Extracting activations") + + with torch.no_grad(): + for i in iterator: + batch_seqs = sequences[i : i + batch_size] + items = [process_item(s, context_length=context_length, tokenizer=inference.tokenizer) for s in batch_seqs] + + batch = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + + out = inference.model(batch, return_hidden_states=True) + hidden = out.all_hidden_states[layer].float().cpu() # [B, L, D] + attn_mask = batch["attention_mask"].cpu() + + # Build mask excluding CLS (pos 0) and SEP (last real pos) + keep = attn_mask.clone() + keep[:, 0] = 0 + lengths = attn_mask.sum(dim=1) + for b in range(keep.shape[0]): + sep = int(lengths[b].item()) - 1 + if sep > 0: + keep[b, sep] = 0 + + all_embeddings.append(hidden) + all_masks.append(keep) + + del out, batch + torch.cuda.empty_cache() + + # Pad to same seq_len across batches + max_len = max(e.shape[1] for e in all_embeddings) + + padded_emb = [] + padded_masks = [] + for emb, msk in zip(all_embeddings, all_masks): + B, L, D = emb.shape + if L < max_len: + emb = torch.cat([emb, torch.zeros(B, max_len - L, D)], dim=1) + msk = torch.cat([msk, torch.zeros(B, max_len - L, dtype=msk.dtype)], dim=1) + padded_emb.append(emb) + padded_masks.append(msk) + + return torch.cat(padded_emb, dim=0), torch.cat(padded_masks, dim=0) + + +def export_codon_features_parquet( + sae: torch.nn.Module, + activations: torch.Tensor, + sequences: List[str], + sequence_ids: List[str], + masks: torch.Tensor, + output_dir: Path, + n_examples: int = 6, + device: str = "cuda", + records: list | None = None, + variant_delta_map: dict | None = None, + precomputed_max_acts: torch.Tensor | None = None, +): + """Export per-codon feature activations for dashboard. + + Two-pass algorithm: + Pass 1: compute max activation per (sequence, feature) + Pass 2: extract per-codon activations for top examples only + """ + import pyarrow as pa + import pyarrow.parquet as pq + + n_sequences = activations.shape[0] + n_features = sae.hidden_dim + + sae = sae.eval().to(device) + + # Valid lengths per sequence (excluding CLS/SEP/padding) + valid_lens = masks.sum(dim=1).long() + + # Pass 1: max activation per (sequence, feature) — reuse if precomputed + if precomputed_max_acts is not None: + print(" Reusing precomputed max activations...") + max_acts = precomputed_max_acts + else: + print(" Pass 1: Computing max activations per sequence...") + max_acts = torch.zeros(n_sequences, n_features) + for i in tqdm(range(n_sequences), desc=" Max activations"): + vl = int(valid_lens[i].item()) + if vl == 0: + continue + emb = activations[i, :vl, :].to(device) + with torch.no_grad(): + _, codes = sae(emb) + max_acts[i] = codes.max(dim=0).values.cpu() + + # Find top examples per feature + print(" Finding top examples per feature...") + top_indices = torch.topk(max_acts, k=min(n_examples, n_sequences), dim=0).indices # (n_examples, n_features) + + # Build reverse index: which sequences need re-encoding + needed_sequences = {} + for feat_idx in range(n_features): + for rank in range(top_indices.shape[0]): + seq_idx = int(top_indices[rank, feat_idx].item()) + if seq_idx not in needed_sequences: + needed_sequences[seq_idx] = set() + needed_sequences[seq_idx].add(feat_idx) + + # Pass 2: extract per-codon activations for top examples + print(f" Pass 2: Extracting per-codon activations ({len(needed_sequences)} sequences)...") + example_acts = {} + + for seq_idx in tqdm(sorted(needed_sequences.keys()), desc=" Per-codon activations"): + vl = int(valid_lens[seq_idx].item()) + if vl == 0: + continue + emb = activations[seq_idx, :vl, :].to(device) + with torch.no_grad(): + _, codes = sae(emb) # (vl, n_features) + codes_cpu = codes.cpu() + + for feat_idx in needed_sequences[seq_idx]: + example_acts[(seq_idx, feat_idx)] = codes_cpu[:, feat_idx].numpy().tolist() + + # Build feature_metadata.parquet + print(" Writing feature_metadata.parquet...") + meta_rows = [] + for feat_idx in range(n_features): + freq = (max_acts[:, feat_idx] > 0).float().mean().item() + max_val = max_acts[:, feat_idx].max().item() + meta_rows.append( + { + "feature_id": feat_idx, + "description": f"Feature {feat_idx}", + "activation_freq": freq, + "max_activation": max_val, + } + ) + + meta_table = pa.table( + { + "feature_id": pa.array([r["feature_id"] for r in meta_rows], type=pa.int32()), + "description": pa.array([r["description"] for r in meta_rows]), + "activation_freq": pa.array([r["activation_freq"] for r in meta_rows], type=pa.float32()), + "max_activation": pa.array([r["max_activation"] for r in meta_rows], type=pa.float32()), + } + ) + pq.write_table(meta_table, output_dir / "feature_metadata.parquet", compression="snappy") + + # Build feature_examples.parquet + print(" Writing feature_examples.parquet...") + example_rows = [] + for feat_idx in range(n_features): + for rank in range(top_indices.shape[0]): + seq_idx = int(top_indices[rank, feat_idx].item()) + key = (seq_idx, feat_idx) + if key not in example_acts: + continue + + # Get the codon sequence (triplets) + raw_seq = sequences[seq_idx] + n_codons = len(raw_seq) // 3 + codon_seq = " ".join(raw_seq[i * 3 : (i + 1) * 3] for i in range(n_codons)) + + acts_list = example_acts[key] + seq_id = sequence_ids[seq_idx] + + row = { + "feature_id": feat_idx, + "example_rank": rank, + "protein_id": seq_id, + "sequence": codon_seq, + "activations": acts_list, + "max_activation": max(acts_list) if acts_list else 0.0, + } + + # Add metadata from record if available + if records is not None and seq_idx < len(records): + meta = records[seq_idx].metadata + row["gene"] = meta.get("gene", "") + row["is_pathogenic"] = str(meta.get("is_pathogenic", "")) + row["ref_codon"] = meta.get("ref_codon", "") + row["alt_codon"] = meta.get("alt_codon", "") + src = str(meta.get("source", "")).lower() + row["source"] = "clinvar" if "clinvar" in src else ("cosmic" if "cosmic" in src else "") + vpo = meta.get("var_pos_offset") + row["var_pos_offset"] = int(float(vpo)) if vpo is not None else -1 + + # Variant delta for this feature + if variant_delta_map is not None and seq_idx in variant_delta_map: + row["variant_delta"] = float(variant_delta_map[seq_idx][feat_idx]) + else: + row["variant_delta"] = None + + example_rows.append(row) + + # Sort by feature_id for efficient row-group filtering + example_rows.sort(key=lambda r: (r["feature_id"], r["example_rank"])) + + table_dict = { + "feature_id": pa.array([r["feature_id"] for r in example_rows], type=pa.int32()), + "example_rank": pa.array([r["example_rank"] for r in example_rows], type=pa.int8()), + "protein_id": pa.array([r["protein_id"] for r in example_rows]), + "sequence": pa.array([r["sequence"] for r in example_rows]), + "activations": pa.array([r["activations"] for r in example_rows], type=pa.list_(pa.float32())), + "max_activation": pa.array([r["max_activation"] for r in example_rows], type=pa.float32()), + } + + # Add metadata columns if present + if records is not None and example_rows and "gene" in example_rows[0]: + table_dict["gene"] = pa.array([r.get("gene", "") for r in example_rows]) + table_dict["is_pathogenic"] = pa.array([r.get("is_pathogenic", "") for r in example_rows]) + table_dict["ref_codon"] = pa.array([r.get("ref_codon", "") for r in example_rows]) + table_dict["alt_codon"] = pa.array([r.get("alt_codon", "") for r in example_rows]) + table_dict["source"] = pa.array([r.get("source", "") for r in example_rows]) + table_dict["var_pos_offset"] = pa.array([r.get("var_pos_offset", -1) for r in example_rows], type=pa.int32()) + table_dict["variant_delta"] = pa.array([r.get("variant_delta") for r in example_rows], type=pa.float32()) + + examples_table = pa.table(table_dict) + + row_group_size = n_examples * 100 + pq.write_table( + examples_table, output_dir / "feature_examples.parquet", row_group_size=row_group_size, compression="snappy" + ) + + print(f" Wrote {len(meta_rows)} features, {len(example_rows)} examples") + + +def compute_variant_analysis( + sae: torch.nn.Module, + records: list, + activations: torch.Tensor, + masks: torch.Tensor, + device: str = "cuda", + score_column: str | None = None, +) -> dict: + """Compute per-feature variant analysis with multi-score, local deltas, and distribution metrics. + + For each feature computes: + - mean_variant_{col} for each available score column (1b_cdwt, 5b_cdwt, 5b) + - Global deltas (variant - ref, max over full sequence) + - Local deltas (variant - ref, max over 3-codon window around variant site) + - Site deltas (variant - ref, at exact variant position) + - GC content distribution (mean, std) among activating sequences + - Trinucleotide context distribution (entropy, dominant fraction) among activating variants + - Gene distribution (entropy, n_unique, dominant fraction) among activating sequences + """ + from collections import defaultdict + + SCORE_COLUMNS = ["1b_cdwt", "5b_cdwt", "5b"] + WINDOW_RADIUS = 3 # codons each side of variant + + n_features = sae.hidden_dim + valid_lens = masks.sum(dim=1).long() + n_sequences = activations.shape[0] + + # Pre-read var_pos_offsets for the forward pass + var_offsets_pre = [] + for r in records: + vo = r.metadata.get("var_pos_offset") + try: + var_offsets_pre.append(int(float(vo)) if vo is not None else -1) + except (ValueError, TypeError): + var_offsets_pre.append(-1) + + # ── Pass 1: max activations + site/window activations ──────────── + print(" Computing per-sequence max activations...") + max_acts = torch.zeros(n_sequences, n_features) + site_acts = {} # seq_idx -> [n_features] at var_pos + window_max_acts = {} # seq_idx -> [n_features] max over local window + + for i in tqdm(range(n_sequences), desc=" Max activations"): + vl = int(valid_lens[i].item()) + if vl == 0: + continue + emb = activations[i, :vl, :].to(device) + with torch.no_grad(): + _, codes = sae(emb) + max_acts[i] = codes.max(dim=0).values.cpu() + + vpo = var_offsets_pre[i] + if vpo >= 0 and vpo < vl: + codes_cpu = codes.cpu() + site_acts[i] = codes_cpu[vpo].numpy() + w_start = max(0, vpo - WINDOW_RADIUS) + w_end = min(vl, vpo + WINDOW_RADIUS + 1) + window_max_acts[i] = codes_cpu[w_start:w_end].max(dim=0).values.numpy() + + max_acts_np = max_acts.numpy() + + # ── Read per-sequence metadata ─────────────────────────────────── + # Multi-score columns + all_scores = {} # col_name -> list[float|None] + for col in SCORE_COLUMNS: + col_scores = [] + for r in records: + sc = r.metadata.get(col) + try: + col_scores.append(float(sc) if sc is not None else None) + except (ValueError, TypeError): + col_scores.append(None) + if any(s is not None for s in col_scores): + all_scores[col] = col_scores + + # Auto-detect primary score column + if score_column is None: + for candidate in SCORE_COLUMNS: + if candidate in all_scores: + score_column = candidate + break + if score_column: + print(f" Primary score column: {score_column}") + print(f" Score columns found: {list(all_scores.keys())}") + + phylop_vals = [] + var_offsets = [] + genes = [] + sources = [] + gc_contents = [] + trinuc_contexts = [] + + for r in records: + m = r.metadata + + pp = m.get("phylop") + try: + phylop_vals.append(float(pp) if pp is not None else None) + except (ValueError, TypeError): + phylop_vals.append(None) + + vo = m.get("var_pos_offset") + try: + var_offsets.append(int(float(vo)) if vo is not None else -1) + except (ValueError, TypeError): + var_offsets.append(-1) + + genes.append(m.get("gene", "")) + + src = str(m.get("source", "")).lower() + if "clinvar" in src: + sources.append("clinvar") + elif "cosmic" in src: + sources.append("cosmic") + else: + sources.append("other") + + gc = m.get("gc_content") + try: + gc_contents.append(float(gc) if gc is not None else None) + except (ValueError, TypeError): + gc_contents.append(None) + + trinuc_contexts.append(str(m.get("trinuc_context", "") or "")) + + # ── Per-feature mean variant score (per score column) ──────────── + mean_variant_scores = {} + for col, col_scores in all_scores.items(): + score_sum = np.zeros(n_features) + score_count = np.zeros(n_features) + for i in range(n_sequences): + if col_scores[i] is None or var_offsets[i] == -1: + continue + active = max_acts_np[i] > 0 + score_sum += active * col_scores[i] + score_count += active + col_key = col.replace("_", "") # 1b_cdwt -> 1bcdwt + mean_variant_scores[f"mean_variant_{col_key}"] = np.where( + score_count > 0, score_sum / score_count, np.nan + ).astype(np.float32) + + # High/low score split (primary score column) + high_score_fire = np.zeros(n_features) + low_score_fire = np.zeros(n_features) + primary_scores = all_scores.get(score_column, [None] * n_sequences) + valid_primary = [s for s in primary_scores if s is not None] + median_score = float(np.median(valid_primary)) if valid_primary else 0.0 + if score_column: + print(f" Median variant score ({score_column}): {median_score:.4f}") + + for i in range(n_sequences): + if primary_scores[i] is None or var_offsets[i] == -1: + continue + active = max_acts_np[i] > 0 + if primary_scores[i] >= median_score: + high_score_fire += active + else: + low_score_fire += active + + total_scored = high_score_fire + low_score_fire + high_score_fraction = np.where(total_scored > 0, high_score_fire / total_scored, np.nan) + + # ── Source enrichment (ClinVar fraction) ───────────────────────── + clinvar_fire = np.zeros(n_features) + cosmic_fire = np.zeros(n_features) + for i in range(n_sequences): + if var_offsets[i] == -1: + continue + active = max_acts_np[i] > 0 + if sources[i] == "clinvar": + clinvar_fire += active + elif sources[i] == "cosmic": + cosmic_fire += active + + total_sourced = clinvar_fire + cosmic_fire + clinvar_fraction = np.where(total_sourced > 0, clinvar_fire / total_sourced, np.nan) + + # ── Mean phyloP ────────────────────────────────────────────────── + phylop_sum = np.zeros(n_features) + phylop_count = np.zeros(n_features) + for i in range(n_sequences): + if phylop_vals[i] is None: + continue + active = max_acts_np[i] > 0 + phylop_sum += active * phylop_vals[i] + phylop_count += active + + mean_phylop = np.where(phylop_count > 0, phylop_sum / phylop_count, np.nan) + + # ── GC content distribution per feature ────────────────────────── + # Uses all sequences (gc_content is a whole-sequence property) + gc_sum = np.zeros(n_features) + gc_sq_sum = np.zeros(n_features) + gc_count = np.zeros(n_features) + for i in range(n_sequences): + if gc_contents[i] is None: + continue + active = max_acts_np[i] > 0 + gc_val = gc_contents[i] + gc_sum += active * gc_val + gc_sq_sum += active * gc_val**2 + gc_count += active + + gc_mean = np.where(gc_count > 0, gc_sum / gc_count, np.nan).astype(np.float32) + gc_var = np.where(gc_count > 1, gc_sq_sum / gc_count - (gc_sum / np.maximum(gc_count, 1)) ** 2, np.nan) + gc_std = np.where(gc_var >= 0, np.sqrt(np.maximum(gc_var, 0)), np.nan).astype(np.float32) + + # ── Trinuc context distribution per feature ────────────────────── + # Only variant rows (ref rows have no trinuc_context) + unique_trinucs = sorted({t for t in trinuc_contexts if t}) + trinuc_to_idx = {t: i for i, t in enumerate(unique_trinucs)} + n_trinucs = len(unique_trinucs) + print(f" {n_trinucs} unique trinucleotide contexts") + + trinuc_entropy = np.full(n_features, np.nan, dtype=np.float32) + trinuc_dominant_frac = np.full(n_features, np.nan, dtype=np.float32) + + if n_trinucs > 0: + trinuc_counts = np.zeros((n_features, n_trinucs)) + for i in range(n_sequences): + if var_offsets[i] == -1 or not trinuc_contexts[i]: + continue + tidx = trinuc_to_idx.get(trinuc_contexts[i]) + if tidx is None: + continue + active = max_acts_np[i] > 0 + trinuc_counts[:, tidx] += active + + # Vectorized entropy: H = -sum(p * log2(p)) + totals = trinuc_counts.sum(axis=1) + valid = totals > 0 + probs = np.zeros_like(trinuc_counts) + probs[valid] = trinuc_counts[valid] / totals[valid, None] + with np.errstate(divide="ignore", invalid="ignore"): + log_probs = np.where(probs > 0, np.log2(probs), 0.0) + trinuc_entropy_arr = -np.sum(probs * log_probs, axis=1) + trinuc_entropy_arr[~valid] = np.nan + trinuc_entropy = trinuc_entropy_arr.astype(np.float32) + trinuc_dominant_frac = np.where(totals > 0, trinuc_counts.max(axis=1) / totals, np.nan).astype(np.float32) + + # ── Gene distribution per feature ──────────────────────────────── + # Uses all sequences (every row has a gene) + unique_genes = sorted({g for g in genes if g}) + gene_to_idx = {g: i for i, g in enumerate(unique_genes)} + n_genes_total = len(unique_genes) + print(f" {n_genes_total} unique genes") + + gene_entropy = np.full(n_features, np.nan, dtype=np.float32) + gene_n_unique = np.zeros(n_features, dtype=np.int32) + gene_dominant_frac = np.full(n_features, np.nan, dtype=np.float32) + + if n_genes_total > 0: + gene_counts = np.zeros((n_features, n_genes_total)) + for i in range(n_sequences): + if not genes[i]: + continue + gidx = gene_to_idx.get(genes[i]) + if gidx is None: + continue + active = max_acts_np[i] > 0 + gene_counts[:, gidx] += active + + totals = gene_counts.sum(axis=1) + valid = totals > 0 + probs = np.zeros_like(gene_counts) + probs[valid] = gene_counts[valid] / totals[valid, None] + with np.errstate(divide="ignore", invalid="ignore"): + log_probs = np.where(probs > 0, np.log2(probs), 0.0) + gene_entropy_arr = -np.sum(probs * log_probs, axis=1) + gene_entropy_arr[~valid] = np.nan + gene_entropy = gene_entropy_arr.astype(np.float32) + gene_n_unique = (gene_counts > 0).sum(axis=1).astype(np.int32) + gene_dominant_frac = np.where(totals > 0, gene_counts.max(axis=1) / totals, np.nan).astype(np.float32) + + # ── Variant-ref deltas: global, site, and local window ─────────── + gene_groups = defaultdict(lambda: {"ref": None, "variants": []}) + for i, rec in enumerate(records): + g = genes[i] + if not g: + continue + if var_offsets[i] == -1: + gene_groups[g]["ref"] = i + else: + gene_groups[g]["variants"].append(i) + + print(" Computing site-specific and local deltas...") + ref_site_cache = {} # (ref_idx, pos) -> [n_features] + ref_window_cache = {} # (ref_idx, pos) -> [n_features] max over window + all_deltas = [] + + for g, group in gene_groups.items(): + ref_idx = group["ref"] + if ref_idx is None: + continue + ref_acts = max_acts_np[ref_idx] + + needed_positions = set() + for vi in group["variants"]: + vpo = var_offsets[vi] + if vi in site_acts and vpo >= 0: + needed_positions.add(vpo) + + # Single SAE forward pass per ref gene for site + window activations + if needed_positions: + vl = int(valid_lens[ref_idx].item()) + if vl > 0: + emb = activations[ref_idx, :vl, :].to(device) + with torch.no_grad(): + _, ref_codes_full = sae(emb) + ref_codes_cpu = ref_codes_full.cpu() + for pos in needed_positions: + if pos < vl: + ref_site_cache[(ref_idx, pos)] = ref_codes_cpu[pos].numpy() + w_start = max(0, pos - WINDOW_RADIUS) + w_end = min(vl, pos + WINDOW_RADIUS + 1) + ref_window_cache[(ref_idx, pos)] = ref_codes_cpu[w_start:w_end].max(dim=0).values.numpy() + del ref_codes_full, ref_codes_cpu + + for vi in group["variants"]: + delta = max_acts_np[vi] - ref_acts # global delta + vpo = var_offsets[vi] + site_delta = None + local_delta = None + if vi in site_acts and (ref_idx, vpo) in ref_site_cache: + site_delta = site_acts[vi] - ref_site_cache[(ref_idx, vpo)] + if vi in window_max_acts and (ref_idx, vpo) in ref_window_cache: + local_delta = window_max_acts[vi] - ref_window_cache[(ref_idx, vpo)] + sc = primary_scores[vi] if vi < len(primary_scores) else None + all_deltas.append((delta, site_delta, local_delta, sc, sources[vi])) + + # Aggregate deltas + mean_variant_delta = np.zeros(n_features) + mean_site_delta = np.zeros(n_features) + mean_local_delta = np.zeros(n_features) + high_score_delta = np.zeros(n_features) + low_score_delta = np.zeros(n_features) + n_all = n_site = n_local = n_high = n_low = 0 + + for delta, site_delta, local_delta, sc, src in all_deltas: + mean_variant_delta += delta + n_all += 1 + if site_delta is not None: + mean_site_delta += site_delta + n_site += 1 + if local_delta is not None: + mean_local_delta += local_delta + n_local += 1 + if sc is not None: + if sc >= median_score: + high_score_delta += delta + n_high += 1 + else: + low_score_delta += delta + n_low += 1 + + if n_all > 0: + mean_variant_delta /= n_all + if n_site > 0: + mean_site_delta /= n_site + if n_local > 0: + mean_local_delta /= n_local + if n_high > 0: + high_score_delta /= n_high + if n_low > 0: + low_score_delta /= n_low + + n_genes_with_ref = sum(1 for g in gene_groups.values() if g["ref"] is not None) + print(f" {n_genes_with_ref} genes with ref, {n_all} variant-ref pairs ({n_site} site, {n_local} local window)") + print(f" {n_high} high-score, {n_low} low-score, {n_all - n_high - n_low} unscored") + n_clinvar = sum(1 for s in sources if s == "clinvar") + n_cosmic = sum(1 for s in sources if s == "cosmic") + print(f" {n_clinvar} ClinVar, {n_cosmic} COSMIC sequences") + + # Build per-sequence variant_delta for use in examples + variant_delta_map = {} + for g, group in gene_groups.items(): + ref_idx = group["ref"] + if ref_idx is None: + continue + ref_acts = max_acts_np[ref_idx] + for vi in group["variants"]: + variant_delta_map[vi] = max_acts_np[vi] - ref_acts + + # ── Assemble extra_columns ─────────────────────────────────────── + extra_columns = { + "high_score_fraction": high_score_fraction.astype(np.float32), + "clinvar_fraction": clinvar_fraction.astype(np.float32), + "mean_phylop": mean_phylop.astype(np.float32), + "mean_variant_delta": mean_variant_delta.astype(np.float32), + "mean_site_delta": mean_site_delta.astype(np.float32), + "mean_local_delta": mean_local_delta.astype(np.float32), + "high_score_delta": high_score_delta.astype(np.float32), + "low_score_delta": low_score_delta.astype(np.float32), + "gc_mean": gc_mean, + "gc_std": gc_std, + "trinuc_entropy": trinuc_entropy, + "trinuc_dominant_frac": trinuc_dominant_frac, + "gene_entropy": gene_entropy, + "gene_n_unique": gene_n_unique, + "gene_dominant_frac": gene_dominant_frac, + } + # Add per-score-column mean variant scores + extra_columns.update(mean_variant_scores) + + return { + "extra_columns": extra_columns, + "max_acts": max_acts, + "variant_delta_map": variant_delta_map, + } + + +def main(): # noqa: D103 + args = parse_args() + set_seed(args.seed) + device = args.device or get_device() + print(f"Using device: {device}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # 1. Load SAE + sae = load_sae_from_checkpoint(args.checkpoint, top_k_override=args.top_k) + + # 2. Load Encodon + print(f"\nLoading Encodon from {args.model_path}...") + inference = EncodonInference( + model_path=args.model_path, task_type="embedding_prediction", use_transformer_engine=True + ) + inference.configure_model() + inference.model.to(device).eval() + + # 3. Load sequences + max_codons = args.context_length - 2 + records = read_codon_csv( + args.csv_path, + seq_column=args.seq_column, + max_sequences=args.num_sequences, + max_codons=max_codons, + ) + sequences = [r.sequence for r in records] + sequence_ids = [r.id for r in records] + print(f"Loaded {len(sequences)} sequences for dashboard") + + # 4. Extract 3D activations + print("\nExtracting 3D activations...") + activations, masks = extract_activations_3d( + inference, + sequences, + args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + ) + activations_flat = activations[masks.bool()] + print(f" {activations_flat.shape[0]:,} codons, dim={activations_flat.shape[1]}") + + # 5. Feature statistics + print("\n[1/4] Computing feature statistics...") + t0 = time.time() + stats, _ = compute_feature_stats(sae, activations_flat, device=device) + print(f" Done in {time.time() - t0:.1f}s") + + # 6. UMAP from decoder weights + print("[2/4] Computing UMAP from decoder weights...") + t0 = time.time() + geometry = compute_feature_umap( + sae, + n_neighbors=args.umap_n_neighbors, + min_dist=args.umap_min_dist, + random_state=args.seed, + compute_clusters=True, + hdbscan_min_cluster_size=args.hdbscan_min_cluster_size, + ) + print(f" Done in {time.time() - t0:.1f}s") + + # 7. Variant analysis (pathogenic enrichment + variant-ref deltas) + print("[3/5] Computing variant analysis...") + t0 = time.time() + variant_results = compute_variant_analysis( + sae, + records, + activations, + masks, + device=device, + score_column=args.score_column, + ) + print(f" Done in {time.time() - t0:.1f}s") + + # 8. Feature atlas (with variant analysis columns) + print("[4/5] Saving feature atlas...") + t0 = time.time() + atlas_path = output_dir / "features_atlas.parquet" + save_feature_atlas(stats, geometry, atlas_path, extra_columns=variant_results["extra_columns"]) + print(f" Saved to {atlas_path} in {time.time() - t0:.1f}s") + + # 9. Protein/codon examples + print("[5/5] Exporting codon examples...") + t0 = time.time() + export_codon_features_parquet( + sae=sae, + activations=activations, + sequences=sequences, + sequence_ids=sequence_ids, + masks=masks, + output_dir=output_dir, + n_examples=args.n_examples, + device=device, + records=records, + variant_delta_map=variant_results["variant_delta_map"], + precomputed_max_acts=variant_results["max_acts"], + ) + print(f" Done in {time.time() - t0:.1f}s") + + # Free GPU + del inference + torch.cuda.empty_cache() + + print(f"\nDashboard data saved to: {output_dir}") + print(f" Atlas: {atlas_path}") + print(f" Metadata: {output_dir / 'feature_metadata.parquet'}") + print(f" Examples: {output_dir / 'feature_examples.parquet'}") + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/download_codonfm_swissprot.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/download_codonfm_swissprot.py new file mode 100644 index 0000000000..8fc71c76d4 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/download_codonfm_swissprot.py @@ -0,0 +1,426 @@ +# 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. + +"""Download SwissProt proteins with both amino acid annotations and nucleotide CDS sequences. + +For each well-annotated SwissProt protein, fetches the EMBL/ENA cross-reference +to get the actual coding DNA sequence (CDS). This enables F1 evaluation of +codon-level models (like CodoNFM) against protein-level SwissProt annotations. + +Usage: + python scripts/download_codonfm_swissprot.py \ + --output-dir ./data/codonfm_swissprot \ + --max-proteins 8000 \ + --max-length 512 \ + --workers 8 + +Output: + codonfm_swissprot.tsv.gz -- TSV with columns: + accession, protein_sequence, codon_sequence, length, + all annotation columns + summary.json -- stats on coverage, failures, etc. +""" + +import argparse +import gzip +import json +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import requests +from tqdm import tqdm + + +# UniProt annotation feature fields (same as ESM2 pipeline) +UNIPROT_FEATURE_FIELDS = [ + "ft_act_site", + "ft_binding", + "ft_disulfid", + "ft_carbohyd", + "ft_lipid", + "ft_mod_res", + "ft_signal", + "ft_transit", + "ft_helix", + "ft_turn", + "ft_strand", + "ft_coiled", + "ft_compbias", + "ft_domain", + "ft_motif", + "ft_region", + "ft_zn_fing", +] + +CODON_TABLE = { + "TTT": "F", + "TTC": "F", + "TTA": "L", + "TTG": "L", + "CTT": "L", + "CTC": "L", + "CTA": "L", + "CTG": "L", + "ATT": "I", + "ATC": "I", + "ATA": "I", + "ATG": "M", + "GTT": "V", + "GTC": "V", + "GTA": "V", + "GTG": "V", + "TCT": "S", + "TCC": "S", + "TCA": "S", + "TCG": "S", + "CCT": "P", + "CCC": "P", + "CCA": "P", + "CCG": "P", + "ACT": "T", + "ACC": "T", + "ACA": "T", + "ACG": "T", + "GCT": "A", + "GCC": "A", + "GCA": "A", + "GCG": "A", + "TAT": "Y", + "TAC": "Y", + "TAA": "*", + "TAG": "*", + "CAT": "H", + "CAC": "H", + "CAA": "Q", + "CAG": "Q", + "AAT": "N", + "AAC": "N", + "AAA": "K", + "AAG": "K", + "GAT": "D", + "GAC": "D", + "GAA": "E", + "GAG": "E", + "TGT": "C", + "TGC": "C", + "TGA": "*", + "TGG": "W", + "CGT": "R", + "CGC": "R", + "CGA": "R", + "CGG": "R", + "AGT": "S", + "AGC": "S", + "AGA": "R", + "AGG": "R", + "GGT": "G", + "GGC": "G", + "GGA": "G", + "GGG": "G", +} + + +def translate_cds(cds: str) -> str: + """Translate a CDS nucleotide sequence to amino acids (excluding stop codon).""" + protein = [] + for i in range(0, len(cds) - 2, 3): + codon = cds[i : i + 3].upper() + aa = CODON_TABLE.get(codon, "X") + if aa == "*": + break + protein.append(aa) + return "".join(protein) + + +def fetch_embl_cds_ids(accession: str, session: requests.Session) -> List[Dict]: + """Fetch EMBL cross-references for a UniProt accession. + + Returns list of dicts with keys: cds_id, molecule_type, etc. + """ + url = f"https://rest.uniprot.org/uniprotkb/{accession}.json" + resp = session.get(url, timeout=30) + resp.raise_for_status() + data = resp.json() + + cds_refs = [] + for xref in data.get("uniProtKBCrossReferences", []): + if xref.get("database") != "EMBL": + continue + properties = {p["key"]: p["value"] for p in xref.get("properties", [])} + protein_id = properties.get("ProteinId", "") + mol_type = properties.get("MoleculeType", "") + status = properties.get("Status", "") + # Skip entries without a valid protein sequence ID + if protein_id and protein_id != "-" and mol_type != "Genomic_DNA": + cds_refs.append( + { + "embl_id": xref.get("id", ""), + "protein_id": protein_id, + "molecule_type": mol_type, + "status": status, + } + ) + return cds_refs + + +def fetch_ena_cds_sequence(cds_protein_id: str, session: requests.Session) -> Optional[str]: + """Fetch a CDS nucleotide sequence from ENA by its protein ID. + + Tries the ENA CDS FASTA endpoint. + """ + url = f"https://www.ebi.ac.uk/ena/browser/api/fasta/{cds_protein_id}?download=true" + try: + resp = session.get(url, timeout=30) + if resp.status_code != 200: + return None + lines = resp.text.strip().split("\n") + seq = "".join(line.strip() for line in lines if not line.startswith(">")) + if seq and len(seq) >= 3: + return seq.upper() + except Exception: + pass + return None + + +def fetch_cds_for_protein( + accession: str, + protein_sequence: str, + session: requests.Session, + max_retries: int = 2, +) -> Optional[str]: + """Try to find a CDS that translates to match the SwissProt protein sequence. + + Tries each EMBL cross-reference until one matches. + """ + for attempt in range(max_retries): + try: + cds_refs = fetch_embl_cds_ids(accession, session) + break + except Exception: + if attempt < max_retries - 1: + time.sleep(1) + else: + return None + + for ref in cds_refs: + cds_protein_id = ref["protein_id"] + for attempt in range(max_retries): + try: + cds_seq = fetch_ena_cds_sequence(cds_protein_id, session) + break + except Exception: + if attempt < max_retries - 1: + time.sleep(0.5) + else: + cds_seq = None + + if cds_seq is None: + continue + + # Validate: translate and compare to protein sequence + translated = translate_cds(cds_seq) + if translated == protein_sequence: + return cds_seq + + # Try with initial methionine mismatch (some CDS start with alt start codons) + if len(translated) == len(protein_sequence) and translated[1:] == protein_sequence[1:]: + return cds_seq + + return None + + +def download_annotated_proteins_tsv( + max_length: int = 512, + annotation_score: int = 5, + max_results: Optional[int] = None, +) -> str: + """Download annotated proteins from UniProt as TSV string.""" + query_parts = [ + "(reviewed:true)", + f"(annotation_score:{annotation_score})", + f"(length:[1 TO {max_length}])", + ] + query = " AND ".join(query_parts) + + fields = ["accession", "sequence", "length"] + UNIPROT_FEATURE_FIELDS + + url = "https://rest.uniprot.org/uniprotkb/stream" + params = { + "query": query, + "fields": ",".join(fields), + "format": "tsv", + } + if max_results: + params["size"] = max_results + + print(f"Downloading from UniProt: {query}") + resp = requests.get(url, params=params, stream=True) + resp.raise_for_status() + + content = resp.text + lines = content.strip().split("\n") + print(f"Downloaded {len(lines) - 1} proteins from UniProt") + return content + + +def parse_tsv_rows(tsv_content: str) -> Tuple[List[str], List[Dict[str, str]]]: + """Parse TSV content into header + list of row dicts.""" + lines = tsv_content.strip().split("\n") + header = lines[0].split("\t") + rows = [] + for line in lines[1:]: + fields = line.split("\t") + row = {} + for i, col in enumerate(header): + row[col] = fields[i] if i < len(fields) else "" + rows.append(row) + return header, rows + + +def process_single_protein(args_tuple): + """Worker function for parallel CDS fetching.""" + accession, protein_seq, session = args_tuple + cds = fetch_cds_for_protein(accession, protein_seq, session) + return accession, cds + + +def parse_args(): + """Parse command-line arguments.""" + p = argparse.ArgumentParser( + description="Download SwissProt proteins with CDS nucleotide sequences for CodoNFM eval" + ) + p.add_argument("--output-dir", type=str, default="./data/codonfm_swissprot") + p.add_argument("--max-proteins", type=int, default=8000, help="Max proteins to download from UniProt") + p.add_argument("--max-length", type=int, default=512, help="Max protein sequence length") + p.add_argument("--annotation-score", type=int, default=5, help="Min UniProt annotation score (1-5)") + p.add_argument("--workers", type=int, default=8, help="Parallel workers for ENA fetching") + p.add_argument("--rate-limit-delay", type=float, default=0.1, help="Delay between ENA requests (seconds)") + return p.parse_args() + + +def main(): + """Download annotated SwissProt proteins and fetch their CDS sequences from ENA.""" + args = parse_args() + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Step 1: Download annotated proteins from UniProt + print("=" * 60) + print("STEP 1: Download annotated proteins from UniProt") + print("=" * 60) + tsv_content = download_annotated_proteins_tsv( + max_length=args.max_length, + annotation_score=args.annotation_score, + max_results=args.max_proteins, + ) + header, rows = parse_tsv_rows(tsv_content) + + # Step 2: Fetch CDS for each protein + print() + print("=" * 60) + print(f"STEP 2: Fetch CDS nucleotide sequences from ENA ({len(rows)} proteins)") + print("=" * 60) + + session = requests.Session() + session.headers.update({"User-Agent": "BioNeMo-SAE/1.0 (CodoNFM eval pipeline)"}) + + cds_map = {} + failed = [] + + tasks = [(row.get("Entry", row.get("Accession", "")), row.get("Sequence", ""), session) for row in rows] + + # Use thread pool for parallel fetching + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = {} + for task in tasks: + future = executor.submit(process_single_protein, task) + futures[future] = task[0] # accession + + for future in tqdm(as_completed(futures), total=len(futures), desc="Fetching CDS"): + accession = futures[future] + try: + _, cds = future.result() + if cds: + cds_map[accession] = cds + else: + failed.append(accession) + except Exception as e: + print(f" Error for {accession}: {e}") + failed.append(accession) + + print(f"\nCDS fetch results: {len(cds_map)} succeeded, {len(failed)} failed") + + # Step 3: Write output TSV with codon_sequence column + print() + print("=" * 60) + print("STEP 3: Write output dataset") + print("=" * 60) + + output_path = output_dir / "codonfm_swissprot.tsv.gz" + + # Build output header: insert codon_sequence after sequence + out_header = [] + for col in header: + out_header.append(col) + if col.lower() == "sequence": + out_header.append("Codon sequence") + + n_written = 0 + with gzip.open(output_path, "wt") as f: + f.write("\t".join(out_header) + "\n") + for row in rows: + accession = row.get("Entry", row.get("Accession", "")) + if accession not in cds_map: + continue + + cds = cds_map[accession] + out_fields = [] + for col in header: + out_fields.append(row.get(col, "")) + if col.lower() == "sequence": + out_fields.append(cds) + f.write("\t".join(out_fields) + "\n") + n_written += 1 + + print(f"Wrote {n_written} proteins to {output_path}") + + # Step 4: Write summary + summary = { + "total_uniprot_proteins": len(rows), + "cds_found": len(cds_map), + "cds_failed": len(failed), + "proteins_written": n_written, + "coverage_pct": round(100 * len(cds_map) / max(len(rows), 1), 1), + "max_length": args.max_length, + "annotation_score": args.annotation_score, + "output_file": str(output_path), + } + + summary_path = output_dir / "summary.json" + with open(summary_path, "w") as f: + json.dump(summary, f, indent=2) + + print(f"\nSummary saved to {summary_path}") + print(f" Coverage: {summary['coverage_pct']}% ({summary['cds_found']}/{summary['total_uniprot_proteins']})") + print("\nTo use with CodoNFM eval:") + print(f" - Load {output_path}") + print(" - 'Codon sequence' column has the nucleotide CDS") + print(" - All annotation columns are identical to the ESM2 SwissProt format") + print(" - Codon position i maps to amino acid position i (codon = nts 3i..3i+2)") + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval.py new file mode 100644 index 0000000000..a8c5c7fa55 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval.py @@ -0,0 +1,196 @@ +# 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. + +"""Step 3: Evaluate CodonFM SAE (loss recovered). + +Loads a trained SAE checkpoint and evaluates loss recovered against +the Encodon model. F1 and dashboard generation are deferred to future work. + +IMPORTANT: Run on a single GPU. Do NOT use torchrun. + + python scripts/eval.py \ + --checkpoint ./outputs/encodon_1b/checkpoints/checkpoint_final.pt \ + --model-path path/to/encodon_1b \ + --layer -2 --top-k 32 \ + --csv-path path/to/data.csv \ + --output-dir ./outputs/encodon_1b/eval +""" + +import argparse +import json +import sys +from pathlib import Path + +import torch + + +# Use codonfm_ptl_te recipe (has TransformerEngine support) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent.parent +_CODONFM_TE_DIR = _REPO_ROOT / "recipes" / "codonfm_ptl_te" +sys.path.insert(0, str(_CODONFM_TE_DIR)) + +from codonfm_sae.data import read_codon_csv # noqa: E402 +from codonfm_sae.eval import evaluate_codonfm_loss_recovered # noqa: E402 +from sae.architectures import TopKSAE # noqa: E402 +from sae.utils import get_device, set_seed # noqa: E402 +from src.inference.encodon import EncodonInference # noqa: E402 + + +def parse_args(): # noqa: D103 + p = argparse.ArgumentParser(description="Evaluate CodonFM SAE") + + # Checkpoint + p.add_argument("--checkpoint", type=str, required=True, help="Path to SAE checkpoint .pt file") + p.add_argument("--top-k", type=int, default=None, help="Override top-k (default: read from checkpoint)") + + # Model + p.add_argument("--model-path", type=str, required=True, help="Path to Encodon checkpoint") + p.add_argument("--layer", type=int, default=-2) + p.add_argument("--context-length", type=int, default=2048) + p.add_argument("--batch-size", type=int, default=8) + + # Data + p.add_argument("--csv-path", type=str, required=True, help="CSV with DNA sequences for evaluation") + p.add_argument("--seq-column", type=str, default=None) + p.add_argument("--num-sequences", type=int, default=100) + + # Output + p.add_argument("--output-dir", type=str, default="./outputs/eval") + + p.add_argument("--seed", type=int, default=42) + p.add_argument("--device", type=str, default=None) + return p.parse_args() + + +def load_sae_from_checkpoint(checkpoint_path: str, top_k_override: int | None = None) -> TopKSAE: + """Load SAE from a Trainer checkpoint, handling DDP module. prefix.""" + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + + state_dict = ckpt["model_state_dict"] + if any(k.startswith("module.") for k in state_dict): + state_dict = {k.removeprefix("module."): v for k, v in state_dict.items()} + + input_dim = ckpt.get("input_dim") + hidden_dim = ckpt.get("hidden_dim") + if input_dim is None or hidden_dim is None: + w = state_dict["encoder.weight"] + hidden_dim = hidden_dim or w.shape[0] + input_dim = input_dim or w.shape[1] + + model_config = ckpt.get("model_config", {}) + normalize_input = model_config.get("normalize_input", False) + + top_k = top_k_override or model_config.get("top_k") + if top_k is None: + raise ValueError("top_k not found in checkpoint. Pass --top-k explicitly.") + if top_k_override and model_config.get("top_k") and top_k_override != model_config["top_k"]: + print(f" WARNING: overriding checkpoint top_k={model_config['top_k']} with --top-k={top_k_override}") + + sae = TopKSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + top_k=top_k, + normalize_input=normalize_input, + ) + sae.load_state_dict(state_dict) + + print(f"Loaded SAE: {input_dim} -> {hidden_dim:,} latents (top-{top_k}, normalize_input={normalize_input})") + return sae + + +def main(): # noqa: D103 + args = parse_args() + set_seed(args.seed) + device = args.device or get_device() + print(f"Using device: {device}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # 1. Load SAE + sae = load_sae_from_checkpoint(args.checkpoint, top_k_override=args.top_k) + + # 2. Load Encodon model + print(f"Loading Encodon from {args.model_path}...") + inference = EncodonInference( + model_path=args.model_path, task_type="embedding_prediction", use_transformer_engine=True + ) + inference.configure_model() + inference.model.to(device).eval() + + num_layers = len(inference.model.model.layers) + target_layer = args.layer if args.layer >= 0 else num_layers + args.layer + print(f" Layers: {num_layers}, Target layer: {target_layer}, Hidden: {inference.model.model.config.hidden_size}") + + # 3. Load sequences + max_codons = args.context_length - 2 + records = read_codon_csv( + args.csv_path, + seq_column=args.seq_column, + max_sequences=args.num_sequences, + max_codons=max_codons, + ) + sequences = [r.sequence for r in records] + print(f"Loaded {len(sequences)} sequences for evaluation") + + # 4. Loss recovered + print("\n" + "=" * 60) + print("LOSS RECOVERED EVALUATION") + print("=" * 60) + + result = evaluate_codonfm_loss_recovered( + sae=sae, + inference=inference, + sequences=sequences, + layer=args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + seed=args.seed, + ) + + print(f" Loss recovered: {result.loss_recovered:.4f}") + print(f" CE original: {result.ce_original:.4f}") + print(f" CE SAE: {result.ce_sae:.4f}") + print(f" CE zero: {result.ce_zero:.4f}") + print(f" Tokens: {result.n_tokens:,}") + + # Save + lr_path = output_dir / "loss_recovered.json" + with open(lr_path, "w") as f: + json.dump( + { + "loss_recovered": result.loss_recovered, + "ce_original": result.ce_original, + "ce_sae": result.ce_sae, + "ce_zero": result.ce_zero, + "n_tokens": result.n_tokens, + }, + f, + indent=2, + ) + print(f"Saved to {lr_path}") + + del inference + torch.cuda.empty_cache() + + print("\n" + "=" * 60) + print("EVALUATION COMPLETE") + print(f"Results saved to: {output_dir}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval_gene_enrichment.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval_gene_enrichment.py new file mode 100644 index 0000000000..76d943c64e --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval_gene_enrichment.py @@ -0,0 +1,558 @@ +# 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. + +"""Evaluate CodonFM SAE features via gene-level GSEA enrichment. + +For each SAE feature, ranks genes by activation strength and runs GSEA +against GO, InterPro, and Pfam databases to identify biologically +meaningful feature labels. + +IMPORTANT: Run on a single GPU. Do NOT use torchrun. + + python scripts/eval_gene_enrichment.py \ + --checkpoint ./outputs/1b_layer16/checkpoints/checkpoint_final.pt \ + --model-path checkpoints/NV-CodonFM-Encodon-TE-Cdwt-1B-v1/model.safetensors \ + --layer 16 \ + --csv-path /path/to/genes.csv \ + --output-dir ./outputs/1b_layer16/gene_enrichment +""" + +import argparse +import json +import sys +import time +from dataclasses import asdict +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +import torch +from tqdm import tqdm + + +# Use codonfm_ptl_te recipe (has TransformerEngine support) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent.parent +_CODONFM_TE_DIR = _REPO_ROOT / "recipes" / "codonfm_ptl_te" +sys.path.insert(0, str(_CODONFM_TE_DIR)) + +from codonfm_sae.data import read_codon_csv # noqa: E402 +from codonfm_sae.eval.gene_enrichment import ( # noqa: E402 + ANNOTATION_DATABASES, + GeneEnrichmentReport, + download_obo_files, + rollup_go_slim, + run_gene_enrichment, +) +from sae.architectures import TopKSAE # noqa: E402 +from sae.utils import get_device, set_seed # noqa: E402 +from src.data.preprocess.codon_sequence import process_item # noqa: E402 +from src.inference.encodon import EncodonInference # noqa: E402 + + +# ── SAE loading (duplicated from eval_swissprot_f1.py — KISS > DRY) ───── + + +def load_sae_from_checkpoint(checkpoint_path: str, top_k_override: Optional[int] = None) -> TopKSAE: + """Load SAE from a Trainer checkpoint.""" + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + + state_dict = ckpt["model_state_dict"] + if any(k.startswith("module.") for k in state_dict): + state_dict = {k.removeprefix("module."): v for k, v in state_dict.items()} + + input_dim = ckpt.get("input_dim") + hidden_dim = ckpt.get("hidden_dim") + if input_dim is None or hidden_dim is None: + w = state_dict["encoder.weight"] + hidden_dim = hidden_dim or w.shape[0] + input_dim = input_dim or w.shape[1] + + model_config = ckpt.get("model_config", {}) + normalize_input = model_config.get("normalize_input", False) + + top_k = top_k_override or model_config.get("top_k") + if top_k is None: + raise ValueError("top_k not found in checkpoint. Pass --top-k explicitly.") + + sae = TopKSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + top_k=top_k, + normalize_input=normalize_input, + ) + sae.load_state_dict(state_dict) + print(f"Loaded SAE: {input_dim} -> {hidden_dim:,} latents (top-{top_k})") + return sae + + +# ── Activation extraction (duplicated from eval_swissprot_f1.py) ──────── + + +def extract_activations_3d( + inference: "EncodonInference", + sequences: List[str], + layer: int, + context_length: int = 2048, + batch_size: int = 1, + device: str = "cuda", + show_progress: bool = True, +) -> Tuple[torch.Tensor, torch.Tensor]: + """Extract 3D activations from CodonFM for codon sequences. + + Returns: + (activations, masks) where: + - activations: (n_sequences, max_codon_len, hidden_dim) float32, padded + - masks: (n_sequences, max_codon_len) bool, 1=valid codon position + """ + all_embeddings = [] + all_masks = [] + + n_batches = (len(sequences) + batch_size - 1) // batch_size + iterator = range(0, len(sequences), batch_size) + if show_progress: + iterator = tqdm(iterator, total=n_batches, desc="Extracting activations") + + with torch.no_grad(): + for i in iterator: + batch_seqs = sequences[i : i + batch_size] + items = [process_item(s, context_length=context_length, tokenizer=inference.tokenizer) for s in batch_seqs] + + batch = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + + out = inference.model(batch, return_hidden_states=True) + layer_acts = out.all_hidden_states[layer] # [B, L, D] + + for j, it in enumerate(items): + seq_len = it["attention_mask"].sum() + # Strip CLS (pos 0) and SEP (last real pos) + acts = layer_acts[j, 1 : seq_len - 1, :].float().cpu() # [n_codons, D] + n_codons = acts.shape[0] + mask = torch.ones(n_codons, dtype=torch.bool) + all_embeddings.append(acts) + all_masks.append(mask) + + del out, layer_acts, batch + + # Pad to same length for 3D stacking + max_len = max(e.shape[0] for e in all_embeddings) + hidden_dim = all_embeddings[0].shape[1] + + padded_emb = [] + padded_masks = [] + for emb, msk in zip(all_embeddings, all_masks): + L = emb.shape[0] + if L < max_len: + emb = torch.cat([emb, torch.zeros(max_len - L, hidden_dim)], dim=0) + msk = torch.cat([msk, torch.zeros(max_len - L, dtype=torch.bool)]) + padded_emb.append(emb.unsqueeze(0)) + padded_masks.append(msk.unsqueeze(0)) + + return torch.cat(padded_emb, dim=0), torch.cat(padded_masks, dim=0) + + +# ── Gene-level activation computation ─────────────────────────────────── + + +def compute_gene_activations( + sae: torch.nn.Module, + activations: torch.Tensor, + masks: torch.Tensor, + gene_names: List[str], + device: str = "cuda", + show_progress: bool = True, +) -> Dict[int, Dict[str, float]]: + """Compute per-gene, per-feature max activations via streaming SAE encode. + + For each sequence, encodes through SAE and takes the max activation per + feature across all valid codon positions. Then groups by gene name and + takes the max across sequences within each gene. + + Args: + sae: Trained SAE model. + activations: (n_sequences, max_len, hidden_dim) padded activations. + masks: (n_sequences, max_len) bool masks. + gene_names: Gene name for each sequence (len == n_sequences). + device: Device for SAE encoding. + show_progress: Whether to show progress bar. + + Returns: + feature_idx -> gene_name -> max activation score. + """ + sae = sae.eval().to(device) + n_sequences = activations.shape[0] + n_features = sae.hidden_dim + + # Phase 1: Compute per-sequence max activations -> (n_sequences, n_features) + # Stream one sequence at a time to avoid OOM + seq_max_acts = np.zeros((n_sequences, n_features), dtype=np.float32) + + iterator = range(n_sequences) + if show_progress: + iterator = tqdm(iterator, desc="SAE encode (per-sequence max)") + + with torch.no_grad(): + for seq_idx in iterator: + emb = activations[seq_idx].to(device) # (max_len, hidden_dim) + mask = masks[seq_idx].numpy().astype(bool) + sae_acts = sae.encode(emb).cpu().numpy() # (max_len, n_features) + + # Apply mask + seq_len = min(len(mask), sae_acts.shape[0]) + valid_acts = sae_acts[:seq_len][mask[:seq_len]] + + if valid_acts.shape[0] > 0: + seq_max_acts[seq_idx] = valid_acts.max(axis=0) + + # Phase 2: Group by gene and take max + df = pd.DataFrame(seq_max_acts) + df["gene"] = gene_names + gene_max = df.groupby("gene").max() # (n_genes, n_features) + + # Convert to dict[feature_idx, dict[gene, score]] + result: Dict[int, Dict[str, float]] = {} + for feat_idx in range(n_features): + col = gene_max[feat_idx] + # Only include genes with non-zero activation + nonzero = col[col > 0] + if len(nonzero) > 0: + result[feat_idx] = nonzero.to_dict() + + return result + + +# ── Report serialization ──────────────────────────────────────────────── + + +def _enrichment_result_to_dict(er): + """Convert EnrichmentResult to JSON-serializable dict.""" + if er is None: + return None + return asdict(er) + + +def save_report_json(report: GeneEnrichmentReport, path: Path): + """Save GeneEnrichmentReport to JSON.""" + data = { + "databases_used": report.databases_used, + "n_features_with_enrichment": report.n_features_with_enrichment, + "n_features_total": report.n_features_total, + "frac_enriched": report.frac_enriched, + "per_database_stats": report.per_database_stats, + "significance_threshold": report.significance_threshold, + "per_feature": [], + } + + for fl in report.per_feature: + entry = { + "feature_idx": fl.feature_idx, + "overall_best": _enrichment_result_to_dict(fl.overall_best), + "go_slim_term": fl.go_slim_term, + "go_slim_name": fl.go_slim_name, + "best_per_database": {db: _enrichment_result_to_dict(er) for db, er in fl.best_per_database.items()}, + "n_significant": len(fl.all_significant), + } + data["per_feature"].append(entry) + + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def save_enrichment_parquet(report: GeneEnrichmentReport, path: Path): + """Save all significant enrichment results to a parquet file.""" + rows = [] + for fl in report.per_feature: + for er in fl.all_significant: + rows.append(asdict(er)) + + if rows: + df = pd.DataFrame(rows) + df.to_parquet(path, index=False, engine="pyarrow") + else: + # Empty parquet with correct schema + df = pd.DataFrame( + columns=[ + "feature_idx", + "term_id", + "term_name", + "database", + "enrichment_score", + "pvalue", + "fdr", + "n_genes_in_term", + ] + ) + df.to_parquet(path, index=False, engine="pyarrow") + + +def update_atlas_with_gsea(report: GeneEnrichmentReport, atlas_path: Path): + """Append gsea_* columns to features_atlas.parquet.""" + import pyarrow as pa + import pyarrow.parquet as pq + + if not atlas_path.exists(): + print(f" WARNING: {atlas_path} does not exist, skipping atlas update") + return + + table = pq.read_table(atlas_path) + n = table.num_rows + + for col_name, label_dict in report.feature_label_columns.items(): + parquet_col = f"gsea_{col_name}" + values = [label_dict.get(i, "unlabeled") for i in range(n)] + + # Drop existing column if present + if parquet_col in table.column_names: + table = table.drop(parquet_col) + table = table.append_column(parquet_col, pa.array(values)) + + pq.write_table(table, atlas_path, compression="snappy") + print(f" Updated {atlas_path} with {len(report.feature_label_columns)} GSEA columns") + + +# ── CLI ────────────────────────────────────────────────────────────────── + + +def parse_args(): + """Parse command-line arguments.""" + p = argparse.ArgumentParser(description="Evaluate CodonFM SAE features via gene-level GSEA enrichment") + + # SAE checkpoint + p.add_argument("--checkpoint", type=str, required=True, help="Path to SAE checkpoint .pt file") + p.add_argument("--top-k", type=int, default=None, help="Override top-k (default: read from checkpoint)") + + # Encodon model + p.add_argument("--model-path", type=str, required=True, help="Path to Encodon checkpoint (.safetensors)") + p.add_argument("--layer", type=int, default=16) + p.add_argument("--context-length", type=int, default=2048) + p.add_argument("--batch-size", type=int, default=8) + + # Data + p.add_argument("--csv-path", type=str, required=True, help="CSV with gene sequences (must have 'gene' column)") + p.add_argument("--num-sequences", type=int, default=None, help="Max sequences to process (default: all)") + + # GSEA parameters + p.add_argument("--n-workers", type=int, default=4, help="Parallel workers for GSEA") + p.add_argument("--fdr-threshold", type=float, default=0.05, help="FDR threshold for significance") + p.add_argument( + "--databases", + type=str, + nargs="+", + default=None, + help="Enrichr library names (default: GO + InterPro + Pfam)", + ) + + # GO Slim + p.add_argument("--no-go-slim", action="store_true", help="Skip GO Slim rollup") + p.add_argument("--obo-dir", type=str, default=None, help="Directory for OBO files (default: output-dir/obo)") + + # Output + p.add_argument("--output-dir", type=str, default="./outputs/gene_enrichment") + p.add_argument( + "--dashboard-dir", + type=str, + default=None, + help="If provided, updates features_atlas.parquet with GSEA columns", + ) + + p.add_argument("--seed", type=int, default=42) + p.add_argument("--device", type=str, default=None) + return p.parse_args() + + +def main(): + """Run gene-level GSEA enrichment evaluation.""" + args = parse_args() + set_seed(args.seed) + device = args.device or get_device() + print(f"Using device: {device}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + databases = args.databases or list(ANNOTATION_DATABASES) + + # 1. Load SAE + print("\n" + "=" * 60) + print("LOADING SAE") + print("=" * 60) + sae = load_sae_from_checkpoint(args.checkpoint, top_k_override=args.top_k) + + # 2. Load CSV and extract gene names + print("\n" + "=" * 60) + print("LOADING DATA") + print("=" * 60) + records = read_codon_csv(args.csv_path, max_sequences=args.num_sequences) + print(f"Loaded {len(records)} sequences") + + # Extract gene names from records + gene_names = [] + valid_records = [] + for rec in records: + gene = rec.metadata.get("gene") + if gene is not None and str(gene).strip(): + gene_names.append(str(gene).strip()) + valid_records.append(rec) + + if not valid_records: + print("ERROR: No sequences with gene names found. CSV must have a 'gene' column.") + return + + print(f" {len(valid_records)} sequences with gene names ({len(set(gene_names))} unique genes)") + sequences = [rec.sequence for rec in valid_records] + + # 3. Load Encodon model + print(f"\nLoading Encodon from {args.model_path}...") + inference = EncodonInference( + model_path=args.model_path, task_type="embedding_prediction", use_transformer_engine=True + ) + inference.configure_model() + inference.model.to(device).eval() + + num_layers = len(inference.model.model.layers) + target_layer = args.layer if args.layer >= 0 else num_layers + args.layer + print(f" Layers: {num_layers}, Target layer: {target_layer}") + + # 4. Extract 3D activations + print("\n" + "=" * 60) + print("EXTRACTING ACTIVATIONS") + print("=" * 60) + activations, masks = extract_activations_3d( + inference, + sequences, + args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + ) + print(f" Activations shape: {activations.shape}") + + # Free Encodon model memory + del inference + torch.cuda.empty_cache() + + # 5. Compute per-gene activation matrix (or load from cache) + gene_acts_cache = output_dir / "gene_activations_cache.json" + if gene_acts_cache.exists(): + print("\n" + "=" * 60) + print("LOADING CACHED GENE ACTIVATIONS") + print("=" * 60) + with open(gene_acts_cache) as f: + raw = json.load(f) + gene_activations = {int(k): v for k, v in raw.items()} + print(f" Loaded {len(gene_activations)} features from cache") + + # Free GPU resources we don't need + del activations, masks + torch.cuda.empty_cache() + sae = sae.cpu() + else: + print("\n" + "=" * 60) + print("COMPUTING PER-GENE ACTIVATIONS") + print("=" * 60) + gene_activations = compute_gene_activations(sae, activations, masks, gene_names, device=device) + print(f" {len(gene_activations)} features with non-zero gene activations") + + # Free activations memory + del activations, masks + torch.cuda.empty_cache() + + # Move SAE to CPU to free GPU + sae = sae.cpu() + + # Cache gene activations so we can restart from here + print(f" Caching gene activations to {gene_acts_cache}...") + with open(gene_acts_cache, "w") as f: + json.dump({str(k): v for k, v in gene_activations.items()}, f) + + # 6. Run GSEA + print("\n" + "=" * 60) + print("RUNNING GSEA ENRICHMENT") + print("=" * 60) + print(f" Databases: {databases}") + print(f" FDR threshold: {args.fdr_threshold}") + print(f" Workers: {args.n_workers}") + print(f" Features to process: {len(gene_activations)}") + + t0 = time.time() + report = run_gene_enrichment( + gene_activations=gene_activations, + databases=databases, + fdr_threshold=args.fdr_threshold, + n_workers=args.n_workers, + ) + gsea_time = time.time() - t0 + print(f"\n GSEA completed in {gsea_time:.1f}s") + + # 7. Save results (before GO Slim so we don't lose GSEA work on failure) + print("\n" + "=" * 60) + print("SAVING RESULTS") + print("=" * 60) + + report_path = output_dir / "gene_enrichment_report.json" + save_report_json(report, report_path) + print(f" Saved report to {report_path}") + + parquet_path = output_dir / "enrichment_results.parquet" + save_enrichment_parquet(report, parquet_path) + print(f" Saved enrichment results to {parquet_path}") + + # 8. Optional GO Slim rollup + if not args.no_go_slim: + print("\n" + "=" * 60) + print("GO SLIM ROLLUP") + print("=" * 60) + obo_dir = args.obo_dir or str(output_dir / "obo") + go_basic_path, go_slim_path = download_obo_files(obo_dir) + rollup_go_slim(report.per_feature, str(go_basic_path), str(go_slim_path)) + + # Rebuild label columns with GO Slim info + from codonfm_sae.eval.gene_enrichment import build_feature_label_columns + + report.feature_label_columns = build_feature_label_columns(report.per_feature, report.n_features_total) + + n_slim = sum(1 for fl in report.per_feature if fl.go_slim_name is not None) + slim_names = {fl.go_slim_name for fl in report.per_feature if fl.go_slim_name is not None} + print(f" {n_slim} features mapped to {len(slim_names)} GO Slim categories") + + # Re-save with GO Slim data + save_report_json(report, report_path) + print(" Updated report with GO Slim labels") + + # 9. Update dashboard atlas if requested + if args.dashboard_dir: + dashboard_dir = Path(args.dashboard_dir) + atlas_path = dashboard_dir / "features_atlas.parquet" + update_atlas_with_gsea(report, atlas_path) + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f" Features total: {report.n_features_total}") + print(f" Features with enrichment: {report.n_features_with_enrichment}") + print(f" Fraction enriched: {report.frac_enriched:.3f}") + for db, stats in report.per_database_stats.items(): + print(f" {db}: {stats['n_enriched']} enriched, {stats['n_unique_terms']} unique terms") + + print(f"\nAll results saved to: {output_dir}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval_swissprot_f1.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval_swissprot_f1.py new file mode 100644 index 0000000000..659a18ac30 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/eval_swissprot_f1.py @@ -0,0 +1,860 @@ +# 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. + +"""Evaluate CodoNFM SAE features against SwissProt annotations via F1 scores. + +Uses the codonfm_swissprot dataset (produced by download_codonfm_swissprot.py) +which contains both CDS nucleotide sequences and residue-level SwissProt annotations. + +Since each codon maps 1:1 to an amino acid position, SwissProt annotations +transfer directly: annotation at amino acid position i → SAE feature at codon position i. + +IMPORTANT: Run on a single GPU. Do NOT use torchrun. + + python scripts/eval_swissprot_f1.py \ + --checkpoint ./outputs/1b_layer16/checkpoints/checkpoint_final.pt \ + --model-path checkpoints/NV-CodonFM-Encodon-TE-Cdwt-1B-v1/model.safetensors \ + --layer 16 \ + --swissprot-tsv ./data/codonfm_swissprot/codonfm_swissprot.tsv.gz \ + --output-dir ./outputs/1b_layer16/swissprot_eval +""" + +import argparse +import json +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +import torch +from tqdm import tqdm + + +# Use codonfm_ptl_te recipe (has TransformerEngine support) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent.parent +_CODONFM_TE_DIR = _REPO_ROOT / "recipes" / "codonfm_ptl_te" +sys.path.insert(0, str(_CODONFM_TE_DIR)) + +from sae.architectures import TopKSAE # noqa: E402 +from sae.utils import get_device, set_seed # noqa: E402 +from src.data.preprocess.codon_sequence import process_item # noqa: E402 +from src.inference.encodon import EncodonInference # noqa: E402 + + +# ── Annotation parsing (adapted from esm2_sae) ───────────────────────── + +FEATURE_COLUMNS = { + "active_site": "ACT_SITE", + "binding_site": "BINDING", + "disulfide_bond": "DISULFID", + "glycosylation": "CARBOHYD", + "lipidation": "LIPID", + "modified_residue": "MOD_RES", + "signal_peptide": "SIGNAL", + "transit_peptide": "TRANSIT", + "helix": "HELIX", + "turn": "TURN", + "beta_strand": "STRAND", + "coiled_coil": "COILED", + "compositional_bias": "COMPBIAS", + "domain_[ft]": "DOMAIN", + "motif": "MOTIF", + "region": "REGION", + "zinc_finger": "ZN_FING", +} + +AA_LEVEL_PREFIXES = ("ACT_SITE", "MOD_RES", "CARBOHYD", "DISULFID") + + +@dataclass +class AnnotatedCodonProtein: + """Protein with CDS sequence and parsed annotations (at codon/AA level).""" + + accession: str + codon_sequence: str # DNA CDS + protein_sequence: str # amino acid sequence + annotations: Dict[str, np.ndarray] # concept -> array of shape (n_codons,) + + +@dataclass +class F1Result: + """Single feature-concept pair result.""" + + feature_idx: int + concept: str + f1: float + precision: float + recall: float + threshold: float + f1_domain: float = 0.0 + recall_domain: float = 0.0 + + +def parse_position(pos_str: str) -> Optional[Tuple[int, int]]: + """Parse a UniProt position string to (start, end) tuple (0-indexed).""" + if not pos_str or "?" in pos_str or ":" in pos_str: + return None + pos_str = pos_str.strip() + if ".." in pos_str: + parts = pos_str.split("..") + start = parts[0].strip("<").strip() + end = parts[1].strip(">").strip() + try: + return (int(start) - 1, int(end)) + except ValueError: + return None + else: + pos_str = pos_str.strip("<>").strip() + try: + pos = int(pos_str) + return (pos - 1, pos) + except ValueError: + return None + + +def parse_annotation_field( + field_value: str, + seq_length: int, + domain_counter: Optional[Dict[str, int]] = None, +) -> Dict[str, np.ndarray]: + """Parse a UniProt annotation field into position-level arrays.""" + if pd.isna(field_value) or not field_value.strip(): + return {} + + results = {} + parts = field_value.split("; ") + + annotations = [] + for part in parts: + part = part.strip().rstrip(";") + if not part: + continue + if part.startswith("/"): + if annotations: + annotations[-1][1].append(part) + else: + annotations.append((part, [])) + + for type_pos, qualifiers in annotations: + tokens = type_pos.split(None, 1) + if len(tokens) < 2: + continue + + ann_type = tokens[0] + pos_str = tokens[1] + pos = parse_position(pos_str) + if pos is None: + continue + + start, end = pos + if start < 0 or end > seq_length: + continue + + note = None + for q in qualifiers: + note_match = re.search(r'/note="([^"]*)"', q) + if note_match: + note = note_match.group(1) + break + + concept = f"{ann_type}:{note}" if note else ann_type + + if concept not in results: + results[concept] = np.zeros(seq_length, dtype=np.float32) + + if domain_counter is not None: + domain_counter[concept] = domain_counter.get(concept, 0) + 1 + results[concept][start:end] = domain_counter[concept] + else: + results[concept][start:end] = 1.0 + + return results + + +def load_swissprot_codon_dataset( + tsv_path: str, + min_positives: int = 10, + max_proteins: Optional[int] = None, +) -> Tuple[List[AnnotatedCodonProtein], Dict[str, int]]: + """Load the codonfm_swissprot TSV with codon sequences + annotations. + + Annotations are indexed at the amino acid / codon level (1:1 mapping). + """ + tsv_path = Path(tsv_path) + if str(tsv_path).endswith(".gz"): + df = pd.read_csv(tsv_path, sep="\t", compression="gzip") + else: + df = pd.read_csv(tsv_path, sep="\t") + + if max_proteins: + df = df.head(max_proteins) + + df.columns = df.columns.str.lower().str.replace(" ", "_") + + proteins = [] + concept_counts = {} + domain_counter = {} + + for _, row in df.iterrows(): + accession = row.get("accession", row.get("entry", "")) + protein_seq = row.get("sequence", "") + codon_seq = row.get("codon_sequence", "") + + if not protein_seq or not codon_seq: + continue + + # Number of codons = number of amino acids + n_codons = len(protein_seq) + all_annotations = {} + + for col, ann_type in FEATURE_COLUMNS.items(): + if col not in df.columns: + continue + field_value = row.get(col, "") + parsed = parse_annotation_field(str(field_value), n_codons, domain_counter) + for concept, arr in parsed.items(): + all_annotations[concept] = arr + concept_counts[concept] = concept_counts.get(concept, 0) + int((arr > 0).sum()) + + if all_annotations: + proteins.append( + AnnotatedCodonProtein( + accession=accession, + codon_sequence=codon_seq, + protein_sequence=protein_seq, + annotations=all_annotations, + ) + ) + + # Filter concepts by min_positives + valid_concepts = {c for c, count in concept_counts.items() if count >= min_positives} + for protein in proteins: + protein.annotations = {c: arr for c, arr in protein.annotations.items() if c in valid_concepts} + + filtered_counts = {c: count for c, count in concept_counts.items() if c in valid_concepts} + print(f"Loaded {len(proteins)} proteins with {len(filtered_counts)} concepts (min_positives={min_positives})") + return proteins, filtered_counts + + +# ── Activation extraction ──────────────────────────────────────────────── + + +def extract_activations_3d( + inference: "EncodonInference", + sequences: List[str], + layer: int, + context_length: int = 2048, + batch_size: int = 1, + device: str = "cuda", + show_progress: bool = True, +) -> Tuple[torch.Tensor, torch.Tensor]: + """Extract 3D activations from CodoNFM for codon sequences. + + Returns: + (activations, masks) where: + - activations: (n_sequences, max_codon_len, hidden_dim) float32, padded + - masks: (n_sequences, max_codon_len) bool, 1=valid codon position + """ + all_embeddings = [] + all_masks = [] + + n_batches = (len(sequences) + batch_size - 1) // batch_size + iterator = range(0, len(sequences), batch_size) + if show_progress: + iterator = tqdm(iterator, total=n_batches, desc="Extracting activations") + + with torch.no_grad(): + for i in iterator: + batch_seqs = sequences[i : i + batch_size] + items = [process_item(s, context_length=context_length, tokenizer=inference.tokenizer) for s in batch_seqs] + + batch = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + + out = inference.model(batch, return_hidden_states=True) + layer_acts = out.all_hidden_states[layer] # [B, L, D] + + for j, it in enumerate(items): + seq_len = it["attention_mask"].sum() + # Strip CLS (pos 0) and SEP (last real pos) + acts = layer_acts[j, 1 : seq_len - 1, :].float().cpu() # [n_codons, D] + n_codons = acts.shape[0] + mask = torch.ones(n_codons, dtype=torch.bool) + all_embeddings.append(acts) + all_masks.append(mask) + + del out, layer_acts, batch + + # Pad to same length for 3D stacking + max_len = max(e.shape[0] for e in all_embeddings) + hidden_dim = all_embeddings[0].shape[1] + + padded_emb = [] + padded_masks = [] + for emb, msk in zip(all_embeddings, all_masks): + L = emb.shape[0] + if L < max_len: + emb = torch.cat([emb, torch.zeros(max_len - L, hidden_dim)], dim=0) + msk = torch.cat([msk, torch.zeros(max_len - L, dtype=torch.bool)]) + padded_emb.append(emb.unsqueeze(0)) + padded_masks.append(msk.unsqueeze(0)) + + return torch.cat(padded_emb, dim=0), torch.cat(padded_masks, dim=0) + + +# ── F1 computation (adapted from esm2_sae/eval/f1.py) ─────────────────── + + +def compute_activation_max( + sae: torch.nn.Module, + embeddings: torch.Tensor, + masks: torch.Tensor, + device: str = "cuda", +) -> np.ndarray: + """Compute per-feature max activation for normalization.""" + sae = sae.eval().to(device) + n_seqs = embeddings.shape[0] + running_max = None + + with torch.no_grad(): + for i in range(n_seqs): + emb = embeddings[i].to(device) + acts = sae.encode(emb) + acts_np = acts.cpu().numpy() + + valid = masks[i].numpy().astype(bool) + seq_len = min(len(valid), acts_np.shape[0]) + acts_np = acts_np[:seq_len][valid[:seq_len]] + + seq_max = acts_np.max(axis=0) + if running_max is None: + running_max = seq_max.copy() + else: + np.maximum(running_max, seq_max, out=running_max) + + return running_max + + +def compute_f1_scores( + sae: torch.nn.Module, + embeddings: torch.Tensor, + concept_labels: List[Dict[str, np.ndarray]], + masks: torch.Tensor, + thresholds: Optional[List[float]] = None, + min_positives: int = 10, + device: str = "cuda", + show_progress: bool = True, + activation_max: Optional[np.ndarray] = None, +) -> List[F1Result]: + """Compute F1 scores between SAE features and biological concepts.""" + if thresholds is None: + thresholds = [0.0, 0.15, 0.5, 0.6, 0.8] + + n_seqs, seq_len_max, _hidden_dim = embeddings.shape + assert len(concept_labels) == n_seqs + + sae = sae.eval().to(device) + n_features = sae.hidden_dim + n_thresholds = len(thresholds) + thresholds_arr = np.array(thresholds) + + if activation_max is None: + print("Computing per-feature activation max for normalization...") + activation_max = compute_activation_max(sae, embeddings, masks, device) + act_max_full = np.where(activation_max > 0, activation_max, 1.0).astype(np.float32) + + # Collect concept metadata + all_concepts: set = set() + for seq_concepts in concept_labels: + all_concepts.update(seq_concepts.keys()) + + concept_total_pos: Dict[str, int] = {} + concept_n_domains: Dict[str, int] = {} + concept_is_aa: Dict[str, bool] = {} + + for concept in all_concepts: + total_pos = 0 + domain_ids: set = set() + is_aa = any(concept.startswith(p) for p in AA_LEVEL_PREFIXES) + + for seq_idx in range(n_seqs): + if concept not in concept_labels[seq_idx]: + continue + labels = concept_labels[seq_idx][concept] + seq_len = min(len(labels), seq_len_max) + labels = labels[:seq_len] + if masks is not None: + valid = masks[seq_idx].numpy()[:seq_len].astype(bool) + labels_v = labels[valid] + else: + labels_v = labels + total_pos += int((labels_v > 0).sum()) + if not is_aa: + domain_ids.update(labels_v[labels_v > 0].tolist()) + + if total_pos >= min_positives: + concept_total_pos[concept] = total_pos + concept_is_aa[concept] = is_aa + if not is_aa: + concept_n_domains[concept] = len(domain_ids) + + valid_concepts = set(concept_total_pos.keys()) + if not valid_concepts: + return [] + + # Initialize accumulators + tp = {c: np.zeros((n_features, n_thresholds), dtype=np.float64) for c in valid_concepts} + fp = {c: np.zeros((n_features, n_thresholds), dtype=np.float64) for c in valid_concepts} + domains_hit = { + c: np.zeros((n_features, n_thresholds), dtype=np.int64) for c in valid_concepts if not concept_is_aa[c] + } + + # Stream through sequences + seq_iter = range(n_seqs) + if show_progress: + seq_iter = tqdm(seq_iter, desc="Computing F1 scores") + + with torch.no_grad(): + for seq_idx in seq_iter: + seq_concepts = concept_labels[seq_idx] + relevant = [c for c in seq_concepts if c in valid_concepts] + if not relevant: + continue + + emb = embeddings[seq_idx].to(device) + acts_full = sae.encode(emb).cpu().numpy() + + for concept in relevant: + labels = seq_concepts[concept] + seq_len = min(len(labels), acts_full.shape[0]) + labels = labels[:seq_len] + acts = acts_full[:seq_len] + + if masks is not None: + valid = masks[seq_idx].numpy()[:seq_len].astype(bool) + else: + valid = np.ones(seq_len, dtype=bool) + + labels_valid = labels[valid] + labels_bool = labels_valid > 0 + if not labels_bool.any(): + continue + + acts_valid = acts[valid] + acts_valid = acts_valid / act_max_full + + is_aa = concept_is_aa[concept] + domain_masks = None + if not is_aa and concept in domains_hit: + unique_ids = np.unique(labels_valid[labels_valid > 0]) + domain_masks = [(d_id, labels_valid == d_id) for d_id in unique_ids] + + for t_idx, thresh in enumerate(thresholds): + preds = acts_valid > thresh + tp_mask = preds & labels_bool[:, None] + tp[concept][:, t_idx] += tp_mask.sum(axis=0) + fp[concept][:, t_idx] += (preds & ~labels_bool[:, None]).sum(axis=0) + + if domain_masks is not None: + for _d_id, is_d in domain_masks: + tp_d = tp_mask[is_d] + if tp_d.shape[0] > 0: + hit = tp_d.any(axis=0) + domains_hit[concept][:, t_idx] += hit.astype(np.int64) + + del acts_full + + # Compute final F1 + results = [] + for concept in valid_concepts: + total_pos = concept_total_pos[concept] + is_aa = concept_is_aa[concept] + n_domains = concept_n_domains.get(concept, 0) + + tp_arr = tp[concept] + fp_arr = fp[concept] + + precision = np.where(tp_arr + fp_arr > 0, tp_arr / (tp_arr + fp_arr), 0.0) + recall = tp_arr / total_pos + f1 = np.where(precision + recall > 0, 2 * precision * recall / (precision + recall), 0.0) + + if is_aa: + recall_domain = recall + f1_domain = f1 + elif n_domains > 0: + recall_domain = domains_hit[concept].astype(np.float64) / n_domains + f1_domain = np.where( + precision + recall_domain > 0, + 2 * precision * recall_domain / (precision + recall_domain), + 0.0, + ) + else: + recall_domain = np.zeros_like(recall) + f1_domain = np.zeros_like(f1) + + best_thresh_idx = f1_domain.argmax(axis=1) + feat_indices = np.arange(n_features) + best_f1 = f1[feat_indices, best_thresh_idx] + best_precision = precision[feat_indices, best_thresh_idx] + best_recall = recall[feat_indices, best_thresh_idx] + best_thresh = thresholds_arr[best_thresh_idx] + best_f1_domain = f1_domain[feat_indices, best_thresh_idx] + best_recall_domain = recall_domain[feat_indices, best_thresh_idx] + + for feat_idx in range(n_features): + if best_f1_domain[feat_idx] > 0: + results.append( + F1Result( + feature_idx=feat_idx, + concept=concept, + f1=float(best_f1[feat_idx]), + precision=float(best_precision[feat_idx]), + recall=float(best_recall[feat_idx]), + threshold=float(best_thresh[feat_idx]), + f1_domain=float(best_f1_domain[feat_idx]), + recall_domain=float(best_recall_domain[feat_idx]), + ) + ) + + return results + + +# ── Label building ─────────────────────────────────────────────────────── + + +def build_f1_labels(val_results, n_features, f1_threshold): + """Build feature labels from F1 results.""" + best_per_feature = {} + for r in val_results: + if r.feature_idx not in best_per_feature or r.f1_domain > best_per_feature[r.feature_idx].f1_domain: + best_per_feature[r.feature_idx] = r + + labels = [] + feature_stats = {} + for i in range(n_features): + if i in best_per_feature and best_per_feature[i].f1_domain >= f1_threshold: + r = best_per_feature[i] + ann_short = r.concept.split(":")[-1] if ":" in r.concept else r.concept + labels.append(f"{ann_short} (F1:{r.f1_domain:.2f})") + feature_stats[i] = { + "best_annotation": r.concept, + "best_f1": float(r.f1_domain), + } + else: + labels.append(f"Feature {i}") + + n_labeled = sum(1 for label in labels if not label.startswith("Feature ")) + print(f" {n_labeled}/{n_features} features labeled (F1 >= {f1_threshold})") + return labels, feature_stats + + +def build_f1_summary(val_results, test_results, f1_threshold): + """Build summary dict from val/test F1 results.""" + test_lookup = {} + for r in test_results: + key = (r.feature_idx, r.concept) + if key not in test_lookup or r.f1_domain > test_lookup[key].f1_domain: + test_lookup[key] = r + + best_per_concept_val = {} + for r in val_results: + if r.concept not in best_per_concept_val or r.f1_domain > best_per_concept_val[r.concept].f1_domain: + best_per_concept_val[r.concept] = r + + test_matched = [] + for concept, val_r in best_per_concept_val.items(): + key = (val_r.feature_idx, concept) + if key in test_lookup: + test_matched.append(test_lookup[key]) + + n_above_threshold_val = sum(1 for r in best_per_concept_val.values() if r.f1_domain > f1_threshold) + n_above_threshold_both = sum( + 1 + for concept, val_r in best_per_concept_val.items() + if val_r.f1_domain > f1_threshold + and (val_r.feature_idx, concept) in test_lookup + and test_lookup[(val_r.feature_idx, concept)].f1_domain > f1_threshold + ) + + test_f1d_vals = [r.f1_domain for r in test_matched] if test_matched else [0.0] + top_pairs = sorted(test_matched, key=lambda x: x.f1_domain, reverse=True)[:10] + + return { + "n_pairs_val": len(val_results), + "n_pairs_test": len(test_results), + "n_concepts_matched": len(test_matched), + "mean_f1_domain_test": float(np.mean(test_f1d_vals)), + "max_f1_domain_test": float(np.max(test_f1d_vals)), + "n_above_threshold_val": n_above_threshold_val, + "n_pairs_above_threshold_both": n_above_threshold_both, + "f1_threshold": f1_threshold, + "top_pairs": [ + { + "feature": r.feature_idx, + "concept": r.concept, + "f1_domain": r.f1_domain, + "f1": r.f1, + "precision": r.precision, + "recall_domain": r.recall_domain, + } + for r in top_pairs + ], + } + + +# ── SAE loading ────────────────────────────────────────────────────────── + + +def load_sae_from_checkpoint(checkpoint_path: str, top_k_override: Optional[int] = None) -> TopKSAE: + """Load SAE from a Trainer checkpoint.""" + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + + state_dict = ckpt["model_state_dict"] + if any(k.startswith("module.") for k in state_dict): + state_dict = {k.removeprefix("module."): v for k, v in state_dict.items()} + + input_dim = ckpt.get("input_dim") + hidden_dim = ckpt.get("hidden_dim") + if input_dim is None or hidden_dim is None: + w = state_dict["encoder.weight"] + hidden_dim = hidden_dim or w.shape[0] + input_dim = input_dim or w.shape[1] + + model_config = ckpt.get("model_config", {}) + normalize_input = model_config.get("normalize_input", False) + + top_k = top_k_override or model_config.get("top_k") + if top_k is None: + raise ValueError("top_k not found in checkpoint. Pass --top-k explicitly.") + + sae = TopKSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + top_k=top_k, + normalize_input=normalize_input, + ) + sae.load_state_dict(state_dict) + print(f"Loaded SAE: {input_dim} -> {hidden_dim:,} latents (top-{top_k})") + return sae + + +# ── CLI ────────────────────────────────────────────────────────────────── + + +def parse_args(): + """Parse command-line arguments.""" + p = argparse.ArgumentParser(description="Evaluate CodoNFM SAE against SwissProt annotations (F1)") + + # Checkpoint + p.add_argument("--checkpoint", type=str, required=True, help="Path to SAE checkpoint .pt file") + p.add_argument("--top-k", type=int, default=None, help="Override top-k (default: read from checkpoint)") + + # Model + p.add_argument("--model-path", type=str, required=True, help="Path to Encodon checkpoint") + p.add_argument("--layer", type=int, default=16) + p.add_argument("--context-length", type=int, default=2048) + p.add_argument("--batch-size", type=int, default=8) + + # Data + p.add_argument( + "--swissprot-tsv", + type=str, + required=True, + help="Path to codonfm_swissprot.tsv.gz (from download_codonfm_swissprot.py)", + ) + + # F1 eval + p.add_argument("--f1-max-proteins", type=int, default=8000) + p.add_argument("--f1-min-positives", type=int, default=10) + p.add_argument("--f1-threshold", type=float, default=0.3, help="F1 threshold for labeling features") + p.add_argument("--normalization-n-proteins", type=int, default=2000) + + # Output + p.add_argument("--output-dir", type=str, default="./outputs/swissprot_eval") + + p.add_argument("--seed", type=int, default=42) + p.add_argument("--device", type=str, default=None) + return p.parse_args() + + +def main(): + """Run CodoNFM SAE F1 evaluation against SwissProt annotations.""" + args = parse_args() + set_seed(args.seed) + device = args.device or get_device() + print(f"Using device: {device}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # 1. Load SAE + sae = load_sae_from_checkpoint(args.checkpoint, top_k_override=args.top_k) + n_features = sae.hidden_dim + + # 2. Load SwissProt+CDS dataset + print("\n" + "=" * 60) + print("LOADING SWISSPROT CODON DATASET") + print("=" * 60) + proteins, concept_counts = load_swissprot_codon_dataset( + args.swissprot_tsv, + min_positives=args.f1_min_positives, + max_proteins=args.f1_max_proteins, + ) + + if not proteins: + print("ERROR: No annotated proteins found. Run download_codonfm_swissprot.py first.") + return + + # Val/test split + rng = np.random.RandomState(args.seed) + indices = rng.permutation(len(proteins)) + mid = len(indices) // 2 + val_proteins = [proteins[i] for i in indices[:mid]] + test_proteins = [proteins[i] for i in indices[mid:]] + + val_sequences = [p.codon_sequence for p in val_proteins] + test_sequences = [p.codon_sequence for p in test_proteins] + val_labels = [p.annotations for p in val_proteins] + test_labels = [p.annotations for p in test_proteins] + + print(f"F1 eval: {len(val_proteins)} val + {len(test_proteins)} test proteins, {len(concept_counts)} concepts") + + # 3. Load CodoNFM model + print(f"\nLoading Encodon from {args.model_path}...") + inference = EncodonInference( + model_path=args.model_path, task_type="embedding_prediction", use_transformer_engine=True + ) + inference.configure_model() + inference.model.to(device).eval() + + num_layers = len(inference.model.model.layers) + target_layer = args.layer if args.layer >= 0 else num_layers + args.layer + print(f" Layers: {num_layers}, Target layer: {target_layer}") + + # 4. Extract activations + print("\n" + "=" * 60) + print("EXTRACTING ACTIVATIONS") + print("=" * 60) + + print("Extracting val embeddings...") + val_embeddings, val_masks = extract_activations_3d( + inference, + val_sequences, + args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + ) + + print("Extracting test embeddings...") + test_embeddings, test_masks = extract_activations_3d( + inference, + test_sequences, + args.layer, + context_length=args.context_length, + batch_size=args.batch_size, + device=device, + ) + + # Compute activation_max for normalization + norm_n = min(args.normalization_n_proteins, val_embeddings.shape[0]) + print(f"Computing activation_max from {norm_n} proteins...") + activation_max = compute_activation_max( + sae, + val_embeddings[:norm_n], + val_masks[:norm_n], + device=device, + ) + print(f" activation_max range: [{activation_max.min():.4f}, {activation_max.max():.4f}]") + + # 5. Compute F1 scores + print("\n" + "=" * 60) + print("F1 EVALUATION") + print("=" * 60) + + print("Computing F1 scores (val)...") + t0 = time.time() + val_results = compute_f1_scores( + sae=sae, + embeddings=val_embeddings, + concept_labels=val_labels, + masks=val_masks, + min_positives=args.f1_min_positives, + device=device, + show_progress=True, + activation_max=activation_max, + ) + print(f" Val: {len(val_results)} pairs in {time.time() - t0:.1f}s") + + print("Computing F1 scores (test)...") + t0 = time.time() + test_results = compute_f1_scores( + sae=sae, + embeddings=test_embeddings, + concept_labels=test_labels, + masks=test_masks, + min_positives=args.f1_min_positives, + device=device, + show_progress=True, + activation_max=activation_max, + ) + print(f" Test: {len(test_results)} pairs in {time.time() - t0:.1f}s") + + # Build labels + print("Building feature labels...") + f1_labels, feature_stats = build_f1_labels(val_results, n_features, args.f1_threshold) + + # Build and save summary + f1_summary = build_f1_summary(val_results, test_results, args.f1_threshold) + + print("\nF1 Summary:") + print(f" Concepts matched: {f1_summary['n_concepts_matched']}") + print(f" Mean F1 (domain, test): {f1_summary['mean_f1_domain_test']:.4f}") + print(f" Max F1 (domain, test): {f1_summary['max_f1_domain_test']:.4f}") + print(f" Above {f1_summary['f1_threshold']} (val): {f1_summary['n_above_threshold_val']}") + print(f" Above {f1_summary['f1_threshold']} (both): {f1_summary['n_pairs_above_threshold_both']}") + if f1_summary["top_pairs"]: + print(" Top pairs (test):") + for p in f1_summary["top_pairs"][:10]: + print(f" Feature {p['feature']:>5d} F1={p['f1_domain']:.3f} {p['concept']}") + + f1_path = output_dir / "f1_results.json" + with open(f1_path, "w") as f: + json.dump(f1_summary, f, indent=2) + print(f"\nSaved F1 results to {f1_path}") + + # Save labels + labels_path = output_dir / "feature_labels.json" + with open(labels_path, "w") as f: + json.dump({"labels": f1_labels, "feature_stats": feature_stats}, f, indent=2) + print(f"Saved feature labels to {labels_path}") + + del inference, val_embeddings, test_embeddings + torch.cuda.empty_cache() + + print("\n" + "=" * 60) + print("EVALUATION COMPLETE") + print(f"All results saved to: {output_dir}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/extract.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/extract.py new file mode 100644 index 0000000000..d9b63bf7be --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/extract.py @@ -0,0 +1,334 @@ +# 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. + +"""Step 1: Extract activations from CodonFM (Encodon) and save to disk. + +Single-GPU: + python scripts/extract.py \ + --csv-path path/to/Primates.csv \ + --model-path path/to/encodon_1b \ + --layer -2 \ + --output .cache/activations/encodon_1b_layer-2 + +Multi-GPU: + torchrun --nproc_per_node=4 scripts/extract.py \ + --csv-path path/to/Primates.csv \ + --model-path path/to/encodon_1b \ + --layer -2 \ + --output .cache/activations/encodon_1b_layer-2 +""" + +import argparse +import json +import os +import shutil +import sys +import time +from pathlib import Path + +import numpy as np +import torch +from tqdm import tqdm + + +# Use codonfm_ptl_te recipe (has TransformerEngine support) +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent.parent +_CODONFM_TE_DIR = _REPO_ROOT / "recipes" / "codonfm_ptl_te" +sys.path.insert(0, str(_CODONFM_TE_DIR)) + +from codonfm_sae.data import read_codon_csv # noqa: E402 +from sae.activation_store import ActivationStore, ActivationStoreConfig # noqa: E402 +from src.data.preprocess.codon_sequence import process_item # noqa: E402 +from src.inference.encodon import EncodonInference # noqa: E402 + + +def parse_args(): # noqa: D103 + p = argparse.ArgumentParser(description="Extract CodonFM layer activations") + p.add_argument( + "--csv-path", type=str, required=True, help="Path to CSV with DNA sequences (auto-detects 'seq'/'cds' column)" + ) + p.add_argument("--seq-column", type=str, default=None, help="Column name for sequences (auto-detect if omitted)") + p.add_argument("--num-sequences", type=int, default=None, help="Max sequences to extract") + p.add_argument( + "--model-path", type=str, required=True, help="Path to Encodon checkpoint (.ckpt, .safetensors, or directory)" + ) + p.add_argument("--layer", type=int, required=True, help="Layer index (negative = from end, e.g. -2 = penultimate)") + p.add_argument("--context-length", type=int, default=2048, help="Max context length in codons (default: 2048)") + p.add_argument("--output", type=str, required=True, help="Output directory for activation shards") + p.add_argument("--batch-size", type=int, default=8) + p.add_argument("--shard-size", type=int, default=100_000) + p.add_argument( + "--use-transformer-engine", + action="store_true", + default=True, + help="Use TransformerEngine model (default: True, for TE checkpoints)", + ) + p.add_argument("--seed", type=int, default=42) + return p.parse_args() + + +def _merge_rank_stores(cache_path: Path, world_size: int, metadata: dict) -> None: + """Merge per-rank temp stores into a single activation store.""" + cache_path.mkdir(parents=True, exist_ok=True) + shard_idx = 0 + total_samples = 0 + total_sequences = 0 + hidden_dim = None + shard_size = None + merged_ranks = [] + failed_ranks = [] + + for r in range(world_size): + tmp_dir = cache_path / f".tmp_rank_{r}" + meta_path = tmp_dir / "metadata.json" + + if not meta_path.exists(): + failed_ranks.append(r) + print(f" WARNING: Rank {r} did not finalize. Skipping.") + if tmp_dir.exists(): + shutil.rmtree(tmp_dir) + continue + + with open(meta_path) as f: + tmp_meta = json.load(f) + + hidden_dim = tmp_meta["hidden_dim"] + shard_size = tmp_meta["shard_size"] + + for i in range(tmp_meta["n_shards"]): + src_file = tmp_dir / f"shard_{i:05d}.parquet" + dst_file = cache_path / f"shard_{shard_idx:05d}.parquet" + shutil.move(str(src_file), str(dst_file)) + shard_idx += 1 + + total_samples += tmp_meta["n_samples"] + total_sequences += tmp_meta.get("n_sequences", 0) + merged_ranks.append(r) + shutil.rmtree(tmp_dir) + + if not merged_ranks: + raise RuntimeError("All ranks failed — no data to merge.") + + metadata.update( + n_samples=total_samples, + n_shards=shard_idx, + n_sequences=total_sequences, + hidden_dim=hidden_dim, + shard_size=shard_size, + ) + with open(cache_path / "metadata.json", "w") as f: + json.dump(metadata, f, indent=2) + + if failed_ranks: + print(f"Merged {len(merged_ranks)}/{world_size} ranks: {total_samples:,} tokens, {shard_idx} shards") + print(f" WARNING: Ranks {failed_ranks} failed.") + else: + print(f"Merged {world_size} rank stores: {total_samples:,} tokens, {shard_idx} shards") + + +def main(): # noqa: D103 + args = parse_args() + torch.manual_seed(args.seed) + + # --- Distributed setup --- + rank = int(os.environ.get("RANK", 0)) + world_size = int(os.environ.get("WORLD_SIZE", 1)) + + if world_size > 1: + from datetime import timedelta + + import torch.distributed as dist + + if not dist.is_initialized(): + dist.init_process_group("nccl", timeout=timedelta(hours=48)) + torch.cuda.set_device(rank) + device = f"cuda:{rank}" + else: + device = "cuda" if torch.cuda.is_available() else "cpu" + + print(f"[Rank {rank}/{world_size}] Device: {device}") + + # --- Check existing cache --- + cache_path = Path(args.output) + if (cache_path / "metadata.json").exists(): + if rank == 0: + with open(cache_path / "metadata.json") as f: + meta = json.load(f) + print(f"Cache exists at {cache_path}: {meta['n_samples']:,} tokens. Skipping.") + if world_size > 1: + dist.barrier() + dist.destroy_process_group() + return + + # Clean stale temp dirs + if rank == 0 and cache_path.exists(): + for tmp in cache_path.glob(".tmp_rank_*"): + shutil.rmtree(tmp) + + # --- Load sequences --- + max_codons = args.context_length - 2 # room for CLS + SEP + records = read_codon_csv( + args.csv_path, + seq_column=args.seq_column, + max_sequences=args.num_sequences, + max_codons=max_codons, + ) + sequences = [r.sequence for r in records] + total_sequences = len(sequences) + + if rank == 0: + print(f"Loaded {total_sequences} sequences from {args.csv_path}") + + # Shard across ranks + if world_size > 1: + dist.barrier() + chunk = total_sequences // world_size + start = rank * chunk + end = total_sequences if rank == world_size - 1 else (rank + 1) * chunk + my_sequences = sequences[start:end] + print(f"[Rank {rank}] sequences {start}-{end} ({len(my_sequences)})") + else: + my_sequences = sequences + + # --- Load model --- + if rank == 0: + print(f"Loading model from {args.model_path}...") + + inf = EncodonInference( + model_path=args.model_path, + task_type="embedding_prediction", + use_transformer_engine=args.use_transformer_engine, + ) + inf.configure_model() + inf.model.to(device).eval() + + num_layers = len(inf.model.model.layers) + target_layer = args.layer if args.layer >= 0 else num_layers + args.layer + hidden_dim = inf.model.model.config.hidden_size + + if rank == 0: + print(f"Extracting layer {target_layer}/{num_layers} (hidden_dim={hidden_dim})") + + # --- Extract activations --- + store_path = cache_path / f".tmp_rank_{rank}" if world_size > 1 else cache_path + store = ActivationStore(store_path, ActivationStoreConfig(shard_size=args.shard_size)) + + n_batches = (len(my_sequences) + args.batch_size - 1) // args.batch_size + iterator = range(0, len(my_sequences), args.batch_size) + if rank == 0: + iterator = tqdm(iterator, total=n_batches, desc="Extracting") + + log_interval = max(1, n_batches // 20) + + t0 = time.time() + extraction_error = None + batches_done = 0 + try: + with torch.no_grad(): + for i in iterator: + batch_seqs = my_sequences[i : i + args.batch_size] + items = [ + process_item(s, context_length=args.context_length, tokenizer=inf.tokenizer) for s in batch_seqs + ] + + batch = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + + out = inf.model(batch, return_hidden_states=True) + layer_acts = out.all_hidden_states[args.layer] # [B, L, D] + + # Strip CLS (pos 0) and SEP (last real pos), keep only codon positions + for j, it in enumerate(items): + seq_len = it["attention_mask"].sum() + acts = layer_acts[j, 1 : seq_len - 1, :].float().cpu() # [num_codons, hidden_dim] + store.append(acts) + + batches_done += 1 + del out, layer_acts, batch + torch.cuda.empty_cache() + + if rank != 0 and batches_done % log_interval == 0: + print( + f"[Rank {rank}] {batches_done}/{n_batches} batches ({100 * batches_done / n_batches:.0f}%)", + flush=True, + ) + + except Exception as e: + extraction_error = e + print( + f"[Rank {rank}] EXTRACTION FAILED at batch {batches_done}/{n_batches}: {type(e).__name__}: {e}", flush=True + ) + + if extraction_error is None: + store.finalize( + metadata={ + "model_path": args.model_path, + "layer": args.layer, + "target_layer": target_layer, + "num_layers": num_layers, + "n_sequences": len(my_sequences), + "context_length": args.context_length, + } + ) + + elapsed = time.time() - t0 + print( + f"[Rank {rank}] {store.metadata['n_samples']:,} tokens from " + f"{len(my_sequences)} sequences in {elapsed:.1f}s", + flush=True, + ) + else: + print(f"[Rank {rank}] Extraction incomplete. Store NOT finalized.", flush=True) + + del inf + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + # --- Multi-GPU merge --- + if world_size > 1: + import torch.distributed as dist + + dist.barrier() + if rank == 0: + _merge_rank_stores( + cache_path, + world_size, + { + "model_path": args.model_path, + "layer": args.layer, + "target_layer": target_layer, + "num_layers": num_layers, + "n_sequences": total_sequences, + "context_length": args.context_length, + }, + ) + dist.barrier() + dist.destroy_process_group() + + if rank == 0: + with open(cache_path / "metadata.json") as f: + meta = json.load(f) + print("\nExtraction complete:") + print(f" Output: {cache_path}") + print(f" Sequences: {meta.get('n_sequences', '?')}") + print(f" Tokens: {meta['n_samples']:,}") + print(f" Hidden dim: {meta['hidden_dim']}") + print(f" Shards: {meta['n_shards']}") + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/launch_dashboard.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/launch_dashboard.py new file mode 100644 index 0000000000..40508433e4 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/launch_dashboard.py @@ -0,0 +1,141 @@ +# 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 codon SAE dashboard locally. + +Usage: + # After scp'ing dashboard data from server: + scp -r server:/path/to/outputs/merged_1b/dashboard ./dash + + python scripts/launch_dashboard.py --data-dir ./dash +""" + +import argparse +import shutil +import subprocess +import time +import webbrowser +from pathlib import Path + + +def _get_live_feature_ids(data_dir: Path): + """Return set of feature_ids with activation_freq > 0.""" + import pyarrow.parquet as pq + + meta_path = data_dir / "feature_metadata.parquet" + if not meta_path.exists(): + return None + table = pq.read_table(meta_path) + df = table.to_pandas() + live = df.loc[df["activation_freq"] > 0, "feature_id"] + return set(live.tolist()) + + +def _filter_and_copy_parquet(src: Path, dst: Path, live_ids: set): + """Filter a parquet file to only include live feature_ids.""" + import pyarrow as pa + import pyarrow.parquet as pq + + table = pq.read_table(src) + df = table.to_pandas() + if "feature_id" not in df.columns: + # No feature_id column — copy as-is + shutil.copy2(src, dst) + return len(df), len(df) + n_before = len(df) + df = df[df["feature_id"].isin(live_ids)] + pq.write_table(pa.Table.from_pandas(df, preserve_index=False), dst) + return n_before, len(df) + + +def main(): # noqa: D103 + p = argparse.ArgumentParser(description="Launch codon SAE dashboard") + p.add_argument( + "--data-dir", + type=str, + required=True, + help="Directory containing features_atlas.parquet, feature_metadata.parquet, feature_examples.parquet", + ) + p.add_argument("--port", type=int, default=5176) + p.add_argument( + "--no-filter-dead", action="store_true", help="Don't filter out dead latents (activation_freq == 0)" + ) + args = p.parse_args() + + data_dir = Path(args.data_dir).resolve() + dashboard_dir = Path(__file__).resolve().parent.parent / "codon_dashboard" + + if not (dashboard_dir / "package.json").exists(): + raise FileNotFoundError(f"Dashboard not found at {dashboard_dir}") + + # Determine live features + filter_dead = not args.no_filter_dead + live_ids = None + if filter_dead: + live_ids = _get_live_feature_ids(data_dir) + if live_ids is not None: + print(f"Filtering to {len(live_ids)} live features (activation_freq > 0)") + else: + print("No feature_metadata.parquet found, skipping dead latent filtering") + filter_dead = False + + # Copy parquet files into dashboard's public/ dir + public_dir = dashboard_dir / "public" + public_dir.mkdir(exist_ok=True) + + parquet_files = ["features_atlas.parquet", "feature_metadata.parquet", "feature_examples.parquet"] + json_files = ["vocab_logits.json", "feature_labels.json", "feature_analysis.json"] + + for fname in parquet_files: + src = data_dir / fname + if not src.exists(): + print(f"WARNING: {fname} not found in {data_dir}") + continue + if filter_dead and live_ids is not None: + n_before, n_after = _filter_and_copy_parquet(src, public_dir / fname, live_ids) + print(f"Copied {fname} ({n_after}/{n_before} rows, {n_before - n_after} dead filtered)") + else: + shutil.copy2(src, public_dir / fname) + print(f"Copied {fname}") + + for fname in json_files: + src = data_dir / fname + if src.exists(): + shutil.copy2(src, public_dir / fname) + print(f"Copied {fname}") + + # Install deps if needed + if not (dashboard_dir / "node_modules").exists(): + print("Installing dashboard dependencies...") + subprocess.run(["npm", "install"], cwd=dashboard_dir, check=True) + + # Launch dev server + print(f"\nStarting dashboard on http://localhost:{args.port}") + proc = subprocess.Popen( + ["npx", "vite", "--port", str(args.port)], + cwd=dashboard_dir, + ) + + time.sleep(2) + webbrowser.open(f"http://localhost:{args.port}") + + try: + input("Dashboard running. Press Enter to stop.\n") + finally: + proc.terminate() + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/train.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/train.py new file mode 100644 index 0000000000..1762fe8ca9 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/scripts/train.py @@ -0,0 +1,303 @@ +# 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. + +"""Step 2: Train SAE from cached CodonFM activations. + +Loads pre-extracted activations from an ActivationStore cache directory +and trains a Sparse Autoencoder. Requires extract.py to have been run first. + +Single-GPU: + python scripts/train.py \ + --cache-dir .cache/activations/encodon_1b_layer-2 \ + --model-path path/to/encodon_1b --layer -2 \ + --expansion-factor 8 --top-k 32 --batch-size 4096 --n-epochs 3 + +Multi-GPU DDP: + torchrun --nproc_per_node=4 scripts/train.py \ + --cache-dir .cache/activations/encodon_1b_layer-2 \ + --model-path path/to/encodon_1b --layer -2 \ + --expansion-factor 8 --top-k 32 --batch-size 4096 --n-epochs 3 \ + --dp-size 4 +""" + +import argparse +import os +from pathlib import Path + +import numpy as np +import torch +from sae.activation_store import load_activations +from sae.architectures import ReLUSAE, TopKSAE +from sae.perf_logger import PerfLogger +from sae.training import ParallelConfig, Trainer, TrainingConfig, WandbConfig +from sae.utils import get_device, set_seed + + +def parse_args(): # noqa: D103 + p = argparse.ArgumentParser( + description="Train SAE from cached CodonFM activations", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + # Required + p.add_argument("--cache-dir", type=str, required=True, help="Path to activation cache (from extract.py)") + p.add_argument("--model-path", type=str, required=True, help="Encodon model path (for cache validation)") + p.add_argument("--layer", type=int, required=True, help="Layer index (for cache validation)") + + # SAE architecture + sae_group = p.add_argument_group("SAE model") + sae_group.add_argument("--model-type", type=str, default="topk", choices=["topk", "relu"]) + sae_group.add_argument("--expansion-factor", type=int, default=8) + sae_group.add_argument("--top-k", type=int, default=32) + sae_group.add_argument("--normalize-input", action=argparse.BooleanOptionalAction, default=False) + sae_group.add_argument("--auxk", type=int, default=None) + sae_group.add_argument("--auxk-coef", type=float, default=1 / 32) + sae_group.add_argument("--dead-tokens-threshold", type=int, default=10_000_000) + sae_group.add_argument("--init-pre-bias", action=argparse.BooleanOptionalAction, default=False) + sae_group.add_argument("--l1-coeff", type=float, default=1e-2, help="L1 coefficient (relu only)") + + # Training + train_group = p.add_argument_group("Training") + train_group.add_argument("--lr", type=float, default=3e-4) + train_group.add_argument("--n-epochs", type=int, default=3) + train_group.add_argument("--batch-size", type=int, default=4096) + train_group.add_argument("--log-interval", type=int, default=50) + train_group.add_argument("--shuffle", action=argparse.BooleanOptionalAction, default=True) + train_group.add_argument("--num-workers", type=int, default=0) + train_group.add_argument("--pin-memory", action=argparse.BooleanOptionalAction, default=False) + train_group.add_argument("--max-grad-norm", type=float, default=None) + train_group.add_argument("--lr-scale-with-latents", action=argparse.BooleanOptionalAction, default=False) + train_group.add_argument("--lr-reference-hidden-dim", type=int, default=2048) + + # W&B + wb_group = p.add_argument_group("Weights & Biases") + wb_group.add_argument("--wandb", action=argparse.BooleanOptionalAction, default=False, dest="wandb_enabled") + wb_group.add_argument("--wandb-project", type=str, default="sae_codonfm_recipe") + wb_group.add_argument("--wandb-run-name", type=str, default=None) + wb_group.add_argument("--wandb-group", type=str, default=None) + wb_group.add_argument("--wandb-job-type", type=str, default=None) + + # Checkpointing + ckpt_group = p.add_argument_group("Checkpointing") + ckpt_group.add_argument("--checkpoint-dir", type=str, default=None) + ckpt_group.add_argument("--checkpoint-steps", type=int, default=None) + ckpt_group.add_argument("--resume-from", type=str, default=None) + + # Infrastructure + p.add_argument("--dp-size", type=int, default=1) + p.add_argument("--output-dir", type=str, default="./outputs") + p.add_argument("--seed", type=int, default=42) + p.add_argument("--device", type=str, default=None) + p.add_argument( + "--num-sequences", + type=int, + default=None, + help="Subset cached activations to this many sequences' worth of shards", + ) + + return p.parse_args() + + +def build_sae(args, input_dim: int) -> torch.nn.Module: # noqa: D103 + hidden_dim = input_dim * args.expansion_factor + + if args.model_type == "topk": + return TopKSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + top_k=args.top_k, + normalize_input=args.normalize_input, + auxk=args.auxk, + auxk_coef=args.auxk_coef, + dead_tokens_threshold=args.dead_tokens_threshold, + ) + elif args.model_type == "relu": + return ReLUSAE( + input_dim=input_dim, + hidden_dim=hidden_dim, + l1_coeff=args.l1_coeff, + ) + else: + raise ValueError(f"Unknown model type: {args.model_type}") + + +def build_training_config(args, device: str) -> TrainingConfig: # noqa: D103 + return TrainingConfig( + lr=args.lr, + n_epochs=args.n_epochs, + batch_size=args.batch_size, + device=device, + log_interval=args.log_interval, + shuffle=args.shuffle, + num_workers=args.num_workers, + pin_memory=args.pin_memory, + checkpoint_dir=args.checkpoint_dir, + checkpoint_steps=args.checkpoint_steps, + lr_scale_with_latents=args.lr_scale_with_latents, + lr_reference_hidden_dim=args.lr_reference_hidden_dim, + ) + + +def build_wandb_config(args) -> WandbConfig: # noqa: D103 + return WandbConfig( + enabled=args.wandb_enabled, + project=args.wandb_project, + run_name=args.wandb_run_name, + group=args.wandb_group, + job_type=args.wandb_job_type, + config=vars(args), + ) + + +def build_parallel_config(args) -> ParallelConfig: # noqa: D103 + return ParallelConfig(dp_size=args.dp_size) + + +def main(): # noqa: D103 + args = parse_args() + + set_seed(args.seed) + device = args.device or get_device() + print(f"Using device: {device}") + print(f"Config: {vars(args)}") + + # Load cached activations + cache_path = Path(args.cache_dir) + if not (cache_path / "metadata.json").exists(): + raise FileNotFoundError(f"No cache found at {cache_path}. Run extract.py first.") + + store = load_activations(cache_path) + meta = store.metadata + + # Validate cache matches config + cached_model = meta.get("model_path", meta.get("model_name", "")) + if cached_model and cached_model != args.model_path: + print(f"WARNING: Cache model '{cached_model}' != '{args.model_path}'") + if meta.get("layer") != args.layer: + raise ValueError(f"Cache layer mismatch: {meta['layer']} vs {args.layer}") + + # Compute subsetting + cached_sequences = meta.get("n_sequences", None) + max_shards = None + if args.num_sequences and cached_sequences and args.num_sequences < cached_sequences: + keep_ratio = args.num_sequences / cached_sequences + max_shards = max(1, int(np.ceil(keep_ratio * meta["n_shards"]))) + print( + f"Subsetting: {args.num_sequences}/{cached_sequences} sequences " + f"-> using {max_shards}/{meta['n_shards']} shards (~{keep_ratio:.1%})" + ) + + # Estimate memory + n_shards_to_use = max_shards or meta["n_shards"] + shard_size = meta.get("shard_size", 100_000) + est_tokens = n_shards_to_use * shard_size + est_gb = est_tokens * meta["hidden_dim"] * 4 / (1024**3) + use_streaming = est_gb > 50 + + input_dim = meta["hidden_dim"] + sae = build_sae(args, input_dim) + print(f"SAE: {args.model_type}, input_dim={input_dim}, hidden_dim={sae.hidden_dim}") + + # Initialize pre_bias + if args.init_pre_bias and hasattr(sae, "init_pre_bias_from_data"): + print("Initializing pre_bias from geometric median of data...") + first_shard = torch.from_numpy(store._load_shard(0)).float() + sample_size = min(32768, len(first_shard)) + sae.init_pre_bias_from_data(first_shard[:sample_size]) + print(f" pre_bias initialized (mean={sae.pre_bias.mean().item():.4f})") + del first_shard + + # Build configs + training_config = build_training_config(args, device) + wandb_config = build_wandb_config(args) + parallel_config = build_parallel_config(args) + + perf_logger = PerfLogger( + log_interval=args.log_interval, + use_wandb=args.wandb_enabled, + print_logs=True, + device=device, + ) + + # Train + trainer = Trainer( + sae, + training_config, + wandb_config=wandb_config, + perf_logger=perf_logger, + parallel_config=parallel_config, + ) + + if use_streaming: + rank = int(os.environ.get("RANK", 0)) + world_size = int(os.environ.get("WORLD_SIZE", 1)) + print( + f"Streaming from disk (~{est_gb:.0f}GB). " + f"Peak RAM: ~{shard_size * meta['hidden_dim'] * 4 / (1024**3):.1f}GB/process" + ) + + dataloader = store.get_streaming_dataloader( + batch_size=args.batch_size, + shuffle=args.shuffle, + seed=args.seed, + rank=rank, + world_size=world_size, + max_shards=max_shards, + ) + # Compute min batch count across all ranks to keep DDP in sync + # Read parquet footers for all ranks' shards (a few KB each, no data loading) + if world_size > 1: + import pyarrow.parquet as pq_meta + + dataset = dataloader.dataset + per_rank = len(dataset.shard_indices) + # Each rank got per_rank contiguous shards; compute batch count for each rank + min_batches = None + for r in range(world_size): + total_rows = sum( + pq_meta.read_metadata(store.path / f"shard_{idx:05d}.parquet").num_rows + for idx in range(r * per_rank, (r + 1) * per_rank) + ) + batches = total_rows // args.batch_size + if min_batches is None or batches < min_batches: + min_batches = batches + dataset.max_batches = min_batches + print(f"[rank {rank}] capped to {min_batches} batches/epoch for DDP sync") + trainer.fit( + dataloader, + max_grad_norm=args.max_grad_norm, + resume_from=args.resume_from, + data_sharded=True, + ) + else: + shards = [] + for i, shard in enumerate(store.iter_shards(shuffle_shards=False)): + if max_shards is not None and i >= max_shards: + break + shards.append(torch.from_numpy(shard).float()) + activations_flat = torch.cat(shards) + print(f"Loaded {activations_flat.shape[0]:,} cached activations into memory") + + trainer.fit( + activations_flat, + max_grad_norm=args.max_grad_norm, + resume_from=args.resume_from, + ) + + print("Training complete.") + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/__init__.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/__init__.py new file mode 100644 index 0000000000..0b5359f6ec --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/__init__.py @@ -0,0 +1,26 @@ +# 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. + +"""CodonFM SAE Recipe: Sparse Autoencoders for CodonFM Codon Language Models.""" + +from .data import CodonRecord, read_codon_csv + + +__version__ = "0.1.0" + +__all__ = [ + "CodonRecord", + "read_codon_csv", +] diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/__init__.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/__init__.py new file mode 100644 index 0000000000..294c81517b --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/__init__.py @@ -0,0 +1,22 @@ +# 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. + +"""CodonFM data loading and processing.""" + +from .csv_loader import read_codon_csv +from .types import CodonRecord + + +__all__ = ["CodonRecord", "read_codon_csv"] diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/csv_loader.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/csv_loader.py new file mode 100644 index 0000000000..4e75f31de1 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/csv_loader.py @@ -0,0 +1,126 @@ +# 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. + +"""CSV data loading for CodonFM codon sequences.""" + +from pathlib import Path +from typing import List, Optional, Union + +import pandas as pd + +from .types import CodonRecord + + +SEQ_COLUMN_CANDIDATES = ["seq", "cds", "sequence", "dna_seq"] +ID_COLUMN_CANDIDATES = ["id", "seq_id", "gene", "name"] + + +def read_codon_csv( + filepath: Union[str, Path], + seq_column: Optional[str] = None, + id_column: Optional[str] = None, + max_sequences: Optional[int] = None, + max_codons: Optional[int] = None, + min_codons: Optional[int] = None, +) -> List[CodonRecord]: + """Read codon sequences from a CSV file. + + Auto-detects column names: tries 'seq', 'cds', 'sequence' for the + sequence column and 'id', 'seq_id', 'gene' for the ID column. + + Args: + filepath: Path to CSV file. + seq_column: Column name containing DNA sequences (auto-detect if None). + id_column: Column name for sequence IDs (auto-detect if None). + max_sequences: Maximum number of sequences to return. + max_codons: Filter out sequences with more codons than this. + min_codons: Filter out sequences with fewer codons than this. + + Returns: + List of CodonRecord objects. + """ + filepath = Path(filepath) + df = pd.read_csv(filepath) + + # Auto-detect sequence column + if seq_column is None: + for candidate in SEQ_COLUMN_CANDIDATES: + if candidate in df.columns: + seq_column = candidate + break + if seq_column is None: + raise ValueError( + f"Cannot auto-detect sequence column in {filepath}. " + f"Columns: {list(df.columns)}. Pass seq_column= explicitly." + ) + + # Auto-detect ID column + if id_column is None: + for candidate in ID_COLUMN_CANDIDATES: + if candidate in df.columns: + id_column = candidate + break + + # Drop rows with missing sequences + df = df.dropna(subset=[seq_column]) + + # Filter by codon count + codon_counts = df[seq_column].str.len() // 3 + if max_codons is not None: + df = df[codon_counts <= max_codons] + codon_counts = codon_counts[df.index] + if min_codons is not None: + df = df[codon_counts >= min_codons] + + if max_sequences is not None: + df = df.head(max_sequences) + + # Metadata columns to carry through (if present) + metadata_columns = [ + "var_pos_offset", + "ref_codon", + "alt_codon", + "source", + "ROLE_IN_CANCER", + "MUTATION_DESCRIPTION", + "is_pathogenic", + "in_splice_junction", + "phylop", + "gene", + "5b_cdwt", + "5b", + "1b_cdwt", + "1b", + "600m", + "80m", + "5b_avg", + "trinuc_context", + "gc_content", + ] + available_meta = [c for c in metadata_columns if c in df.columns] + + records = [] + for idx, row in df.iterrows(): + record_id = str(row[id_column]) if id_column else f"seq_{idx}" + meta = {} + for col in available_meta: + val = row[col] + if pd.isna(val): + meta[col] = None + else: + meta[col] = val + records.append(CodonRecord(id=record_id, sequence=str(row[seq_column]), metadata=meta)) + + return records diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/types.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/types.py new file mode 100644 index 0000000000..8dff91fd8a --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/data/types.py @@ -0,0 +1,30 @@ +# 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. + +from dataclasses import dataclass, field +from typing import Any, Dict + + +@dataclass +class CodonRecord: + """Container for a codon DNA sequence record.""" + + id: str + sequence: str # raw DNA string (e.g., "ATGCGT...") + metadata: Dict[str, Any] = field(default_factory=dict) + + @property + def num_codons(self) -> int: # noqa: D102 + return len(self.sequence) // 3 diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/__init__.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/__init__.py new file mode 100644 index 0000000000..6394a99335 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/__init__.py @@ -0,0 +1,30 @@ +# 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. + +"""CodonFM-specific evaluation metrics.""" + +from .loss_recovered import evaluate_codonfm_loss_recovered + + +__all__ = ["evaluate_codonfm_loss_recovered"] + + +# Lazy import for gene_enrichment (requires optional gsea dependencies) +def __getattr__(name): + if name == "gene_enrichment": + from . import gene_enrichment + + return gene_enrichment + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/gene_enrichment.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/gene_enrichment.py new file mode 100644 index 0000000000..2299756a50 --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/gene_enrichment.py @@ -0,0 +1,534 @@ +# 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. + +"""Gene-level GSEA enrichment metric for CodonFM SAE features. + +For each SAE feature, ranks genes by activation strength and runs GSEA +(Gene Set Enrichment Analysis) against GO, InterPro, and Pfam databases. +This captures functional/pathway-level interpretability that residue-level +F1 misses (e.g., a feature that fires on all ribosomal protein genes). + +Dependencies: gseapy>=1.0, goatools>=1.3 +Install via: pip install codonfm-sae[gsea] +""" + +import logging +import re +import warnings +from dataclasses import dataclass, field +from multiprocessing import Pool +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + + +logger = logging.getLogger(__name__) + +ANNOTATION_DATABASES = [ + "GO_Biological_Process_2023", + "GO_Molecular_Function_2023", + "GO_Cellular_Component_2023", + "InterPro_Domains_2019", + "Pfam_Domains_2019", +] + +_GO_ID_PATTERN = re.compile(r"\((GO:\d+)\)") + + +# ── Dataclasses ────────────────────────────────────────────────────────── + + +@dataclass +class EnrichmentResult: + """Single (feature, term) enrichment result.""" + + feature_idx: int + term_id: str + term_name: str + database: str + enrichment_score: float + pvalue: float + fdr: float + n_genes_in_term: int + + +@dataclass +class FeatureLabels: + """Best label for a single feature, per database.""" + + feature_idx: int + best_per_database: Dict[str, Optional[EnrichmentResult]] + overall_best: Optional[EnrichmentResult] + go_slim_term: Optional[str] = None + go_slim_name: Optional[str] = None + all_significant: List[EnrichmentResult] = field(default_factory=list) + + +@dataclass +class GeneEnrichmentReport: + """Full results across all features.""" + + per_feature: List[FeatureLabels] + databases_used: List[str] + n_features_with_enrichment: int + n_features_total: int + frac_enriched: float + per_database_stats: Dict[str, dict] + feature_label_columns: Dict[str, Dict[int, str]] + significance_threshold: float + + +# ── Utilities ──────────────────────────────────────────────────────────── + + +def reduce_to_gene_level( + per_codon_activations: Dict[int, Dict[str, List[float]]], + method: str = "max", +) -> Dict[int, Dict[str, float]]: + """Collapse per-codon activation lists to per-gene scalars. + + Args: + per_codon_activations: feature_idx -> gene_name -> list of per-codon values + method: Aggregation method ("max" or "mean"). + + Returns: + feature_idx -> gene_name -> single scalar score + """ + agg_fn = np.max if method == "max" else np.mean + result = {} + for feat_idx, gene_dict in per_codon_activations.items(): + result[feat_idx] = {gene: float(agg_fn(vals)) for gene, vals in gene_dict.items()} + return result + + +def validate_databases(databases: List[str]) -> List[str]: + """Check Enrichr library names are available, warn on missing ones. + + Returns the subset of databases that are available. + """ + import gseapy + + available = set(gseapy.get_library_name()) + valid = [] + for db in databases: + if db in available: + valid.append(db) + else: + # Try to find a close match + candidates = [a for a in available if db.split("_")[0] in a] + if candidates: + logger.warning("Database '%s' not found in Enrichr. Similar: %s", db, candidates[:3]) + else: + logger.warning("Database '%s' not found in Enrichr.", db) + return valid + + +def _parse_go_id(term_string: str) -> str: + """Extract GO ID from Enrichr term string like 'translation (GO:0006412)'.""" + match = _GO_ID_PATTERN.search(term_string) + return match.group(1) if match else term_string + + +def _parse_term_name(term_string: str) -> str: + """Extract human-readable name from Enrichr term string.""" + # Remove trailing GO ID like " (GO:0006412)" + name = _GO_ID_PATTERN.sub("", term_string).strip() + return name if name else term_string + + +# ── Per-feature GSEA ───────────────────────────────────────────────────── + + +def run_gsea_for_feature( + feature_idx: int, + gene_scores: Dict[str, float], + databases: List[str], + fdr_threshold: float = 0.05, +) -> FeatureLabels: + """Run gseapy.prerank() against all databases for one feature. + + Args: + feature_idx: Index of the SAE feature. + gene_scores: gene_name -> activation score. + databases: List of Enrichr library names. + fdr_threshold: FDR cutoff for significance. + + Returns: + FeatureLabels with best enrichment per database. + """ + import gseapy + + # Build ranked gene list (descending by activation) + series = pd.Series(gene_scores).sort_values(ascending=False) + + best_per_db: Dict[str, Optional[EnrichmentResult]] = {} + all_significant: List[EnrichmentResult] = [] + overall_best: Optional[EnrichmentResult] = None + + for db in databases: + best_per_db[db] = None + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = gseapy.prerank( + rnk=series, + gene_sets=db, + no_plot=True, + outdir=None, + verbose=False, + seed=42, + ) + + if res.res2d is None or res.res2d.empty: + continue + + df = res.res2d.copy() + # gseapy returns FDR as 'FDR q-val' or 'fdr' + fdr_col = "FDR q-val" if "FDR q-val" in df.columns else "fdr" + es_col = "NES" if "NES" in df.columns else "nes" + pval_col = "NOM p-val" if "NOM p-val" in df.columns else "pval" + geneset_size_col = "Gene %" if "Gene %" in df.columns else "geneset_size" + + df[fdr_col] = pd.to_numeric(df[fdr_col], errors="coerce") + df = df.dropna(subset=[fdr_col]) + + if df.empty: + continue + + # Best = lowest FDR + best_row = df.loc[df[fdr_col].idxmin()] + term_raw = str(best_row.get("Term", best_row.name)) + + # Parse GO ID if applicable + is_go = db.startswith("GO_") + term_id = _parse_go_id(term_raw) if is_go else term_raw + term_name = _parse_term_name(term_raw) if is_go else term_raw + + fdr_val = float(best_row[fdr_col]) + es_val = float(best_row.get(es_col, 0.0)) + pval = float(best_row.get(pval_col, 1.0)) + n_genes = int(best_row.get(geneset_size_col, 0)) if geneset_size_col in df.columns else 0 + + result = EnrichmentResult( + feature_idx=feature_idx, + term_id=term_id, + term_name=term_name, + database=db, + enrichment_score=es_val, + pvalue=pval, + fdr=fdr_val, + n_genes_in_term=n_genes, + ) + + if fdr_val < fdr_threshold: + best_per_db[db] = result + all_significant.append(result) + if overall_best is None or fdr_val < overall_best.fdr: + overall_best = result + + # Also collect all significant terms from this database + sig_rows = df[df[fdr_col] < fdr_threshold] + for _, row in sig_rows.iterrows(): + t_raw = str(row.get("Term", row.name)) + if t_raw == term_raw: + continue # Already added the best + t_id = _parse_go_id(t_raw) if is_go else t_raw + t_name = _parse_term_name(t_raw) if is_go else t_raw + all_significant.append( + EnrichmentResult( + feature_idx=feature_idx, + term_id=t_id, + term_name=t_name, + database=db, + enrichment_score=float(row.get(es_col, 0.0)), + pvalue=float(row.get(pval_col, 1.0)), + fdr=float(row[fdr_col]), + n_genes_in_term=int(row.get(geneset_size_col, 0)) if geneset_size_col in df.columns else 0, + ) + ) + + except Exception as e: + logger.debug("GSEA failed for feature %d, db %s: %s", feature_idx, db, e) + continue + + return FeatureLabels( + feature_idx=feature_idx, + best_per_database=best_per_db, + overall_best=overall_best, + all_significant=all_significant, + ) + + +# ── Worker function for multiprocessing ────────────────────────────────── + + +def _worker_gsea(args: Tuple[int, Dict[str, float], List[str], float]) -> FeatureLabels: + """Multiprocessing worker: run GSEA for a single feature.""" + feature_idx, gene_scores, databases, fdr_threshold = args + return run_gsea_for_feature(feature_idx, gene_scores, databases, fdr_threshold) + + +# ── Parallel dispatch ──────────────────────────────────────────────────── + + +def run_gene_enrichment( + gene_activations: Dict[int, Dict[str, float]], + databases: Optional[List[str]] = None, + fdr_threshold: float = 0.05, + n_workers: int = 4, + show_progress: bool = True, +) -> GeneEnrichmentReport: + """Run GSEA enrichment for all features in parallel. + + Args: + gene_activations: feature_idx -> gene_name -> activation score. + databases: Enrichr library names (default: ANNOTATION_DATABASES). + fdr_threshold: FDR cutoff for significance. + n_workers: Number of parallel workers. + show_progress: Whether to show a progress bar. + + Returns: + GeneEnrichmentReport with enrichment results for all features. + """ + from tqdm import tqdm + + if databases is None: + databases = list(ANNOTATION_DATABASES) + + # Validate databases + valid_databases = validate_databases(databases) + if not valid_databases: + raise ValueError(f"None of the requested databases are available in Enrichr: {databases}") + if len(valid_databases) < len(databases): + logger.warning("Using %d/%d databases: %s", len(valid_databases), len(databases), valid_databases) + + # Build work items, skip dead/flat features + work_items = [] + skipped = 0 + for feat_idx, gene_scores in gene_activations.items(): + vals = list(gene_scores.values()) + if not vals or max(vals) == 0: + skipped += 1 + continue + if len(set(vals)) <= 1: + skipped += 1 + continue + work_items.append((feat_idx, gene_scores, valid_databases, fdr_threshold)) + + if skipped > 0: + logger.info("Skipped %d dead/flat features", skipped) + + n_features_total = len(gene_activations) + per_feature_results: List[FeatureLabels] = [] + + if n_workers <= 1: + iterator = work_items + if show_progress: + iterator = tqdm(iterator, desc="GSEA enrichment") + for item in iterator: + per_feature_results.append(_worker_gsea(item)) + else: + with Pool(n_workers) as pool: + iterator = pool.imap_unordered(_worker_gsea, work_items) + if show_progress: + iterator = tqdm(iterator, total=len(work_items), desc="GSEA enrichment") + for result in iterator: + per_feature_results.append(result) + + # Sort by feature index for deterministic output + per_feature_results.sort(key=lambda x: x.feature_idx) + + # Compute stats + n_enriched = sum(1 for fl in per_feature_results if fl.overall_best is not None) + per_db_stats = {} + for db in valid_databases: + db_enriched = [fl for fl in per_feature_results if fl.best_per_database.get(db) is not None] + unique_terms = {fl.best_per_database[db].term_id for fl in db_enriched} + per_db_stats[db] = {"n_enriched": len(db_enriched), "n_unique_terms": len(unique_terms)} + + # Build label columns + label_columns = build_feature_label_columns(per_feature_results, n_features_total) + + return GeneEnrichmentReport( + per_feature=per_feature_results, + databases_used=valid_databases, + n_features_with_enrichment=n_enriched, + n_features_total=n_features_total, + frac_enriched=n_enriched / max(n_features_total, 1), + per_database_stats=per_db_stats, + feature_label_columns=label_columns, + significance_threshold=fdr_threshold, + ) + + +# ── GO Slim rollup ────────────────────────────────────────────────────── + + +def download_obo_files(output_dir: str) -> Tuple[Path, Path]: + """Download go-basic.obo and goslim_generic.obo if not present. + + Returns: + (go_basic_path, go_slim_path) + """ + import urllib.request + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + go_basic_path = output_dir / "go-basic.obo" + go_slim_path = output_dir / "goslim_generic.obo" + + if not go_basic_path.exists(): + logger.info("Downloading go-basic.obo...") + urllib.request.urlretrieve( + "http://purl.obolibrary.org/obo/go/go-basic.obo", + str(go_basic_path), + ) + + if not go_slim_path.exists(): + logger.info("Downloading goslim_generic.obo...") + urllib.request.urlretrieve( + "http://purl.obolibrary.org/obo/go/subsets/goslim_generic.obo", + str(go_slim_path), + ) + + return go_basic_path, go_slim_path + + +def rollup_go_slim( + feature_labels: List[FeatureLabels], + go_basic_path: str, + go_slim_path: str, +) -> List[FeatureLabels]: + """Walk GO DAG upward to nearest GO Slim ancestor for each feature. + + Modifies FeatureLabels in-place, setting go_slim_term and go_slim_name. + + Args: + feature_labels: List of FeatureLabels (modified in-place). + go_basic_path: Path to go-basic.obo file. + go_slim_path: Path to goslim_generic.obo file. + + Returns: + The same list, with go_slim_term/go_slim_name populated. + """ + from goatools.obo_parser import GODag + + go_dag = GODag(str(go_basic_path)) + slim_dag = GODag(str(go_slim_path)) + slim_ids = set(slim_dag.keys()) + + def _find_slim_ancestor(go_id: str) -> Optional[Tuple[str, str]]: + """Walk parents until we hit a GO Slim term.""" + if go_id not in go_dag: + return None + if go_id in slim_ids: + term = go_dag[go_id] + return go_id, term.name + + visited = set() + queue = [go_id] + while queue: + current = queue.pop(0) + if current in visited: + continue + visited.add(current) + if current not in go_dag: + continue + term = go_dag[current] + for parent in term.parents: + if parent.id in slim_ids: + return parent.id, parent.name + queue.append(parent.id) + return None + + for fl in feature_labels: + # Find a GO term to roll up from + go_term_id = None + for db_key in ["GO_Biological_Process_2023", "GO_Molecular_Function_2023", "GO_Cellular_Component_2023"]: + best = fl.best_per_database.get(db_key) + if best is not None and best.term_id.startswith("GO:"): + go_term_id = best.term_id + break + if fl.overall_best is not None and fl.overall_best.term_id.startswith("GO:"): + go_term_id = fl.overall_best.term_id + + if go_term_id is not None: + slim = _find_slim_ancestor(go_term_id) + if slim is not None: + fl.go_slim_term, fl.go_slim_name = slim + + return feature_labels + + +# ── Label columns for UMAP ────────────────────────────────────────────── + + +def build_feature_label_columns( + per_feature: List[FeatureLabels], + n_features: int, +) -> Dict[str, Dict[int, str]]: + """Build dict[column_name, dict[feature_idx, label]] for UMAP dropdown. + + Keys: overall_best, GO_Biological_Process, GO_Molecular_Function, + GO_Cellular_Component, InterPro_Domains, Pfam_Domains, GO_Slim + """ + db_to_column = { + "GO_Biological_Process_2023": "GO_Biological_Process", + "GO_Molecular_Function_2023": "GO_Molecular_Function", + "GO_Cellular_Component_2023": "GO_Cellular_Component", + "InterPro_Domains_2019": "InterPro_Domains", + "Pfam_Domains_2019": "Pfam_Domains", + } + + columns: Dict[str, Dict[int, str]] = { + "overall_best": {}, + "GO_Biological_Process": {}, + "GO_Molecular_Function": {}, + "GO_Cellular_Component": {}, + "InterPro_Domains": {}, + "Pfam_Domains": {}, + "GO_Slim": {}, + } + + for fl in per_feature: + idx = fl.feature_idx + + if fl.overall_best is not None: + columns["overall_best"][idx] = fl.overall_best.term_name + else: + columns["overall_best"][idx] = "unlabeled" + + for db, col_name in db_to_column.items(): + best = fl.best_per_database.get(db) + if best is not None: + columns[col_name][idx] = best.term_name + else: + columns[col_name][idx] = "unlabeled" + + if fl.go_slim_name is not None: + columns["GO_Slim"][idx] = fl.go_slim_name + else: + columns["GO_Slim"][idx] = "unlabeled" + + # Fill missing feature indices with "unlabeled" + for col in columns: + for i in range(n_features): + if i not in columns[col]: + columns[col][i] = "unlabeled" + + return columns diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/loss_recovered.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/loss_recovered.py new file mode 100644 index 0000000000..1814719a1f --- /dev/null +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/codonfm/src/codonfm_sae/eval/loss_recovered.py @@ -0,0 +1,197 @@ +# 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. + +"""Loss recovered evaluation for SAEs on CodonFM (Encodon). + +CodonFM-specific wrapper around the general loss_recovered metric. +CE is computed over all non-special codon tokens (positions 1..length-2), +matching extract.py's CLS/SEP removal. +""" + +from typing import List, Tuple + +import numpy as np +import torch +import torch.nn.functional as F +from sae.eval import LossRecoveredResult, evaluate_loss_recovered + + +def evaluate_codonfm_loss_recovered( + sae: torch.nn.Module, + inference, + sequences: List[str], + layer: int, + context_length: int = 2048, + batch_size: int = 8, + device: str = "cuda", + seed: int = 42, +) -> LossRecoveredResult: + """Evaluate SAE loss recovered on codon sequences. + + Args: + sae: Trained sparse autoencoder. + inference: EncodonInference instance (already configured, on device). + sequences: List of DNA sequences. + layer: Layer index (negative indexing supported). + context_length: Max context length for tokenization. + batch_size: Batch size for evaluation. + device: Device to run on. + seed: Random seed. + + Returns: + LossRecoveredResult with loss_recovered score and CE breakdowns. + """ + from src.data.preprocess.codon_sequence import process_item + + np.random.seed(seed) + torch.manual_seed(seed) + + # Resolve negative layer index + num_layers = len(inference.model.model.layers) + layer_idx = layer if layer >= 0 else num_layers + layer + + # The EnCodon model: inference.model -> EncodonPL -> .model -> EnCodon + encodon_model = inference.model.model + encoder_layers = encodon_model.layers + + # Pre-tokenize into batches + batches = [] + for i in range(0, len(sequences), batch_size): + batch_seqs = sequences[i : i + batch_size] + items = [process_item(s, context_length=context_length, tokenizer=inference.tokenizer) for s in batch_seqs] + batch = { + "input_ids": torch.tensor(np.stack([it["input_ids"] for it in items])).to(device), + "attention_mask": torch.tensor(np.stack([it["attention_mask"] for it in items])).to(device), + } + batches.append(batch) + + def get_hiddens(batch): + out = inference.model(batch, return_hidden_states=True) + return out.all_hidden_states[layer_idx] + + def compute_ce(batch, hidden_override=None): + input_ids = batch["input_ids"] + attention_mask = batch["attention_mask"] + + if hidden_override is None: + out = inference.model(batch) + logits = out.logits + else: + logits = _forward_with_hidden( + encodon_model, + encoder_layers, + layer_idx, + input_ids, + attention_mask, + hidden_override, + ) + + return _codonfm_sequence_ce(logits, input_ids, attention_mask) + + def get_recon_mask(batch): + return _reconstruction_mask(batch["attention_mask"]) + + return evaluate_loss_recovered( + sae=sae, + batches=batches, + get_hiddens=get_hiddens, + compute_ce=compute_ce, + device=device, + get_recon_mask=get_recon_mask, + ) + + +def _forward_with_hidden( + encodon_model, + encoder_layers, + layer_idx: int, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + hidden_override: torch.Tensor, +) -> torch.Tensor: + """Forward pass with the hidden state at layer_idx replaced. + + EncoderLayer.forward returns a single tensor (not a tuple), + so the hook simply returns the replacement. + """ + + def hook_fn(module, inputs, output): + return hidden_override.to(dtype=output.dtype) + + handle = encoder_layers[layer_idx].register_forward_hook(hook_fn) + try: + out = encodon_model(input_ids=input_ids, attention_mask=attention_mask) + return out.logits + finally: + handle.remove() + + +def _reconstruction_mask(attention_mask: torch.Tensor) -> torch.Tensor: + """Bool mask excluding CLS (pos 0), SEP (last real pos), and padding. + + Matches extract.py's `layer_acts[j, 1:seq_len-1, :]` logic. + """ + B, L = attention_mask.shape + mask = attention_mask.bool().clone() + + # Exclude CLS (position 0) + if L > 0: + mask[:, 0] = False + + # Exclude SEP (last real token per sequence) + lengths = attention_mask.sum(dim=1) + for i in range(B): + sep = int(lengths[i].item()) - 1 + if sep > 0: + mask[i, sep] = False + + return mask + + +def _codonfm_sequence_ce( + logits: torch.Tensor, + labels: torch.Tensor, + attention_mask: torch.Tensor, +) -> Tuple[float, int]: + """CE over non-special, non-padding codon tokens. + + Excludes CLS (pos 0) and SEP (last real pos). + Returns (total_ce, n_tokens). + """ + B, L, V = logits.shape + + valid_mask = attention_mask.clone() + + # Exclude CLS + if L > 0: + valid_mask[:, 0] = 0 + + # Exclude SEP + lengths = attention_mask.sum(dim=1) + for i in range(B): + end_pos = int(lengths[i].item()) - 1 + if 0 <= end_pos < L: + valid_mask[i, end_pos] = 0 + + ce = F.cross_entropy( + logits.view(-1, V), + labels.view(-1), + reduction="none", + ).view(B, L) + + total_ce = (ce * valid_mask.float()).sum().item() + n_tokens = int(valid_mask.sum().item()) + + return total_ce, n_tokens diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/README.md b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/README.md index c103efc660..2af7f9306b 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/README.md +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/README.md @@ -98,7 +98,6 @@ This computes F1 scores against Swiss-Prot annotations, loss recovered metrics, | Model | Params | Embedding Dim | Layers | Batch Size | Config | | --------- | ------ | ------------- | ------ | ---------- | ------------ | -| ESM2-8M | 8M | 320 | 6 | 32 | `model=8m` | | ESM2-650M | 650M | 1280 | 33 | 16 | `model=650m` | | ESM2-3B | 3B | 2560 | 36 | 4 | `model=3b` | | ESM2-15B | 15B | 5120 | 48 | 1 | `model=15b` | @@ -125,24 +124,26 @@ recipes/esm2/ extract.py Extract layer activations (multi-GPU) train.py Train TopK SAE (multi-GPU) eval.py Evaluate + build dashboard data + launch_dashboard.py Launch the interactive dashboard + 650m.sh, 3b.sh, 15b.sh Ready-to-run shell scripts src/esm2_sae/ Recipe-specific code data/ Protein data loaders (FASTA, SwissProt, UniRef50) eval/ F1 scores, loss recovered + analysis/ Protein ranking and interpretability viz/ UMAP, feature stats, top examples data_export.py Parquet/DuckDB export protein_dashboard/ React/Vite interactive dashboard - notebooks/ Jupyter notebooks (minimal workflow, checkpoint-to-dashboard) - 650m.sh, 3b.sh, 15b.sh Ready-to-run shell scripts ``` ## Python API ```python from sae.architectures import TopKSAE -from esm2_sae.data import read_fasta, download_swissprot +from esm2_sae import read_fasta, download_swissprot -# Load sequences -sequences = read_fasta("proteins.fasta") +# Load sequences (returns list of FastaRecord with .id and .sequence) +records = read_fasta("proteins.fasta") +sequences = [r.sequence for r in records] # Load trained SAE import torch @@ -168,4 +169,4 @@ save_activations_parquet( save_activations_duckdb(codes=codes, protein_ids=ids, db_path="data.duckdb") ``` -Requires: `uv pip install pyarrow duckdb` or `uv sync --extra export`. +Requires: `pip install pyarrow duckdb` or install with the export extra. diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/README.md b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/README.md index a720f6525d..519caca670 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/README.md +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/README.md @@ -9,32 +9,33 @@ Interactive dashboard for exploring Sparse Autoencoder (SAE) features with UMAP - **Feature Cards**: Expandable cards showing: - Feature description/label - Activation frequency and max activation stats - - Top positive/negative logits (tokens the feature promotes/suppresses) - - Top activating examples with token highlighting + - Top activating examples with per-residue activation highlighting - **Search**: Filter features by description text - **Color by Category**: Color points by categorical or sequential columns ## Usage -### From Python (via `sae.launch_dashboard`) +### From Python (via `esm2_sae.launch_protein_dashboard`) ```python -from sae import launch_dashboard +from esm2_sae import launch_protein_dashboard # Launch dashboard with your data -launch_dashboard( - features_json="path/to/features.json", - atlas_parquet="path/to/features_atlas.parquet", - port=5173, +proc = launch_protein_dashboard( + "path/to/features_atlas.parquet", + features_dir="path/to/dashboard_dir", ) +input("Dashboard running. Press Enter to stop.\n") +proc.terminate() ``` ### Manual Setup 1. Copy your data files to the `public/` directory: - - `features.json` - Feature metadata with examples - `features_atlas.parquet` - UMAP coordinates and stats + - `feature_metadata.parquet` - Feature metadata + - `feature_examples.parquet` - Top activating examples per feature 2. Install dependencies and run: @@ -47,43 +48,82 @@ launch_dashboard( ## Data Format -### features.json - -```json -{ - "features": [ - { - "feature_id": 0, - "description": "Feature description", - "activation_freq": 0.05, - "max_activation": 12.5, - "top_positive_logits": [["token1", 2.5], ["token2", 2.1]], - "top_negative_logits": [["token3", -1.8], ["token4", -1.5]], - "examples": [ - { - "max_activation": 10.2, - "tokens": [ - {"token": "hello", "activation": 0.0}, - {"token": " world", "activation": 10.2} - ] - } - ] - } - ] -} +The dashboard loads three Parquet files from the `public/` directory via DuckDB-WASM. + +### `features_atlas.parquet` + +One row per SAE feature. Drives the UMAP scatter plot and histograms. + +| Column | Type | Description | +| ------------------- | ------- | ------------------------------------------------------ | +| `feature_id` | INT32 | Feature index (0 to n_features-1) | +| `label` | VARCHAR | Display label (e.g. "Kinase (F1:0.82)" or "Feature 5") | +| `x` | FLOAT | UMAP x coordinate (from decoder weights) | +| `y` | FLOAT | UMAP y coordinate | +| `activation_freq` | FLOAT | Fraction of residues where feature fires (> 0) | +| `log_frequency` | FLOAT | log10(activation_freq), clamped to -10 when zero | +| `mean_activation` | FLOAT | Mean activation when active | +| `max_activation` | FLOAT | Maximum activation observed | +| `std_activation` | FLOAT | Std dev of activation when active | +| `total_activations` | INT64 | Total count of firings | +| `cluster_id` | INT32 | HDBSCAN cluster (NULL for noise points) | + +Any additional VARCHAR column with \<= 50 unique values is available as a coloring option. + +### `feature_metadata.parquet` + +One row per SAE feature. Loaded into a DuckDB table for feature card display. + +| Column | Type | Description | +| ----------------- | ------- | ------------------------------------------------- | +| `feature_id` | INT32 | Feature index | +| `description` | VARCHAR | Best annotation or "Feature {id}" if unlabeled | +| `activation_freq` | FLOAT32 | Fraction of residues where feature fires | +| `max_activation` | FLOAT32 | Global maximum activation | +| `best_f1` | FLOAT32 | F1 score for best SwissProt annotation (nullable) | +| `best_annotation` | VARCHAR | Best SwissProt annotation string (nullable) | + +### `feature_examples.parquet` + +Top activating protein examples per feature. Loaded as a DuckDB view and queried lazily when a feature card is expanded. + +| Column | Type | Description | +| ---------------- | -------------- | ---------------------------------------------------- | +| `feature_id` | INT32 | Feature index | +| `example_rank` | INT8 | Rank within feature (0 = highest activation) | +| `protein_id` | VARCHAR | UniProt accession (e.g. "sp\|P12345\|...") | +| `alphafold_id` | VARCHAR | AlphaFold structure ID (e.g. "AF-P12345-F1") | +| `sequence` | VARCHAR | Amino acid sequence | +| `activations` | LIST\ | Per-residue activation values (same len as sequence) | +| `max_activation` | FLOAT32 | Max activation for this protein on this feature | + +Sorted by `feature_id` for efficient row-group pushdown queries. + +## Inspecting the Data + +```bash +# Requires: pip install duckdb +python -c " +import duckdb +con = duckdb.connect() + +# Atlas overview +con.sql(\"SELECT * FROM 'features_atlas.parquet' LIMIT 5\").show() + +# Top annotated features +con.sql(\"\"\" + SELECT feature_id, description, best_f1, activation_freq + FROM 'feature_metadata.parquet' + WHERE best_f1 IS NOT NULL + ORDER BY best_f1 DESC + LIMIT 10 +\"\"\").show() + +# Top examples for a specific feature +con.sql(\"\"\" + SELECT feature_id, example_rank, protein_id, max_activation + FROM 'feature_examples.parquet' + WHERE feature_id = 42 +\"\"\").show() +" ``` - -### features_atlas.parquet - -Required columns: - -- `feature_id`: Integer feature ID -- `x`, `y`: UMAP coordinates -- `label` or `best_annotation`: Feature label for display -- `log_frequency`: Log of activation frequency -- `max_activation`: Maximum activation value - -Optional columns for coloring: - -- Any VARCHAR column with \<= 50 unique values (categorical) -- `cluster`, `category`, `group` integer columns (categorical) diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/index.html b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/index.html index 9d50e92abb..1cf8502082 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/index.html +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/index.html @@ -41,6 +41,50 @@ src: url(https://brand-assets.cne.ngc.nvidia.com/assets/fonts/nvidia-sans/1.0.0/NVIDIASans_BdIt.woff2); font-weight: bold; } + :root { + --bg: #f5f5f5; + --bg-card: #fff; + --bg-card-expanded: #fafafa; + --bg-example: #fff; + --bg-input: #fff; + --border: #e0e0e0; + --border-light: #eee; + --border-input: #ddd; + --text: #333; + --text-secondary: #666; + --text-tertiary: #888; + --text-muted: #999; + --text-heading: #000; + --accent: #76b900; + --highlight-border: #222; + --highlight-shadow: rgba(0,0,0,0.15); + --link: #2563eb; + --loading-bar-bg: #e0e0e0; + --density-bar-bg: #e0e0e0; + --scrollbar-thumb: #ccc; + } + :root.dark { + --bg: #000; + --bg-card: #000; + --bg-card-expanded: #000; + --bg-example: #0a0a0a; + --bg-input: #0a0a0a; + --border: #444; + --border-light: #3a3a3a; + --border-input: #4a4a4a; + --text: #E0E0E0; + --text-secondary: #bbb; + --text-tertiary: #999; + --text-muted: #777; + --text-heading: #fff; + --accent: #76b900; + --highlight-border: #76b900; + --highlight-shadow: rgba(118,185,0,0.3); + --link: #76b900; + --loading-bar-bg: #444; + --density-bar-bg: #444; + --scrollbar-thumb: #555; + } * { margin: 0; padding: 0; @@ -48,7 +92,8 @@ } body { font-family: 'NVIDIA Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f5f5f5; + background: var(--bg); + color: var(--text); } diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/App.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/App.jsx index 85cf7b564e..53a8e0d957 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/App.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/App.jsx @@ -32,10 +32,10 @@ function InfoButton({ text }) { width: '15px', height: '15px', borderRadius: '50%', - border: '1px solid #bbb', + border: '1px solid var(--border-input)', fontSize: '10px', fontWeight: '600', - color: '#888', + color: 'var(--text-tertiary)', cursor: 'pointer', userSelect: 'none', lineHeight: 1, @@ -50,15 +50,15 @@ function InfoButton({ text }) { left: '50%', transform: 'translateX(-50%)', width: '240px', - background: '#fff', - border: '1px solid #ddd', + background: 'var(--bg-card)', + border: '1px solid var(--border-input)', borderRadius: '6px', padding: '10px 12px', fontSize: '12px', fontWeight: 'normal', - color: '#444', + color: 'var(--text-secondary)', lineHeight: '1.5', - boxShadow: '0 2px 8px rgba(0,0,0,0.12)', + boxShadow: '0 2px 8px rgba(0,0,0,0.2)', zIndex: 10, }}> {text} @@ -74,8 +74,10 @@ const styles = { display: 'flex', flexDirection: 'column', padding: '16px', - gap: '12px', + gap: '4px', overflow: 'hidden', + background: 'var(--bg)', + color: 'var(--text)', }, header: { flexShrink: 0, @@ -84,15 +86,17 @@ const styles = { fontSize: '22px', fontWeight: '600', marginBottom: '2px', + color: 'var(--text-heading)', }, subtitle: { - color: '#666', + color: 'var(--text-secondary)', fontSize: '13px', + margin: 0, }, mainContent: { flex: 1, display: 'grid', - gridTemplateColumns: '60% 40%', + gridTemplateColumns: '3fr 2fr', gap: '16px', minHeight: 0, overflow: 'hidden', @@ -102,17 +106,19 @@ const styles = { flexDirection: 'column', gap: '12px', minHeight: 0, + minWidth: 0, overflow: 'hidden', }, embeddingPanel: { flex: 1, - background: '#fff', + background: 'var(--bg-card)', borderRadius: '8px', - border: '1px solid #e0e0e0', + border: '1px solid var(--border)', padding: '12px', display: 'flex', flexDirection: 'column', minHeight: '300px', + minWidth: 0, overflow: 'hidden', }, panelHeader: { @@ -136,12 +142,15 @@ const styles = { gridTemplateColumns: '1fr 1fr', gap: '12px', flexShrink: 0, + minHeight: '100px', + marginBottom: '4px', }, histogramPanel: { - background: '#fff', + background: 'var(--bg-card)', borderRadius: '8px', - border: '1px solid #e0e0e0', - padding: '12px', + border: '1px solid var(--border)', + padding: '8px', + overflow: 'hidden', }, rightPanel: { display: 'flex', @@ -149,6 +158,7 @@ const styles = { gap: '10px', minHeight: 0, height: '100%', + overflow: 'hidden', }, searchBar: { display: 'flex', @@ -156,25 +166,28 @@ const styles = { flexShrink: 0, }, searchInput: { - flex: 1, + flex: 0.81, padding: '8px 12px', fontSize: '13px', - border: '1px solid #ddd', + 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 #ddd', + border: '1px solid var(--border-input)', borderRadius: '6px', - background: 'white', + background: 'var(--bg-input)', + color: 'var(--text)', cursor: 'pointer', }, stats: { padding: '4px 0', fontSize: '12px', - color: '#666', + color: 'var(--text-secondary)', flexShrink: 0, }, featureList: { @@ -190,7 +203,7 @@ const styles = { loading: { textAlign: 'center', padding: '40px', - color: '#666', + color: 'var(--text-secondary)', }, error: { textAlign: 'center', @@ -200,24 +213,38 @@ const styles = { colorSelect: { padding: '4px 8px', fontSize: '12px', - border: '1px solid #ddd', + border: '1px solid var(--border-input)', borderRadius: '4px', - background: 'white', + background: 'var(--bg-input)', + color: 'var(--text)', cursor: 'pointer', }, clearButton: { padding: '4px 12px', fontSize: '12px', - border: '2px solid #76b900', + border: '2px solid var(--accent)', borderRadius: '4px', - background: 'white', - color: '#76b900', + background: 'transparent', + color: 'var(--accent)', fontWeight: '600', cursor: 'pointer', }, + darkModeBtn: { + padding: '4px 10px', + fontSize: '16px', + border: '1px solid var(--border-input)', + borderRadius: '6px', + background: 'var(--bg-input)', + color: 'var(--text)', + cursor: 'pointer', + lineHeight: 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + }, } -export default function App({ title = "SAE Feature Explorer", subtitle = "Explore sparse autoencoder features with UMAP embedding and crossfiltering" }) { +export default function App({ title = "ESM2 Sparse Autoencoder Feature Explorer", subtitle = "Explore sparse autoencoder features with UMAP embedding and crossfiltering" }) { const [features, setFeatures] = useState([]) const [loading, setLoading] = useState(true) const [loadingProgress, setLoadingProgress] = useState({ step: 0, total: 4, message: 'Starting up...' }) @@ -229,8 +256,13 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor const [selectedCategory, setSelectedCategory] = useState('none') const [clickedFeatureId, setClickedFeatureId] = useState(null) const [clusterLabels, setClusterLabels] = useState(null) + const [vocabLogits, setVocabLogits] = useState(null) + const [darkMode, setDarkMode] = useState(true) + const [histMetric1, setHistMetric1] = useState('log_frequency') + const [histMetric2, setHistMetric2] = useState('max_activation') const brushRef = useRef(null) + const [searchTerm, setSearchTerm] = useState('') const [cardResetKey, setCardResetKey] = useState(0) const [plotResetKey, setPlotResetKey] = useState(0) @@ -239,6 +271,18 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor const featureListRef = useRef(null) const searchSource = useRef({ source: 'search' }) + // Dark mode toggle + useEffect(() => { + document.documentElement.classList.toggle('dark', darkMode) + }, [darkMode]) + + // Sync second histogram with color-by selection + useEffect(() => { + if (selectedCategory && selectedCategory !== 'none') { + setHistMetric2(selectedCategory) + } + }, [selectedCategory]) + // Lazy-load examples for a single feature from DuckDB (feature_examples VIEW) const loadExamplesForFeature = useCallback(async (featureId) => { const result = await vg.coordinator().query( @@ -254,155 +298,18 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor })) }, []) - // 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 - if (!initialViewportRef.current && vp) { - initialViewportRef.current = { ...vp } - } - // Always track current viewport (but not during our own animations) - if (!animationRef.current) { - currentViewportRef.current = vp - } - }, []) - - // Easing functions - const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4) - const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2 - const easeInOutQuad = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2 - - // Smooth zoom-in with "fly-to" trajectory (zoom out -> pan -> zoom in) - const zoomToPoint = useCallback((x, y) => { - if (x == null || y == null) return - - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - animationRef.current = null - } - - const start = currentViewportRef.current || initialViewportRef.current || { x: 0, y: 0, scale: 1 } - const targetScale = 4 - const duration = 800 - const startTime = performance.now() - - // Calculate how far we need to pan (in data space) - const panDistance = Math.sqrt(Math.pow(x - start.x, 2) + Math.pow(y - start.y, 2)) - - // Determine the "cruise altitude" - how much to zoom out during the pan - // Zoom out more for longer distances, less for short distances - const minScale = Math.min(start.scale, 0.8) // Never zoom out below 0.8 - const maxZoomOut = Math.max(0, start.scale - minScale) - const zoomOutAmount = Math.min(maxZoomOut, panDistance * 0.1) // Scale zoom-out with distance - const cruiseScale = start.scale - zoomOutAmount - - const animate = (currentTime) => { - const elapsed = currentTime - startTime - const t = Math.min(elapsed / duration, 1) - - // Use smooth ease-in-out for the overall progress - const smoothT = easeInOutCubic(t) - - // Pan follows the smooth progress - const panT = smoothT - - // Zoom follows a "U-shaped" profile: - // - First half: ease from start.scale down to cruiseScale (or stay flat if already low) - // - Second half: ease from cruiseScale up to targetScale - let zoomScale - if (t < 0.4) { - // First 40%: zoom out slightly (ease-out) - const zoomOutT = t / 0.4 - const easeOut = 1 - Math.pow(1 - zoomOutT, 2) - zoomScale = start.scale + (cruiseScale - start.scale) * easeOut - } else if (t < 0.6) { - // Middle 20%: hold at cruise altitude - zoomScale = cruiseScale - } else { - // Last 40%: zoom in to target (ease-in then ease-out) - const zoomInT = (t - 0.6) / 0.4 - const easeInOut = easeInOutQuad(zoomInT) - zoomScale = cruiseScale + (targetScale - cruiseScale) * easeInOut - } - - const newViewport = { - x: start.x + (x - start.x) * panT, - y: start.y + (y - start.y) * panT, - scale: zoomScale - } - - setViewportState(newViewport) - - if (t < 1) { - animationRef.current = requestAnimationFrame(animate) - } else { - currentViewportRef.current = { x, y, scale: targetScale } - animationRef.current = null - } - } - - animationRef.current = requestAnimationFrame(animate) - }, []) - - // Smooth zoom-out: zoom out first, then pan back - const resetViewport = useCallback(() => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - animationRef.current = null - } - - const start = currentViewportRef.current || { x: 0, y: 0, scale: 1 } - const target = initialViewportRef.current || { x: 0, y: 0, scale: 1 } - const duration = 600 - const startTime = performance.now() - - const animate = (currentTime) => { - const elapsed = currentTime - startTime - const t = Math.min(elapsed / duration, 1) - - // Zoom out fast at start (ease-out) - const zoomT = easeOutQuart(t) - - // Pan eases in-out - const panT = easeInOutCubic(t) - - const newViewport = { - x: start.x + (target.x - start.x) * panT, - y: start.y + (target.y - start.y) * panT, - scale: start.scale + (target.scale - start.scale) * zoomT - } - - setViewportState(newViewport) - - if (t < 1) { - animationRef.current = requestAnimationFrame(animate) - } else { - currentViewportRef.current = { ...target } - animationRef.current = null - } - } - - animationRef.current = requestAnimationFrame(animate) + currentViewportRef.current = vp }, []) - // Handle click on a feature in the UMAP (with coordinates for zooming) + // Handle click on a feature in the UMAP (highlight + scroll, no zoom) const handleFeatureClick = useCallback((featureId, x, y) => { - console.log('Feature clicked in UMAP:', featureId, x, y) - setClickedFeatureId(featureId) - // If clicking on empty canvas (featureId is null), reset viewport and return - if (featureId == null) { - resetViewport() - return - } - - // Zoom to the clicked point - zoomToPoint(x, y) + if (featureId == null) return // Scroll to the feature card after a short delay to allow render setTimeout(() => { @@ -411,35 +318,17 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor ref.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, 50) - }, [zoomToPoint, resetViewport]) - - // Handle click on a feature card (highlights point in UMAP and zooms to it) - const handleCardClick = useCallback(async (featureId, isExpanding) => { - console.log('Feature card clicked:', featureId, isExpanding ? 'expanding' : 'collapsing') + }, []) - // If collapsing, zoom back out and clear highlight + // Handle click on a feature card (highlights point in UMAP) + const handleCardClick = useCallback((featureId, isExpanding) => { if (!isExpanding) { setClickedFeatureId(null) - resetViewport() return } setClickedFeatureId(featureId) - - // Query for the feature's coordinates and zoom to it - try { - const result = await vg.coordinator().query(` - SELECT x, y FROM features WHERE feature_id = ${featureId} LIMIT 1 - `) - const rows = result.toArray() - if (rows.length > 0) { - const { x, y } = rows[0] - zoomToPoint(x, y) - } - } catch (err) { - console.warn('Could not get feature coordinates:', err) - } - }, [zoomToPoint, resetViewport]) + }, []) // Initialize Mosaic and load data useEffect(() => { @@ -581,6 +470,40 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor })) setFeatures(loadedFeatures) + // Derive annotation_type (top-level category) from best_annotation + try { + await vg.coordinator().exec(` + CREATE OR REPLACE TABLE features AS + SELECT f.*, + CASE + WHEN m.best_annotation IS NULL OR m.best_annotation = '' OR m.best_annotation = 'None' THEN 'unlabeled' + WHEN CONTAINS(m.best_annotation, ':') THEN SPLIT_PART(m.best_annotation, ':', 1) + ELSE m.best_annotation + END AS annotation_type + FROM features f + LEFT JOIN feature_metadata m ON f.feature_id = m.feature_id + `) + // Add integer-encoded version for embedding-atlas + await vg.coordinator().exec(` + CREATE OR REPLACE TABLE features AS + SELECT *, + DENSE_RANK() OVER (ORDER BY annotation_type) - 1 AS annotation_type_cat + FROM features + `) + const cardResult = await vg.coordinator().query(` + SELECT COUNT(DISTINCT annotation_type) as n_unique FROM features WHERE annotation_type IS NOT NULL + `) + const nUnique = cardResult.toArray()[0]?.n_unique ?? 0 + if (nUnique > 0 && nUnique <= 50) { + detectedCategories.push({ name: 'annotation_type', type: 'string', nUnique }) + setCategoryColumns([...detectedCategories]) + // Default color-by to annotation_type + setSelectedCategory('annotation_type') + } + } catch (err) { + console.warn('Could not create annotation_type column:', err) + } + // Load cluster labels (non-fatal if missing) try { const labelsRes = await fetch('./cluster_labels.json') @@ -593,6 +516,19 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor console.log('No cluster labels found (optional)') } + // 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) + console.log(`Loaded vocab logits for ${Object.keys(logitsData).length} features`) + } + } catch (e) { + console.log('No vocab logits found (optional)') + } + + // Pre-cache feature coordinates for instant zoom setMosaicReady(true) setLoading(false) @@ -718,8 +654,6 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor setClickedFeatureId(null) // Reset viewport - will be handled by remount with plotResetKey setViewportState(null) - // Clear initial viewport ref so it gets recaptured after remount - initialViewportRef.current = null currentViewportRef.current = null // Reset all cards to collapsed state setCardResetKey(k => k + 1) @@ -785,6 +719,8 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor result = [...result].sort((a, b) => (b.activation_freq || 0) - (a.activation_freq || 0)) } else if (sortBy === 'max_activation') { result = [...result].sort((a, b) => (b.max_activation || 0) - (a.max_activation || 0)) + } else if (sortBy === 'best_f1') { + result = [...result].sort((a, b) => (b.best_f1 || 0) - (a.best_f1 || 0)) } else if (sortBy === 'feature_id') { result = [...result].sort((a, b) => a.feature_id - b.feature_id) } @@ -800,7 +736,7 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor
- + @@ -863,7 +808,7 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor
-
+
{mosaicReady && ( 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, ' ')} + +
+ ) + } + + // Categorical legend (string or integer types) + const catColors = [ + "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", + "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", + "#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5", + "#c49c94", "#f7b6d2", "#c7c7c7", "#dbdb8d", "#9edae5" + ] + // Get distinct values sorted alphabetically (matches DENSE_RANK ORDER BY) + const distinctVals = [...new Set( + features.map(f => f.best_annotation) + )] + // Derive category labels the same way as SQL + const categoryLabels = [...new Set( + distinctVals.map(v => { + if (v == null || v === '' || v === 'None') return 'unlabeled' + if (v.includes(':')) return v.split(':')[0] + return v + }) + )].sort() + + return ( +
+ + {selectedCategory.replace(/_/g, ' ')} + + {categoryLabels.map((label, i) => ( +
+
+ + {label} + +
+ ))} +
+ ) + })()}
-
- - Log Frequency - - -
How often each feature fires across inputs
- {mosaicReady && ( - - )} -
-
- - Max Activation - - -
Strongest activation observed per feature
- {mosaicReady && ( - - )} -
+ {[ + { value: histMetric1, setter: setHistMetric1 }, + { value: histMetric2, setter: setHistMetric2 }, + ].map(({ value, setter }, i) => ( +
+
+ +
+ {mosaicReady && value && value !== 'none' && ( + + )} +
+ ))}
@@ -928,6 +982,7 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor > +
@@ -958,6 +1013,8 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor forceExpanded={true} onClick={handleCardClick} loadExamples={loadExamplesForFeature} + vocabLogits={vocabLogits} + darkMode={darkMode} /> )} {visibleFeatures.map(feature => ( @@ -969,18 +1026,20 @@ export default function App({ title = "SAE Feature Explorer", subtitle = "Explor forceExpanded={Number(clickedFeatureId) === Number(feature.feature_id)} onClick={handleCardClick} loadExamples={loadExamplesForFeature} + vocabLogits={vocabLogits} + darkMode={darkMode} /> ))} ) })()} {filteredFeatures.length > 100 && ( -
+
Showing first 100 results. Refine your selection to see more.
)} {filteredFeatures.length === 0 && clickedFeatureId == null && ( -
+
No features match your selection.
)} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/EmbeddingView.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/EmbeddingView.jsx index a60508c210..7bd81a5694 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/EmbeddingView.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/EmbeddingView.jsx @@ -9,10 +9,10 @@ const CATEGORY_COLORS = [ "#c49c94", "#f7b6d2", "#c7c7c7", "#dbdb8d", "#9edae5" ] -// Sequential color palette (viridis-like) +// Sequential color palette (NVIDIA brand) const SEQUENTIAL_COLORS = [ - "#440154", "#482878", "#3e4a89", "#31688e", "#26838f", - "#1f9e89", "#35b779", "#6ece58", "#b5de2b", "#fde725" + "#c359ef", "#9525C6", "#0046a4", "#0074DF", "#3f8500", + "#76B900", "#ef9100", "#F9C500", "#ff8181", "#EF2020" ] // Default color for uniform coloring (NVIDIA green) @@ -24,14 +24,15 @@ class FeatureTooltip { this.node = node this.inner = document.createElement("div") this.inner.style.cssText = ` - background: white; - border: 1px solid #ddd; + background: var(--bg-card); + border: 1px solid var(--border-input); 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.15); max-width: 300px; + color: var(--text); ` this.node.appendChild(this.inner) this.update(props) @@ -50,11 +51,11 @@ class FeatureTooltip { 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)}
` : ""} +
Feature #${featureId}
+
${label}
+ ${colorField ? `
Category: ${colorField}
` : ""} + ${logFreq !== undefined ? `
Log Frequency: ${logFreq.toFixed(3)}
` : ""} + ${maxAct !== undefined ? `
Max Activation: ${maxAct.toFixed(2)}
` : ""} ` } @@ -63,7 +64,7 @@ class FeatureTooltip { } } -export default function EmbeddingView({ brush, categoryColumn, categoryColumns, onFeatureClick, highlightedFeatureId, viewportState, onViewportChange, labels }) { +export default function EmbeddingView({ brush, categoryColumn, categoryColumns, onFeatureClick, highlightedFeatureId, viewportState, onViewportChange, labels, darkMode }) { const containerRef = useRef(null) const viewRef = useRef(null) const onFeatureClickRef = useRef(onFeatureClick) @@ -166,7 +167,7 @@ export default function EmbeddingView({ brush, categoryColumn, categoryColumns, labels: labels || null, config: { mode: "points", - colorScheme: "light", + colorScheme: darkMode ? "dark" : "light", autoLabelEnabled: false, }, theme: { @@ -211,7 +212,7 @@ export default function EmbeddingView({ brush, categoryColumn, categoryColumns, containerRef.current.innerHTML = '' } } - }, [brush, categoryColumn, categoryColumns]) + }, [brush, categoryColumn, categoryColumns, darkMode]) // Handle resize useEffect(() => { diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/FeatureCard.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/FeatureCard.jsx index 104cad1193..eff497e75d 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/FeatureCard.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/FeatureCard.jsx @@ -1,26 +1,26 @@ import React, { useState, useEffect, useRef, forwardRef } from 'react' -import ProteinSequence from './ProteinSequence' +import ProteinSequence, { computeAlignInfo } from './ProteinSequence' import MolstarThumbnail from './MolstarThumbnail' import ProteinDetailModal from './ProteinDetailModal' import { getAccession, uniprotUrl } from './utils' const styles = { card: { - background: '#fff', + background: 'var(--bg-card)', borderRadius: '8px', - border: '1px solid #e0e0e0', + border: '1px solid var(--border)', flexShrink: 0, }, cardHighlighted: { - background: '#fff', + background: 'var(--bg-card)', borderRadius: '8px', - border: '2px solid #222', + border: '2px solid var(--highlight-border)', flexShrink: 0, - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', + boxShadow: '0 2px 8px var(--highlight-shadow)', }, header: { padding: '12px 14px', - borderBottom: '1px solid #eee', + borderBottom: '1px solid var(--border-light)', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', @@ -33,7 +33,7 @@ const styles = { }, featureId: { fontSize: '11px', - color: '#888', + color: 'var(--text-tertiary)', fontFamily: 'monospace', marginBottom: '2px', }, @@ -42,12 +42,13 @@ const styles = { fontWeight: '500', wordBreak: 'break-word', lineHeight: '1.4', + color: 'var(--text)', }, stats: { display: 'flex', gap: '12px', fontSize: '11px', - color: '#666', + color: 'var(--text-secondary)', flexShrink: 0, }, stat: { @@ -56,7 +57,7 @@ const styles = { alignItems: 'flex-end', }, statLabel: { - color: '#999', + color: 'var(--text-muted)', fontSize: '9px', textTransform: 'uppercase', }, @@ -65,19 +66,19 @@ const styles = { fontWeight: '500', }, expandIcon: { - color: '#999', + color: 'var(--text-muted)', fontSize: '10px', marginLeft: '6px', }, expandedContent: { padding: '10px 14px', - background: '#fafafa', + background: 'var(--bg-card-expanded)', maxHeight: '900px', overflowY: 'auto', }, sectionHeader: { fontSize: '10px', - color: '#888', + color: 'var(--text-tertiary)', textTransform: 'uppercase', marginBottom: '8px', fontWeight: '500', @@ -85,13 +86,13 @@ const styles = { example: { marginBottom: '8px', padding: '8px 10px', - background: '#fff', + background: 'var(--bg-example)', borderRadius: '4px', - border: '1px solid #eee', + border: '1px solid var(--border-light)', }, exampleMeta: { fontSize: '10px', - color: '#999', + color: 'var(--text-muted)', marginBottom: '4px', fontFamily: 'monospace', display: 'flex', @@ -99,23 +100,23 @@ const styles = { alignItems: 'center', }, proteinId: { - color: '#2563eb', + color: 'var(--link)', fontWeight: '600', }, annotation: { - color: '#666', + color: 'var(--text-secondary)', fontStyle: 'italic', marginLeft: '8px', }, uniprotLink: { - color: '#2563eb', + color: 'var(--link)', textDecoration: 'none', fontSize: '11px', marginLeft: '4px', opacity: 0.6, }, noExamples: { - color: '#999', + color: 'var(--text-muted)', fontSize: '12px', fontStyle: 'italic', }, @@ -127,33 +128,66 @@ const styles = { }, structureHeader: { fontSize: '10px', - color: '#888', + color: 'var(--text-tertiary)', textTransform: 'uppercase', marginTop: '16px', marginBottom: '8px', fontWeight: '500', }, + alignBar: { + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '10px', + color: 'var(--text-tertiary)', + }, + alignLabel: { + textTransform: 'uppercase', + fontWeight: '500', + }, + alignBtn: { + padding: '2px 8px', + border: '1px solid var(--border-input)', + borderRadius: '3px', + background: 'var(--bg-input)', + cursor: 'pointer', + fontSize: '10px', + color: 'var(--text-secondary)', + }, + alignBtnActive: { + padding: '2px 8px', + border: '1px solid var(--accent)', + borderRadius: '3px', + background: 'var(--bg-card)', + cursor: 'pointer', + fontSize: '10px', + color: 'var(--text)', + fontWeight: '600', + }, densityBar: { width: '50px', height: '3px', - background: '#eee', + background: 'var(--density-bar-bg)', borderRadius: '2px', overflow: 'hidden', marginTop: '3px', }, densityFill: { height: '100%', - background: '#76b900', + background: 'var(--accent)', borderRadius: '2px', }, } -const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, forceExpanded, onClick, loadExamples }, ref) { +const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, forceExpanded, onClick, loadExamples, vocabLogits, darkMode }, ref) { const [expanded, setExpanded] = useState(false) const [detailProtein, setDetailProtein] = useState(null) const [examples, setExamples] = useState([]) const [loadingExamples, setLoadingExamples] = useState(false) const examplesCacheRef = useRef(null) + const scrollGroupRef = useRef([]) + const [alignMode, setAlignMode] = useState('start') + const [hoveredToken, setHoveredToken] = useState(null) // If forceExpanded changes to true, expand the card useEffect(() => { @@ -162,6 +196,11 @@ const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, fo } }, [forceExpanded]) + // Reset scroll group when card collapses or alignMode changes + useEffect(() => { + scrollGroupRef.current = [] + }, [expanded, alignMode]) + // Lazy-load examples from DuckDB when card is expanded useEffect(() => { if (!expanded || !loadExamples || examplesCacheRef.current) return @@ -182,6 +221,7 @@ const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, fo const freq = feature.activation_freq || 0 const maxAct = feature.max_activation || 0 + const bestF1 = feature.best_f1 || 0 const description = feature.description || `Feature ${feature.feature_id}` const handleClick = () => { @@ -196,7 +236,22 @@ const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, fo
-
Feature #{feature.feature_id}
+
+
Feature #{feature.feature_id}
+ {bestF1 > 0 && ( + = 0.5 ? 'rgba(118, 185, 0, 0.15)' : 'rgba(255, 165, 0, 0.15)', + color: bestF1 >= 0.5 ? '#76b900' : '#ef9100', + whiteSpace: 'nowrap', + }}> + F1: {bestF1.toFixed(2)} + + )} +
{description}
@@ -211,48 +266,144 @@ const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, fo Max {maxAct.toFixed(1)}
- {expanded ? '▼' : '▶'} + {expanded ? '\u25BC' : '\u25B6'}
{expanded && (
+ {/* Decoder Logits */} + {vocabLogits && vocabLogits[String(feature.feature_id)] && (() => { + const logits = vocabLogits[String(feature.feature_id)] + const tokenLogitMap = {} + for (const [tok, val] of (logits.top_positive || [])) tokenLogitMap[tok] = val + for (const [tok, val] of (logits.top_negative || [])) tokenLogitMap[tok] = val + // Show all 20 standard amino acids in alphabetical order + const AMINO_ACIDS = ['A','C','D','E','F','G','H','I','K','L','M','N','P','Q','R','S','T','V','W','Y'] + const allTokens = AMINO_ACIDS + const maxAbs = Math.max(...AMINO_ACIDS.map(aa => Math.abs(tokenLogitMap[aa] || 0)), 0.001) + return ( +
+
Decoder Logits (mean-centered)
+
+ {hoveredToken && (() => { + const val = tokenLogitMap[hoveredToken] || 0 + return ( +
+ {hoveredToken}: {val > 0 ? '+' : ''}{val.toFixed(3)} +
+ ) + })()} +
+ {allTokens.map(tok => { + const val = tokenLogitMap[tok] || 0 + const h = Math.max(1, (Math.abs(val) / maxAbs) * 28) + const isHovered = hoveredToken === tok + const barColor = val === 0 ? 'var(--text-muted)' : val > 0 ? '#76b900' : '#e57373' + return ( +
setHoveredToken(tok)} + onMouseLeave={() => setHoveredToken(null)} + > +
+
+ ) + })} +
+
+ {allTokens.map(tok => ( +
+ {tok} +
+ ))} +
+
+
+ Promoted + Suppressed + relative to average feature +
+
+ ) + })()} + {/* Protein sequence examples */} -
Top Activating Proteins
+
+
Top Activating Proteins
+
+ Align by: + {['start', 'first_activation', 'max_activation'].map(mode => ( + + ))} +
+
{loadingExamples ? ( -
+
Loading examples...
) : examples.length > 0 ? ( <> - {examples.slice(0, 6).map((ex, i) => ( -
-
- - {ex.protein_id} - e.stopPropagation()} - title="View on UniProt" - > - ↗ - - {ex.best_annotation && ( - {ex.best_annotation} - )} - - max: {ex.max_activation?.toFixed(3) || 'N/A'} + {(() => { + const visibleExamples = examples.slice(0, 6) + const { anchor: alignAnchor, totalLength } = computeAlignInfo(visibleExamples, alignMode) + return visibleExamples.map((ex, i) => ( +
+
+ + {ex.protein_id} + e.stopPropagation()} + title="View on UniProt" + > + ↗ + + {ex.best_annotation && ( + {ex.best_annotation} + )} + + max: {ex.max_activation?.toFixed(3) || 'N/A'} +
+
- -
- ))} + )) + })()} {/* 2x3 Mol* structure grid */}
3D Structures (AlphaFold)
@@ -266,6 +417,7 @@ const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, fo activations={ex.activations} maxActivation={ex.max_activation} onExpand={() => setDetailProtein(ex)} + darkMode={darkMode} /> ))}
@@ -280,6 +432,7 @@ const FeatureCard = forwardRef(function FeatureCard({ feature, isHighlighted, fo setDetailProtein(null)} + darkMode={darkMode} /> )}
diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/Histogram.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/Histogram.jsx index 2939dc9e11..6bd00d631e 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/Histogram.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/Histogram.jsx @@ -2,9 +2,24 @@ import React, { useEffect, useRef } from 'react' import * as vg from '@uwdata/vgplot' const FILL_COLOR = "#76b900" -const BACKGROUND_COLOR = "#e0e0e0" -export default function Histogram({ brush, column, label }) { +function injectAxisLine(plot, marginLeft, marginRight, marginBottom, height, axisColor) { + const svg = plot.tagName === 'svg' ? plot : plot.querySelector?.('svg') + if (!svg) return + 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, categoryColumns }) { const containerRef = useRef(null) useEffect(() => { @@ -13,44 +28,86 @@ export default function Histogram({ brush, column, label }) { // Clear previous content containerRef.current.innerHTML = '' + const computedBg = 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 = 80 - - const plot = vg.plot( - // Background histogram: full data (no filterBy) - vg.rectY( - vg.from("features"), - { x: vg.bin(column), y: vg.count(), fill: BACKGROUND_COLOR, 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(45), - vg.marginBottom(20), - vg.marginTop(5), - vg.marginRight(10) - ) + const height = 50 + const marginLeft = 45 + const marginBottom = 20 + const marginRight = 10 + const marginTop = 5 + + // Check if this column is categorical + const colInfo = categoryColumns?.find(c => c.name === column) + const isCategorical = colInfo && (colInfo.type === 'string' || colInfo.type === 'integer') + + let plot + if (isCategorical) { + const catHeight = 80 + const catMarginBottom = 50 + plot = vg.plot( + vg.barY( + vg.from("features"), + { x: column, y: vg.count(), fill: computedBg, inset: 1 } + ), + vg.barY( + vg.from("features", { filterBy: brush }), + { x: column, y: vg.count(), fill: FILL_COLOR, inset: 1 } + ), + vg.toggleX({ as: brush }), + vg.xLabel(null), + vg.yLabel(null), + vg.xTickRotate(-45), + vg.xTickSize(3), + vg.style({ fontSize: '9px' }), + vg.width(width), + vg.height(catHeight), + vg.marginLeft(marginLeft), + vg.marginBottom(catMarginBottom), + vg.marginTop(marginTop), + vg.marginRight(marginRight) + ) + } else { + // Numeric histogram: binned rectY + plot = vg.plot( + vg.rectY( + vg.from("features"), + { x: vg.bin(column), y: vg.count(), fill: computedBg, inset: 1 } + ), + 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) + const timer = setTimeout(() => { + injectAxisLine(plot, marginLeft, marginRight, marginBottom, height, axisColor) + }, 50) + return () => { + clearTimeout(timer) if (containerRef.current) { containerRef.current.innerHTML = '' } } - }, [brush, column, label]) + }, [brush, column, label, categoryColumns]) return (
) } diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/MolstarThumbnail.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/MolstarThumbnail.jsx index 7ff85dd76f..739314627e 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/MolstarThumbnail.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/MolstarThumbnail.jsx @@ -3,9 +3,9 @@ import { getAccession } from './utils' const styles = { container: { - background: '#fafafa', + background: 'var(--bg-card-expanded)', borderRadius: '6px', - border: '1px solid #eee', + border: '1px solid var(--border-light)', overflow: 'hidden', display: 'flex', flexDirection: 'column', @@ -21,8 +21,8 @@ const styles = { top: '6px', right: '6px', zIndex: 20, - background: 'rgba(255,255,255,0.85)', - border: '1px solid #ddd', + background: 'var(--bg-card)', + border: '1px solid var(--border-input)', borderRadius: '4px', width: '24px', height: '24px', @@ -31,7 +31,7 @@ const styles = { justifyContent: 'center', cursor: 'pointer', fontSize: '12px', - color: '#555', + color: 'var(--text-secondary)', opacity: 0, transition: 'opacity 0.15s', pointerEvents: 'auto', @@ -40,18 +40,18 @@ const styles = { padding: '4px 8px', fontSize: '10px', fontFamily: 'monospace', - color: '#555', - borderTop: '1px solid #eee', + color: 'var(--text-secondary)', + borderTop: '1px solid var(--border-light)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', }, proteinId: { fontWeight: '600', - color: '#2563eb', + color: 'var(--link)', }, activation: { - color: '#999', + color: 'var(--text-muted)', }, loading: { position: 'absolute', @@ -59,11 +59,11 @@ const styles = { display: 'flex', alignItems: 'center', justifyContent: 'center', - background: '#f9f9f9', + background: 'var(--bg-card-expanded)', zIndex: 10, pointerEvents: 'none', fontSize: '10px', - color: '#aaa', + color: 'var(--text-muted)', }, error: { position: 'absolute', @@ -71,14 +71,14 @@ const styles = { display: 'flex', alignItems: 'center', justifyContent: 'center', - background: '#f9f9f9', + background: 'var(--bg-card-expanded)', zIndex: 10, fontSize: '10px', color: '#e57373', }, } -export default function MolstarThumbnail({ proteinId, alphafoldId, sequence, activations, maxActivation, onExpand }) { +export default function MolstarThumbnail({ proteinId, alphafoldId, sequence, activations, maxActivation, onExpand, darkMode }) { const wrapperRef = useRef(null) const molContainerRef = useRef(null) const pluginRef = useRef(null) @@ -147,6 +147,14 @@ export default function MolstarThumbnail({ proteinId, alphafoldId, sequence, act plugin.initViewer(canvas, molDiv) } catch { /* fallback for different Mol* versions */ } + // Set canvas background based on dark mode + try { + const bgColor = darkMode ? 0x000000 : 0xffffff + plugin.canvas3d?.setProps({ + renderer: { backgroundColor: bgColor }, + }) + } catch { /* older Mol* versions may not support this */ } + pluginRef.current = plugin // Custom activation color theme @@ -173,7 +181,7 @@ export default function MolstarThumbnail({ proteinId, alphafoldId, sequence, act } } } catch { /* fallback */ } - return Color.fromRgb(200, 200, 200) + return darkMode ? Color.fromRgb(80, 80, 80) : Color.fromRgb(200, 200, 200) } const colorThemeProvider = { diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinDetailModal.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinDetailModal.jsx index 7f6588a73f..7e6327b2d9 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinDetailModal.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinDetailModal.jsx @@ -7,15 +7,15 @@ const styles = { backdrop: { position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.5)', + background: 'rgba(0,0,0,0.45)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', }, modal: { - background: '#fff', - borderRadius: '12px', + background: 'var(--bg-card)', + borderRadius: '10px', width: '90vw', maxWidth: '1200px', height: '80vh', @@ -30,8 +30,8 @@ const styles = { top: '12px', right: '12px', zIndex: 10, - background: 'rgba(255,255,255,0.9)', - border: '1px solid #ddd', + background: 'var(--bg-card)', + border: '1px solid var(--border-input)', borderRadius: '50%', width: '32px', height: '32px', @@ -40,13 +40,13 @@ const styles = { justifyContent: 'center', cursor: 'pointer', fontSize: '16px', - color: '#555', + color: 'var(--text-secondary)', }, leftPanel: { flex: '0 0 60%', position: 'relative', - background: '#f5f5f5', - borderRight: '1px solid #eee', + background: 'var(--bg)', + borderRight: '1px solid var(--border-light)', }, viewer: { width: '100%', @@ -59,7 +59,7 @@ const styles = { display: 'flex', alignItems: 'center', justifyContent: 'center', - color: '#aaa', + color: 'var(--text-muted)', fontSize: '13px', }, viewerError: { @@ -73,7 +73,7 @@ const styles = { }, rightPanel: { flex: 1, - padding: '24px', + padding: '28px 32px', overflowY: 'auto', display: 'flex', flexDirection: 'column', @@ -89,7 +89,7 @@ const styles = { fontSize: '18px', fontWeight: '700', fontFamily: 'monospace', - color: '#222', + color: 'var(--text-heading)', }, uniprotBtn: { display: 'inline-flex', @@ -97,9 +97,9 @@ const styles = { gap: '4px', padding: '4px 10px', fontSize: '12px', - color: '#2563eb', - background: '#eff6ff', - border: '1px solid #bfdbfe', + color: 'var(--link)', + background: 'var(--bg-card-expanded)', + border: '1px solid var(--border)', borderRadius: '6px', textDecoration: 'none', fontWeight: '500', @@ -110,13 +110,13 @@ const styles = { }, statBox: { padding: '10px 14px', - background: '#f9fafb', + background: 'var(--bg-card-expanded)', borderRadius: '8px', - border: '1px solid #eee', + border: '1px solid var(--border-light)', }, statLabel: { fontSize: '10px', - color: '#888', + color: 'var(--text-tertiary)', textTransform: 'uppercase', marginBottom: '2px', }, @@ -124,17 +124,17 @@ const styles = { fontSize: '14px', fontWeight: '600', fontFamily: 'monospace', - color: '#333', + color: 'var(--text)', }, sectionLabel: { fontSize: '11px', - color: '#888', + color: 'var(--text-tertiary)', textTransform: 'uppercase', fontWeight: '500', }, sequenceBox: { - background: '#fafafa', - border: '1px solid #eee', + background: 'var(--bg-card-expanded)', + border: '1px solid var(--border-light)', borderRadius: '8px', padding: '12px', maxHeight: '300px', @@ -142,7 +142,7 @@ const styles = { }, } -export default function ProteinDetailModal({ protein, onClose }) { +export default function ProteinDetailModal({ protein, onClose, darkMode }) { const wrapperRef = useRef(null) const molContainerRef = useRef(null) const pluginRef = useRef(null) @@ -207,6 +207,14 @@ export default function ProteinDetailModal({ protein, onClose }) { try { plugin.initViewer(canvas, molDiv) } catch {} + // Set canvas background based on dark mode + try { + const bgColor = darkMode ? 0x000000 : 0xffffff + plugin.canvas3d?.setProps({ + renderer: { backgroundColor: bgColor }, + }) + } catch { /* older Mol* versions may not support this */ } + pluginRef.current = plugin // Custom activation color theme @@ -234,7 +242,7 @@ export default function ProteinDetailModal({ protein, onClose }) { } } } catch {} - return Color.fromRgb(200, 200, 200) + return darkMode ? Color.fromRgb(80, 80, 80) : Color.fromRgb(200, 200, 200) } const colorThemeProvider = { diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinSequence.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinSequence.jsx index 4a098928d0..240906a597 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinSequence.jsx +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/protein_dashboard/src/ProteinSequence.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useEffect, useRef } from 'react' // White-to-NVIDIA-green (#76b900) gradient based on activation value function activationColorHex(value, maxValue) { @@ -15,25 +15,53 @@ const styles = { container: { fontFamily: 'Monaco, Menlo, "Courier New", monospace', fontSize: '11px', - lineHeight: '1.4', + lineHeight: '1.2', overflowX: 'auto', - whiteSpace: 'nowrap', position: 'relative', }, residueRow: { display: 'inline-flex', + whiteSpace: 'nowrap', }, - residue: { - display: 'inline-block', - textAlign: 'center', - minWidth: '12px', + residueBlock: { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'center', cursor: 'default', - borderRadius: '1px', + borderRadius: '2px', + padding: '1px 2px', + marginRight: '1px', + minWidth: '14px', + }, + padBlock: { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'center', + borderRadius: '2px', + padding: '1px 2px', + marginRight: '1px', + minWidth: '14px', + background: 'var(--density-bar-bg)', + }, + padText: { + fontSize: '10px', + color: 'var(--text-muted)', + }, + residueText: { + fontSize: '10px', + letterSpacing: '0.5px', + color: 'var(--text)', + }, + idxText: { + fontSize: '7px', + color: 'var(--text-tertiary)', + marginTop: '0px', + lineHeight: '1', }, tooltip: { position: 'fixed', - background: '#333', - color: '#fff', + background: 'var(--bg-card)', + color: 'var(--text)', padding: '4px 8px', borderRadius: '4px', fontSize: '10px', @@ -41,19 +69,80 @@ const styles = { zIndex: 1000, pointerEvents: 'none', whiteSpace: 'nowrap', + border: '1px solid var(--border)', + boxShadow: '0 2px 8px rgba(0,0,0,0.2)', }, } -export default function ProteinSequence({ sequence, activations, maxActivation }) { +export default function ProteinSequence({ + sequence, activations, maxActivation, + alignMode, alignAnchor, totalLength, + scrollGroupRef, +}) { const [tooltip, setTooltip] = useState(null) + const scrollRef = useRef(null) + const anchorRef = useRef(null) - if (!sequence || sequence.length === 0) { - return No sequence + const residues = sequence ? sequence.split('') : [] + const acts = activations ? activations.slice(0, residues.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 } }) } - // Trim activations to sequence length (ESM2 may add an extra token) - const acts = activations ? activations.slice(0, sequence.length) : [] - const maxAct = maxActivation || Math.max(...acts, 0.001) + // 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 - residues.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 + + // Register this element in the group + 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, residue, idx, act) => { setTooltip({ @@ -74,23 +163,49 @@ export default function ProteinSequence({ sequence, activations, maxActivation } } return ( -
+
- {sequence.split('').map((residue, idx) => { + {/* Left padding */} + {Array.from({ length: leftPad }, (_, i) => ( + + · +   + + ))} + + {/* Actual residues */} + {residues.map((residue, idx) => { const act = acts[idx] || 0 const bg = activationColorHex(act, maxAct) + const hasActivation = act > 0 + const activeTextColor = hasActivation ? '#000' : undefined + const isAnchor = isAligned && idx === localAnchor return ( handleMouseEnter(e, residue, idx, act)} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} > - {residue} + {residue} + {idx + 1} ) })} + + {/* Right padding */} + {Array.from({ length: rightPad }, (_, i) => ( + + · +   + + ))}
{tooltip && ( @@ -100,3 +215,47 @@ export default function ProteinSequence({ sequence, activations, maxActivation }
) } + +/** + * Compute alignment info for a set of examples. + */ +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/esm2/run_configs/model/650m.yaml b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/run_configs/model/650m.yaml index d3928a4604..65220f8b0b 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/run_configs/model/650m.yaml +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/run_configs/model/650m.yaml @@ -1,6 +1,6 @@ # @package _global_ # ESM2-650M configuration model_name: nvidia/esm2_t33_650M_UR50D -run_name: 650m_5k -num_proteins: 5000 +run_name: 650m_50k +num_proteins: 50000 batch_size: 16 diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/15b.sh b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/15b.sh similarity index 95% rename from bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/15b.sh rename to bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/15b.sh index 103a4facc6..c61c25ec5d 100755 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/15b.sh +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/15b.sh @@ -56,6 +56,9 @@ python scripts/eval.py \ --batch-size 1 \ --dtype bf16 \ --num-proteins 1000 \ + --umap-n-neighbors 50 \ + --umap-min-dist 0.0 \ + --hdbscan-min-cluster-size 20 \ --output-dir ./outputs/15b_50k/eval echo "" diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/3b.sh b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/3b.sh similarity index 88% rename from bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/3b.sh rename to bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/3b.sh index 65eb576cd3..0956d37959 100755 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/3b.sh +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/3b.sh @@ -55,7 +55,14 @@ python scripts/eval.py \ --layer 24 \ --batch-size 4 \ --dtype bf16 \ - --num-proteins 1000 \ + --num-proteins 2000 \ + --f1-max-proteins 50000 \ + --f1-min-positives 5 \ + --f1-threshold 0.2 \ + --normalization-n-proteins 3000 \ + --umap-n-neighbors 50 \ + --umap-min-dist 0.0 \ + --hdbscan-min-cluster-size 20 \ --output-dir ./outputs/3b_50k/eval echo "" diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/650m.sh b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/650m.sh similarity index 66% rename from bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/650m.sh rename to bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/650m.sh index d004de12f2..be88f4e2c5 100755 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/650m.sh +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/650m.sh @@ -5,24 +5,24 @@ echo "============================================================" echo "STEP 1: Extract activations from ESM2-650M" echo "============================================================" -torchrun --nproc_per_node=4 scripts/extract.py \ +torchrun --nproc_per_node=2 scripts/extract.py \ --source uniref50 \ - --num-proteins 5000 \ + --num-proteins 50000 \ --data-dir ./data \ --layer 24 \ --model-name nvidia/esm2_t33_650M_UR50D \ --batch-size 16 \ --max-length 1024 \ --filter-length \ - --output .cache/activations/650m_5k_layer24 + --output .cache/activations/650m_50k_layer24 echo "" echo "============================================================" echo "STEP 2: Train SAE on cached activations" echo "============================================================" -torchrun --nproc_per_node=4 scripts/train.py \ - --cache-dir .cache/activations/650m_5k_layer24 \ +torchrun --nproc_per_node=2 scripts/train.py \ + --cache-dir .cache/activations/650m_50k_layer24 \ --model-name nvidia/esm2_t33_650M_UR50D \ --layer 24 \ --model-type topk \ @@ -36,11 +36,11 @@ torchrun --nproc_per_node=4 scripts/train.py \ --lr 3e-4 \ --log-interval 50 \ --no-wandb \ - --dp-size 4 \ + --dp-size 2 \ --seed 42 \ - --num-proteins 5000 \ - --output-dir "$(pwd)/outputs/650m_5k" \ - --checkpoint-dir "$(pwd)/outputs/650m_5k/checkpoints" \ + --num-proteins 50000 \ + --output-dir "$(pwd)/outputs/650m_50k" \ + --checkpoint-dir "$(pwd)/outputs/650m_50k/checkpoints" \ --checkpoint-steps 999999 echo "" @@ -49,14 +49,21 @@ echo "STEP 3: Evaluate SAE + build dashboard" echo "============================================================" python scripts/eval.py \ - --checkpoint ./outputs/650m_5k/checkpoints/checkpoint_final.pt \ + --checkpoint ./outputs/650m_50k/checkpoints/checkpoint_final.pt \ --top-k 32 \ --model-name nvidia/esm2_t33_650M_UR50D \ --layer 24 \ --batch-size 16 \ --dtype bf16 \ - --num-proteins 1000 \ - --output-dir ./outputs/650m_5k/eval + --num-proteins 2000 \ + --f1-max-proteins 50000 \ + --f1-min-positives 5 \ + --f1-threshold 0.2 \ + --normalization-n-proteins 3000 \ + --umap-n-neighbors 50 \ + --umap-min-dist 0.0 \ + --hdbscan-min-cluster-size 20 \ + --output-dir ./outputs/650m_50k/eval echo "" echo "============================================================" diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/eval.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/eval.py index cdfab7b75e..3992f2ccfb 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/eval.py +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/eval.py @@ -86,6 +86,20 @@ def parse_args(): # Loss recovered p.add_argument("--loss-recovered-n-sequences", type=int, default=100) + # Annotation download + p.add_argument( + "--annotation-score", + type=int, + default=None, + help="UniProt annotation score filter (1-5, None=no filter). Default None for max coverage.", + ) + + # Dashboard / UMAP + p.add_argument("--umap-n-neighbors", type=int, default=50, help="UMAP n_neighbors parameter") + p.add_argument("--umap-min-dist", type=float, default=0.0, help="UMAP min_dist parameter") + p.add_argument("--hdbscan-min-cluster-size", type=int, default=20, help="HDBSCAN min_cluster_size parameter") + p.add_argument("--n-examples", type=int, default=6, help="Top proteins per feature for dashboard") + # Skip flags p.add_argument("--skip-f1", action="store_true", help="Skip F1 evaluation") p.add_argument("--skip-loss-recovered", action="store_true", help="Skip loss recovered evaluation") @@ -96,6 +110,94 @@ def parse_args(): return p.parse_args() +# ── Vocabulary logit analysis ───────────────────────────────────────── + + +def compute_vocab_logits(sae, model_name, model_dtype, device="cuda"): + """Project SAE decoder through the ESM2 LM head to get per-feature token logits. + + Returns dict mapping feature_id -> {top_positive, top_negative} with + mean-centered logit values (baseline subtracted). + """ + from transformers import AutoModelForMaskedLM + + print("Loading LM head model for vocab logits...") + lm_kwargs = {"trust_remote_code": True} + if model_dtype != torch.float32: + lm_kwargs["dtype"] = model_dtype + lm_model = AutoModelForMaskedLM.from_pretrained(model_name, **lm_kwargs).to(device).eval() + + tokenizer = None + try: + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) + except Exception: + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D") + + # Get the LM head + lm_head = lm_model.lm_head if hasattr(lm_model, "lm_head") else lm_model.cls + + # Decoder weights: (input_dim, n_features) + W_dec = sae.decoder.weight.to(device).to(model_dtype) + + with torch.no_grad(): + logits = lm_head(W_dec.T).float() # (n_features, output_vocab_size) + + # Subtract mean logit vector (baseline) so values reflect + # feature-specific effects rather than the LM head's global bias. + mean_logits = logits.mean(dim=0, keepdim=True) + logits = logits - mean_logits + + # Build vocab list matching the LM head output dimension + # (ESM2 pads output beyond tokenizer.vocab_size) + output_vocab_size = logits.shape[1] + vocab = [] + for i in range(output_vocab_size): + if i < len(tokenizer): + vocab.append(tokenizer.decode([i]).strip()) + else: + vocab.append(f"") + + # Special tokens to exclude from top lists + special_tokens = {"", "", "", "", "", "", ""} + + valid_mask = torch.ones(output_vocab_size, dtype=torch.bool) + for i, tok in enumerate(vocab): + if tok.lower() in special_tokens or tok.startswith("<"): + valid_mask[i] = False + + n_features = logits.shape[0] + results = {} + for f in range(n_features): + feat_logits = logits[f].cpu() + + masked_logits = feat_logits.clone() + masked_logits[~valid_mask] = float("-inf") + + top_pos_idx = masked_logits.topk(10).indices.tolist() + + masked_logits_neg = feat_logits.clone() + masked_logits_neg[~valid_mask] = float("inf") + top_neg_idx = masked_logits_neg.topk(10, largest=False).indices.tolist() + + top_positive = [(vocab[i], round(feat_logits[i].item(), 3)) for i in top_pos_idx] + top_negative = [(vocab[i], round(feat_logits[i].item(), 3)) for i in top_neg_idx] + + results[f] = { + "top_positive": top_positive, + "top_negative": top_negative, + } + + del lm_model + torch.cuda.empty_cache() + + print(f" Computed mean-centered vocab logits for {n_features} features") + return results + + def load_sae_from_checkpoint(checkpoint_path: str, top_k: int) -> TopKSAE: """Load SAE from a Trainer checkpoint, handling DDP module. prefix.""" ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) @@ -290,6 +392,20 @@ def build_f1_labels(val_results, n_features, f1_threshold): n_labeled = sum(1 for l in labels if not l.startswith("Feature ")) print(f" {n_labeled}/{n_features} features labeled (F1 >= {f1_threshold})") + + # Show all matched annotation categories + from collections import Counter + + category_counts = Counter() + for i, stats in feature_stats.items(): + concept = stats["best_annotation"] + category = concept.split(":")[0] if ":" in concept else concept + category_counts[category] += 1 + if category_counts: + print(f" Annotation categories matched ({len(category_counts)} types):") + for cat, count in category_counts.most_common(): + print(f" {cat}: {count} features") + return labels, feature_stats @@ -360,7 +476,7 @@ def get_esm2(): output_path=annotations_path, max_length=args.max_seq_len, reviewed_only=True, - annotation_score=5, + annotation_score=args.annotation_score, max_results=args.f1_max_proteins, ) @@ -593,26 +709,32 @@ def get_esm2(): print(f" {activations_flat.shape[0]:,} residues, dim={activations_flat.shape[1]}") # Step 1: Feature statistics - print("\n[1/4] Computing feature statistics...") + print("\n[1/5] Computing feature statistics...") t0 = time.time() stats, _ = compute_feature_stats(sae, activations_flat, device=device) print(f" Done in {time.time() - t0:.1f}s") # Step 2: UMAP from decoder weights - print("[2/4] Computing UMAP from decoder weights...") + print("[2/5] Computing UMAP from decoder weights...") t0 = time.time() - geometry = compute_feature_umap(sae, random_state=42) + geometry = compute_feature_umap( + sae, + n_neighbors=args.umap_n_neighbors, + min_dist=args.umap_min_dist, + random_state=args.seed, + hdbscan_min_cluster_size=args.hdbscan_min_cluster_size, + ) print(f" Done in {time.time() - t0:.1f}s") # Step 3: Save feature atlas with F1 labels - print("[3/4] Saving feature atlas...") + print("[3/5] Saving feature atlas...") t0 = time.time() atlas_path = dashboard_dir / "features_atlas.parquet" save_feature_atlas(stats, geometry, atlas_path, labels=f1_labels) print(f" Saved to {atlas_path} in {time.time() - t0:.1f}s") # Step 4: Export protein examples with F1 annotations - print("[4/4] Exporting protein examples...") + print("[4/5] Exporting protein examples...") t0 = time.time() export_protein_features_parquet( sae=sae, @@ -621,16 +743,26 @@ def get_esm2(): protein_ids=protein_ids, output_dir=dashboard_dir, masks=masks, - n_examples=6, + n_examples=args.n_examples, device=device, feature_stats=feature_stats_for_dashboard, ) print(f" Done in {time.time() - t0:.1f}s") + # Step 5: Compute vocab logits (decoder -> LM head projection) + print("[5/5] Computing vocab logits...") + t0 = time.time() + vocab_logits = compute_vocab_logits(sae, args.model_name, model_dtype, device=device) + logits_path = dashboard_dir / "vocab_logits.json" + with open(logits_path, "w") as f: + json.dump(vocab_logits, f) + print(f" Saved to {logits_path} in {time.time() - t0:.1f}s") + print(f"\nDashboard data saved to: {dashboard_dir}") print(f" Atlas: {atlas_path}") print(f" Features: {dashboard_dir}/feature_metadata.parquet") print(f" Examples: {dashboard_dir}/feature_examples.parquet") + print(f" Logits: {logits_path}") print("\nTo view locally:") print(f" scp -r cluster:{dashboard_dir} ./dashboard") print( diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/launch_dashboard.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/launch_dashboard.py index 6051cfa10f..3f2dfd1a67 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/launch_dashboard.py +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/launch_dashboard.py @@ -13,9 +13,126 @@ # See the License for the specific language governing permissions and # limitations under the License. -from esm2_sae import launch_protein_dashboard +"""Launch the ESM2 SAE dashboard locally. +Usage: + # After scp'ing dashboard data from server: + scp -r server:/path/to/outputs/650m_5k/eval/dashboard ./dash -proc = launch_protein_dashboard("./dash/features_atlas.parquet", features_dir="dash") -input("Dashboard running. Press enter to stop. \n") -proc.terminate() + python scripts/launch_dashboard.py --data-dir ./dash +""" + +import argparse +import shutil +import subprocess +import time +import webbrowser +from pathlib import Path + + +def _get_live_feature_ids(data_dir: Path): + """Return set of feature_ids with activation_freq > 0.""" + import pyarrow.parquet as pq + + meta_path = data_dir / "feature_metadata.parquet" + if not meta_path.exists(): + return None + table = pq.read_table(meta_path) + df = table.to_pandas() + live = df.loc[df["activation_freq"] > 0, "feature_id"] + return set(live.tolist()) + + +def _filter_and_copy_parquet(src: Path, dst: Path, live_ids: set): + """Filter a parquet file to only include live feature_ids.""" + import pyarrow as pa + import pyarrow.parquet as pq + + table = pq.read_table(src) + df = table.to_pandas() + if "feature_id" not in df.columns: + shutil.copy2(src, dst) + return len(df), len(df) + n_before = len(df) + df = df[df["feature_id"].isin(live_ids)] + pq.write_table(pa.Table.from_pandas(df, preserve_index=False), dst) + return n_before, len(df) + + +def main(): # noqa: D103 + p = argparse.ArgumentParser(description="Launch ESM2 SAE dashboard") + p.add_argument( + "--data-dir", + type=str, + required=True, + help="Directory containing features_atlas.parquet, feature_metadata.parquet, feature_examples.parquet", + ) + p.add_argument("--port", type=int, default=5176) + p.add_argument("--filter-dead", action="store_true", help="Filter out dead latents (activation_freq == 0)") + args = p.parse_args() + + data_dir = Path(args.data_dir).resolve() + dashboard_dir = Path(__file__).resolve().parent.parent / "protein_dashboard" + + if not (dashboard_dir / "package.json").exists(): + raise FileNotFoundError(f"Dashboard not found at {dashboard_dir}") + + # Determine live features (opt-in) + filter_dead = args.filter_dead + live_ids = None + if filter_dead: + live_ids = _get_live_feature_ids(data_dir) + if live_ids is not None: + print(f"Filtering to {len(live_ids)} live features (activation_freq > 0)") + else: + print("No feature_metadata.parquet found, skipping dead latent filtering") + filter_dead = False + + # Copy parquet files into dashboard's public/ dir + public_dir = dashboard_dir / "public" + public_dir.mkdir(exist_ok=True) + + parquet_files = ["features_atlas.parquet", "feature_metadata.parquet", "feature_examples.parquet"] + json_files = ["vocab_logits.json", "cluster_labels.json"] + + for fname in parquet_files: + src = data_dir / fname + if not src.exists(): + print(f"WARNING: {fname} not found in {data_dir}") + continue + if filter_dead and live_ids is not None: + n_before, n_after = _filter_and_copy_parquet(src, public_dir / fname, live_ids) + print(f"Copied {fname} ({n_after}/{n_before} rows, {n_before - n_after} dead filtered)") + else: + shutil.copy2(src, public_dir / fname) + print(f"Copied {fname}") + + for fname in json_files: + src = data_dir / fname + if src.exists(): + shutil.copy2(src, public_dir / fname) + print(f"Copied {fname}") + + # Install deps if needed + if not (dashboard_dir / "node_modules").exists(): + print("Installing dashboard dependencies...") + subprocess.run(["npm", "install"], cwd=dashboard_dir, check=True) + + # Launch dev server + print(f"\nStarting dashboard on http://localhost:{args.port}") + proc = subprocess.Popen( + ["npx", "vite", "--port", str(args.port)], + cwd=dashboard_dir, + ) + + time.sleep(2) + webbrowser.open(f"http://localhost:{args.port}") + + try: + input("Dashboard running. Press Enter to stop.\n") + finally: + proc.terminate() + + +if __name__ == "__main__": + main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/old_extract.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/old_extract.py deleted file mode 100644 index 9822a8bb2b..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/old_extract.py +++ /dev/null @@ -1,298 +0,0 @@ -# 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. - -"""Step 1: Extract activations from ESM2 and save to disk. - -Extracts layer activations from an ESM2 model for a set of protein sequences -and writes them as sharded Parquet files via ActivationStore. - -Supports multi-GPU extraction via torchrun for Nx speedup: - torchrun --nproc_per_node=4 scripts/step1_extract.py ... - -Single-GPU usage: - python scripts/step1_extract.py \ - data.source=uniref50 data.data_dir=./data data.num_proteins=10000 \ - activations.model_name=facebook/esm2_t33_650M_UR50D activations.layer=25 \ - activations.cache_dir=.cache/activations/esm2_650m_layer25 -""" - -import json -import os -import shutil -import time -from pathlib import Path - -import hydra -import torch -from esm2_sae.data import download_swissprot, download_uniref50, read_fasta -from esm2_sae.models import ESM2Model -from omegaconf import DictConfig, OmegaConf -from sae.utils import get_device, set_seed - - -def resolve_data_path(cfg: DictConfig, data_dir: Path, rank: int) -> Path: - """Resolve protein FASTA path, downloading if needed. Only rank 0 downloads.""" - source = str(cfg.data.get("source", "swissprot")).lower() - num_proteins = cfg.data.get("num_proteins", None) - - if source == "swissprot": - fasta_path = data_dir / "uniprot_sprot.fasta.gz" - if not fasta_path.exists(): - if rank == 0: - print(f"Downloading SwissProt to {fasta_path}") - download_swissprot(data_dir) - else: - _wait_for_file(fasta_path) - return fasta_path - - if source == "uniref50": - download_max = cfg.data.get("download_max_proteins", num_proteins) - if download_max is None: - fasta_path = data_dir / "uniref50.fasta.gz" - else: - fasta_path = data_dir / f"uniref50_first_{download_max}.fasta" - - if not fasta_path.exists(): - if rank == 0: - print(f"Downloading UniRef50 to {fasta_path}") - download_uniref50(data_dir, max_proteins=download_max) - else: - _wait_for_file(fasta_path) - return fasta_path - - raise ValueError(f"Unknown data.source='{source}'. Use 'swissprot' or 'uniref50'.") - - -def _wait_for_file(path: Path, timeout_sec: int = 7200, poll_sec: float = 2.0) -> None: - """Wait for a file to appear (non-rank-0 waits for rank 0 to download).""" - start = time.time() - while not path.exists(): - if (time.time() - start) > timeout_sec: - raise TimeoutError(f"Timed out waiting for: {path}") - time.sleep(poll_sec) - - -def _merge_rank_stores(cache_path: Path, world_size: int, metadata: dict) -> None: - """Merge per-rank temp stores into a single store by moving shard files.""" - cache_path.mkdir(parents=True, exist_ok=True) - shard_idx = 0 - total_samples = 0 - hidden_dim = None - shard_size = None - - for r in range(world_size): - tmp_dir = cache_path / f".tmp_rank_{r}" - with open(tmp_dir / "metadata.json") as f: - tmp_meta = json.load(f) - - hidden_dim = tmp_meta["hidden_dim"] - shard_size = tmp_meta["shard_size"] - - for i in range(tmp_meta["n_shards"]): - src = tmp_dir / f"shard_{i:05d}.parquet" - dst = cache_path / f"shard_{shard_idx:05d}.parquet" - shutil.move(str(src), str(dst)) - shard_idx += 1 - - total_samples += tmp_meta["n_samples"] - shutil.rmtree(tmp_dir) - - metadata.update( - n_samples=total_samples, - n_shards=shard_idx, - hidden_dim=hidden_dim, - shard_size=shard_size, - ) - with open(cache_path / "metadata.json", "w") as f: - json.dump(metadata, f, indent=2) - - print(f"Merged {world_size} rank stores: {total_samples:,} tokens, {shard_idx} shards") - - -@hydra.main(version_base=None, config_path="../configs", config_name="config") -def main(cfg: DictConfig) -> None: - """Extract ESM2 layer activations using Hydra configuration.""" - print(OmegaConf.to_yaml(cfg)) - - set_seed(cfg.seed) - - # Distributed setup - rank = int(os.environ.get("RANK", 0)) - world_size = int(os.environ.get("WORLD_SIZE", 1)) - - if world_size > 1: - from datetime import timedelta - - import torch.distributed as dist - - if not dist.is_initialized(): - dist.init_process_group("nccl", timeout=timedelta(hours=48)) - torch.cuda.set_device(rank) - device = f"cuda:{rank}" - else: - device = get_device() - - print(f"[Rank {rank}/{world_size}] Device: {device}") - - # Resolve cache path - cache_dir = cfg.activations.get("cache_dir", None) - if not cache_dir: - raise ValueError("activations.cache_dir is required for extraction.") - cache_path = Path(hydra.utils.get_original_cwd()) / cache_dir - - # Check if cache already exists - if (cache_path / "metadata.json").exists(): - if rank == 0: - print(f"Cache already exists at {cache_path}. Skipping extraction.") - with open(cache_path / "metadata.json") as f: - meta = json.load(f) - print(f" {meta['n_samples']:,} tokens, {meta['n_shards']} shards, dim={meta['hidden_dim']}") - if world_size > 1: - import torch.distributed as dist - - dist.barrier() - dist.destroy_process_group() - return - - # Clean up stale temp dirs from a previous failed multi-GPU run - if rank == 0 and cache_path.exists(): - for tmp in cache_path.glob(".tmp_rank_*"): - shutil.rmtree(tmp) - - # Load sequences - data_dir = Path(hydra.utils.get_original_cwd()) / cfg.data.data_dir - fasta_path = resolve_data_path(cfg, data_dir, rank) - - # Wait for download to finish on all ranks - if world_size > 1: - import torch.distributed as dist - - dist.barrier() - - num_proteins = cfg.data.get("num_proteins", None) - records = read_fasta( - fasta_path, - max_sequences=num_proteins, - max_length=cfg.data.max_seq_length, - ) - sequences = [rec.sequence for rec in records] - total_sequences = len(sequences) - - if rank == 0: - print(f"Loaded {total_sequences} sequences from {fasta_path}") - - # Split sequences across ranks - if world_size > 1: - chunk = total_sequences // world_size - my_start = rank * chunk - my_end = total_sequences if rank == world_size - 1 else (rank + 1) * chunk - my_sequences = sequences[my_start:my_end] - print(f"[Rank {rank}] Extracting sequences {my_start}-{my_end} ({len(my_sequences)} proteins)") - else: - my_sequences = sequences - - # Create ESM2 model - esm2 = ESM2Model( - model_name=cfg.activations.model_name, - layer=cfg.activations.layer, - device=device, - ) - - # Extract activations and write to store - from sae.activation_store import ActivationStore - from tqdm import tqdm - - if world_size > 1: - store_path = cache_path / f".tmp_rank_{rank}" - else: - store_path = cache_path - - store = ActivationStore(store_path) - batch_size = cfg.activations.batch_size - remove_special = cfg.activations.remove_special_tokens - padding = cfg.activations.get("tokenizer_padding", "longest") - - n_batches = (len(my_sequences) + batch_size - 1) // batch_size - show_progress = rank == 0 - - iterator = range(0, len(my_sequences), batch_size) - if show_progress: - iterator = tqdm(iterator, total=n_batches, desc="Extracting activations") - - t0 = time.time() - for i in iterator: - batch_seqs = my_sequences[i : i + batch_size] - batch_emb, batch_masks = esm2.generate_activations( - sequences=batch_seqs, - batch_size=len(batch_seqs), - remove_special_tokens=remove_special, - show_progress=False, - padding=padding, - ) - batch_flat = batch_emb[batch_masks.bool()] - store.append(batch_flat) - - store.finalize( - metadata={ - "model_name": cfg.activations.model_name, - "layer": cfg.activations.layer, - "n_sequences": len(my_sequences), - } - ) - - elapsed = time.time() - t0 - print( - f"[Rank {rank}] Extracted {store.metadata['n_samples']:,} tokens " - f"from {len(my_sequences)} proteins in {elapsed:.1f}s" - ) - - # Free GPU memory - del esm2 - torch.cuda.empty_cache() if torch.cuda.is_available() else None - - # Multi-GPU: merge rank stores - if world_size > 1: - import torch.distributed as dist - - dist.barrier() - - if rank == 0: - _merge_rank_stores( - cache_path, - world_size, - metadata={ - "model_name": cfg.activations.model_name, - "layer": cfg.activations.layer, - "n_sequences": total_sequences, - }, - ) - - dist.barrier() - dist.destroy_process_group() - - # Print final summary - if rank == 0: - with open(cache_path / "metadata.json") as f: - meta = json.load(f) - print("\nExtraction complete:") - print(f" Cache: {cache_path}") - print(f" Sequences: {meta.get('n_sequences', '?')}") - print(f" Tokens: {meta['n_samples']:,}") - print(f" Hidden dim: {meta['hidden_dim']}") - print(f" Shards: {meta['n_shards']}") - - -if __name__ == "__main__": - main() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/train.py b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/train.py index 6d271fc9ce..6d1aa6e37e 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/train.py +++ b/bionemo-recipes/interpretability/sparse_autoencoders/recipes/esm2/scripts/train.py @@ -86,6 +86,7 @@ def parse_args(): train_group.add_argument("--max-grad-norm", type=float, default=None) train_group.add_argument("--lr-scale-with-latents", action=argparse.BooleanOptionalAction, default=False) train_group.add_argument("--lr-reference-hidden-dim", type=int, default=2048) + train_group.add_argument("--grad-accumulation-steps", type=int, default=1, help="Gradient accumulation steps") # W&B wb_group = p.add_argument_group("Weights & Biases") @@ -155,6 +156,7 @@ def build_training_config(args, device: str) -> TrainingConfig: checkpoint_steps=args.checkpoint_steps, lr_scale_with_latents=args.lr_scale_with_latents, lr_reference_hidden_dim=args.lr_reference_hidden_dim, + grad_accumulation_steps=args.grad_accumulation_steps, ) @@ -266,6 +268,24 @@ def main(): world_size=world_size, max_shards=max_shards, ) + # Compute min batch count across all ranks to keep DDP in sync + # Read parquet footers for all ranks' shards (a few KB each, no data loading) + if world_size > 1: + import pyarrow.parquet as pq_meta + + dataset = dataloader.dataset + per_rank = len(dataset.shard_indices) + min_batches = None + for r in range(world_size): + total_rows = sum( + pq_meta.read_metadata(store.path / f"shard_{idx:05d}.parquet").num_rows + for idx in range(r * per_rank, (r + 1) * per_rank) + ) + batches = total_rows // args.batch_size + if min_batches is None or batches < min_batches: + min_batches = batches + dataset.max_batches = min_batches + print(f"[rank {rank}] capped to {min_batches} batches/epoch for DDP sync") trainer.fit( dataloader, max_grad_norm=args.max_grad_norm, diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/pyproject.toml b/bionemo-recipes/interpretability/sparse_autoencoders/sae/pyproject.toml index a9a9813469..ec08e494cb 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/pyproject.toml +++ b/bionemo-recipes/interpretability/sparse_autoencoders/sae/pyproject.toml @@ -23,10 +23,6 @@ dependencies = [ ] [project.optional-dependencies] -steering = [ - "fastapi>=0.100", - "uvicorn>=0.20", -] tracking = [ "wandb>=0.15", ] diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/__init__.py b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/__init__.py index aa38288728..51bd5ff777 100644 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/__init__.py +++ b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/__init__.py @@ -82,7 +82,6 @@ ) from .perf_logger import PerfLogger from .process_group_manager import ProcessGroupManager -from .steering import Intervention, InterventionMode, SteeredModel from .training import ParallelConfig, Trainer, TrainingConfig, WandbConfig from .utils import get_device, set_seed @@ -106,8 +105,6 @@ "FeatureLogits", "FeatureSampler", "FeatureStats", - "Intervention", - "InterventionMode", "LLMClient", "LLMResponse", "LossRecoveredResult", @@ -122,7 +119,6 @@ "ReLUSAE", "SparseAutoencoder", "SparsityMetrics", - "SteeredModel", "TokenActivationCollector", "TokenExample", "TopExample", diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering.py b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering.py deleted file mode 100644 index 62d753d5dc..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering.py +++ /dev/null @@ -1,230 +0,0 @@ -# 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. - -"""Feature steering via SAE interventions at inference time. - -Intercepts a model's residual stream at a target layer, modifies specific -SAE feature activations (amplify, suppress, or clamp), and re-injects the -modified activations. Supports three intervention modes: - -- additive_code: encode → codes[f] += weight → decode → replace -- multiplicative_code: encode → codes[f] *= weight → decode → replace -- direct: activations += weight * W_dec[f] (no encode/decode) -""" - -from __future__ import annotations - -from contextlib import contextmanager -from dataclasses import dataclass -from enum import Enum -from typing import List, Optional - -import torch -import torch.nn as nn - -from .architectures.base import SparseAutoencoder - - -class InterventionMode(str, Enum): - """Modes for SAE feature interventions.""" - - ADDITIVE_CODE = "additive_code" - MULTIPLICATIVE_CODE = "multiplicative_code" - DIRECT = "direct" - - -@dataclass -class Intervention: - """A single feature intervention.""" - - feature_id: int - weight: float - mode: InterventionMode = InterventionMode.ADDITIVE_CODE - - -class SteeredModel: - """Wraps a language model + SAE to apply feature steering at inference time. - - Registers a forward hook on the target transformer layer that intercepts - the residual stream, applies interventions, and re-injects modified - activations. - - Args: - model: A HuggingFace-style transformer model (has .transformer.h or - .model.layers attribute). - sae: A trained SparseAutoencoder instance. - layer: The layer index where the SAE was trained. - device: Device for computations. If None, uses model's device. - - Example: - >>> steered = SteeredModel(gpt2_model, sae, layer=6) - >>> steered.set_interventions([ - ... Intervention(feature_id=42, weight=3.0, mode=InterventionMode.ADDITIVE_CODE), - ... ]) - >>> output = model.generate(input_ids) - """ - - def __init__( - self, - model: nn.Module, - sae: SparseAutoencoder, - layer: int, - device: Optional[torch.device] = None, - ): - """Initialize the steered model with a language model, SAE, and target layer.""" - self.model = model - self.sae = sae - self.layer = layer - self.device = device or next(model.parameters()).device - self._interventions: List[Intervention] = [] - self._hook_handle = None - - # Move SAE to same device as model, in eval mode - self.sae = self.sae.to(self.device).eval() - - # Resolve the target layer module - self._target_module = self._resolve_layer(model, layer) - - @staticmethod - def _resolve_layer(model: nn.Module, layer: int) -> nn.Module: - """Find the transformer block at the given layer index.""" - # GPT-2 style: model.transformer.h[layer] - if hasattr(model, "transformer") and hasattr(model.transformer, "h"): - return model.transformer.h[layer] - # LLaMA / Mistral style: model.model.layers[layer] - if hasattr(model, "model") and hasattr(model.model, "layers"): - return model.model.layers[layer] - # Generic: try common attribute names - for attr in ("layers", "blocks", "encoder.layer", "decoder.layer"): - parts = attr.split(".") - obj = model - try: - for p in parts: - obj = getattr(obj, p) - return obj[layer] - except (AttributeError, IndexError, TypeError): - continue - raise ValueError( - f"Cannot find transformer layer {layer}. " - "Supported layouts: model.transformer.h[], model.model.layers[], " - "model.layers[], model.blocks[]" - ) - - def set_interventions(self, interventions: List[Intervention]) -> None: - """Set active interventions and register/update the forward hook.""" - self._interventions = list(interventions) - self._unregister_hook() - if self._interventions: - self._register_hook() - - def clear_interventions(self) -> None: - """Remove all interventions and unregister the hook.""" - self._interventions = [] - self._unregister_hook() - - @contextmanager - def intervene(self, interventions: List[Intervention]): - """Context manager for temporary steering.""" - prev = self._interventions[:] - self.set_interventions(interventions) - try: - yield self - finally: - self.set_interventions(prev) - - def _register_hook(self) -> None: - self._hook_handle = self._target_module.register_forward_hook(self._hook_fn) - - def _unregister_hook(self) -> None: - if self._hook_handle is not None: - self._hook_handle.remove() - self._hook_handle = None - - @torch.no_grad() - def _hook_fn(self, module, input, output): - """Forward hook that applies interventions to the residual stream.""" - # HuggingFace transformer blocks return (hidden_states, ...) tuples - if isinstance(output, tuple): - hidden_states = output[0] - rest = output[1:] - else: - hidden_states = output - rest = None - - # Separate interventions by type - direct_interventions = [iv for iv in self._interventions if iv.mode == InterventionMode.DIRECT] - code_interventions = [iv for iv in self._interventions if iv.mode != InterventionMode.DIRECT] - - # Apply direct interventions: activations += weight * W_dec[feature_id] - if direct_interventions: - # Get decoder weight matrix: shape [input_dim, hidden_dim] - W_dec = self.sae.decoder.weight.data - for iv in direct_interventions: - # W_dec[:, feature_id] is the decoder direction for this feature - direction = W_dec[:, iv.feature_id] # [input_dim] - hidden_states = hidden_states + iv.weight * direction - - # Apply code-space interventions: encode → modify → decode → replace - if code_interventions: - original_shape = hidden_states.shape # [batch, seq_len, hidden_dim] - flat = hidden_states.reshape(-1, original_shape[-1]) # [B*T, D] - - # Encode - codes = self.sae.encode(flat) # [B*T, n_features] - - # Apply modifications - for iv in code_interventions: - if iv.mode == InterventionMode.ADDITIVE_CODE: - codes[:, iv.feature_id] = codes[:, iv.feature_id] + iv.weight - elif iv.mode == InterventionMode.MULTIPLICATIVE_CODE: - codes[:, iv.feature_id] = codes[:, iv.feature_id] * iv.weight - - # Decode back — use base decode (without normalization info) - reconstructed = self.sae.decode(codes) # [B*T, D] - - # Compute the SAE residual on the unmodified input to preserve - # information not captured by the SAE - flat_original = hidden_states.reshape(-1, original_shape[-1]) - codes_original = self.sae.encode(flat_original) - recon_original = self.sae.decode(codes_original) - residual = flat_original - recon_original # what SAE can't represent - - # Final output: steered reconstruction + original residual - hidden_states = (reconstructed + residual).reshape(original_shape) - - if rest is not None: - return (hidden_states,) + rest - return hidden_states - - def generate( - self, - input_ids: torch.Tensor, - attention_mask: Optional[torch.Tensor] = None, - **generate_kwargs, - ) -> torch.Tensor: - """Run model.generate() with current interventions active. - - This is a convenience wrapper — the hook fires automatically on each - forward pass during autoregressive generation. - """ - return self.model.generate( - input_ids=input_ids, - attention_mask=attention_mask, - **generate_kwargs, - ) - - def __del__(self): - """Clean up by unregistering the forward hook.""" - self._unregister_hook() diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_server.py b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_server.py deleted file mode 100644 index 9eb7c86b58..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_server.py +++ /dev/null @@ -1,250 +0,0 @@ -# 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. - -"""FastAPI server for SAE feature steering with SSE streaming. - -Provides three endpoints: -- GET /features — feature metadata from parquet (for the picker UI) -- POST /chat — steered text generation with SSE streaming -- GET /health — model info - -Launch via launch_steering_server() or use create_app() for custom setups. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, Dict, List, Optional - -import torch - -from .steering import Intervention, InterventionMode, SteeredModel - - -def load_feature_metadata(parquet_dir: str | Path) -> List[Dict[str, Any]]: - """Read feature_metadata.parquet into a list of dicts.""" - import pyarrow.parquet as pq - - parquet_dir = Path(parquet_dir) - meta_path = parquet_dir / "feature_metadata.parquet" - if not meta_path.exists(): - return [] - table = pq.read_table(meta_path) - rows = table.to_pydict() - n = len(rows.get("feature_id", [])) - features = [] - for i in range(n): - feat = {} - for col in rows: - feat[col] = rows[col][i] - features.append(feat) - return features - - -def create_app( - steered_model: SteeredModel, - tokenizer: Any, - parquet_dir: str | Path, - max_new_tokens: int = 256, -) -> Any: - """Create a FastAPI application for steering. - - Args: - steered_model: SteeredModel wrapping the LM + SAE. - tokenizer: HuggingFace tokenizer for the model. - parquet_dir: Directory containing feature_metadata.parquet. - max_new_tokens: Default max tokens for generation. - - Returns: - FastAPI app instance. - """ - from fastapi import FastAPI, Request - from fastapi.middleware.cors import CORSMiddleware - from fastapi.responses import StreamingResponse - - app = FastAPI(title="SAE Steering Server") - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - ) - - # Load feature metadata at startup - feature_metadata = load_feature_metadata(parquet_dir) - - @app.get("/health") - def health(): - return { - "status": "ok", - "model": getattr(steered_model.model, "name_or_path", str(type(steered_model.model).__name__)), - "sae_hidden_dim": steered_model.sae.hidden_dim, - "sae_input_dim": steered_model.sae.input_dim, - "layer": steered_model.layer, - "n_features": len(feature_metadata), - } - - @app.get("/features") - def get_features(search: Optional[str] = None, limit: int = 200): - results = feature_metadata - if search: - q = search.lower() - results = [ - f for f in results if q in str(f.get("description", "")).lower() or q in str(f.get("feature_id", "")) - ] - return results[:limit] - - @app.post("/chat") - async def chat(request: Request): - body = await request.json() - messages = body.get("messages", []) - raw_interventions = body.get("interventions", []) - compare = body.get("compare", False) - max_tokens = body.get("max_tokens", max_new_tokens) - - # Build prompt from messages - prompt = _build_prompt(messages) - - # Parse interventions - interventions = [ - Intervention( - feature_id=iv["feature_id"], - weight=iv["weight"], - mode=InterventionMode(iv.get("mode", "additive_code")), - ) - for iv in raw_interventions - ] - - async def event_stream(): - # Generate steered response - if interventions: - steered_model.set_interventions(interventions) - else: - steered_model.clear_interventions() - - steered_tokens = _generate_tokens(steered_model, tokenizer, prompt, max_tokens) - for token_text in steered_tokens: - event = json.dumps({"source": "steered", "token": token_text}) - yield f"event: token\ndata: {event}\n\n" - - # Generate baseline response if compare mode - if compare: - steered_model.clear_interventions() - baseline_tokens = _generate_tokens(steered_model, tokenizer, prompt, max_tokens) - for token_text in baseline_tokens: - event = json.dumps({"source": "baseline", "token": token_text}) - yield f"event: token\ndata: {event}\n\n" - - # Clean up: restore interventions if they were set - if interventions and not compare: - pass # Already set - elif interventions: - steered_model.set_interventions(interventions) - - yield "event: done\ndata: {}\n\n" - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - return app - - -def _build_prompt(messages: List[Dict[str, str]]) -> str: - """Convert chat messages into a single prompt string for the LM.""" - parts = [] - for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") - if role == "user": - parts.append(f"User: {content}") - elif role == "assistant": - parts.append(f"Assistant: {content}") - elif role == "system": - parts.append(content) - parts.append("Assistant:") - return "\n".join(parts) - - -def _generate_tokens( - steered_model: SteeredModel, - tokenizer: Any, - prompt: str, - max_new_tokens: int, -) -> List[str]: - """Generate tokens one at a time, returning a list of token strings. - - Uses model.generate() with the steering hook active, then splits - the output into individual tokens for streaming. - """ - device = steered_model.device - inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512) - input_ids = inputs["input_ids"].to(device) - attention_mask = inputs["attention_mask"].to(device) - prompt_len = input_ids.shape[1] - - with torch.no_grad(): - output_ids = steered_model.generate( - input_ids=input_ids, - attention_mask=attention_mask, - max_new_tokens=max_new_tokens, - do_sample=True, - temperature=0.7, - top_p=0.9, - pad_token_id=tokenizer.eos_token_id, - ) - - # Extract only the new tokens - new_token_ids = output_ids[0, prompt_len:] - - # Decode each token individually for streaming - tokens = [] - for tid in new_token_ids: - if tid.item() == tokenizer.eos_token_id: - break - tokens.append(tokenizer.decode([tid.item()])) - - return tokens - - -def launch_steering_server( - steered_model: SteeredModel, - tokenizer: Any, - parquet_dir: str | Path, - port: int = 8000, - host: str = "127.0.0.1", -) -> None: - """Start the steering API server. - - Args: - steered_model: SteeredModel wrapping the LM + SAE. - tokenizer: HuggingFace tokenizer. - parquet_dir: Directory with feature_metadata.parquet. - port: Server port (default: 8000). - host: Server host (default: 127.0.0.1). - """ - import uvicorn - - app = create_app(steered_model, tokenizer, parquet_dir) - print(f"Starting steering server at http://{host}:{port}") - print(f" Features loaded from: {parquet_dir}") - uvicorn.run(app, host=host, port=port) diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/index.html b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/index.html deleted file mode 100644 index 0ad5c2fac2..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - SAE Steering - - - -
- - - diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/package-lock.json b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/package-lock.json deleted file mode 100644 index 972424c25b..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/package-lock.json +++ /dev/null @@ -1,1674 +0,0 @@ -{ - "name": "sae-steering-ui", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sae-steering-ui", - "version": "0.1.0", - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.2.0", - "vite": "^5.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "dev": true, - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/App.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/App.jsx deleted file mode 100644 index a427b732ba..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/App.jsx +++ /dev/null @@ -1,296 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react' -import FeaturePicker from './FeaturePicker' -import InterventionPanel from './InterventionPanel' -import ChatPanel from './ChatPanel' - -const API_BASE = '/api' - -const styles = { - container: { - display: 'flex', - height: '100vh', - overflow: 'hidden', - }, - sidebar: { - width: '360px', - flexShrink: 0, - background: '#fff', - borderRight: '1px solid #e0e0e0', - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - }, - sidebarHeader: { - padding: '16px 16px 12px', - borderBottom: '1px solid #e0e0e0', - flexShrink: 0, - }, - title: { - fontSize: '18px', - fontWeight: '700', - marginBottom: '2px', - }, - subtitle: { - fontSize: '12px', - color: '#888', - }, - sidebarContent: { - flex: 1, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - minHeight: 0, - }, - main: { - flex: 1, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - minWidth: 0, - }, - mainHeader: { - padding: '12px 20px', - borderBottom: '1px solid #e0e0e0', - background: '#fff', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexShrink: 0, - }, - compareToggle: { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '13px', - color: '#555', - }, - toggleSwitch: { - position: 'relative', - width: '36px', - height: '20px', - borderRadius: '10px', - cursor: 'pointer', - transition: 'background 0.2s', - }, - toggleKnob: { - position: 'absolute', - top: '2px', - width: '16px', - height: '16px', - borderRadius: '50%', - background: '#fff', - transition: 'left 0.2s', - boxShadow: '0 1px 3px rgba(0,0,0,0.2)', - }, - modelInfo: { - fontSize: '12px', - color: '#999', - fontFamily: 'monospace', - }, -} - -export default function App() { - const [features, setFeatures] = useState([]) - const [interventions, setInterventions] = useState([]) - const [messages, setMessages] = useState([]) - const [compare, setCompare] = useState(true) - const [loading, setLoading] = useState(false) - const [modelInfo, setModelInfo] = useState(null) - - // Fetch features and health on mount - useEffect(() => { - fetch(`${API_BASE}/features?limit=500`) - .then(r => r.json()) - .then(setFeatures) - .catch(err => console.error('Failed to load features:', err)) - - fetch(`${API_BASE}/health`) - .then(r => r.json()) - .then(setModelInfo) - .catch(err => console.error('Failed to load health:', err)) - }, []) - - const handleAddIntervention = useCallback((feature) => { - setInterventions(prev => { - if (prev.some(iv => iv.feature_id === feature.feature_id)) return prev - return [...prev, { - feature_id: feature.feature_id, - description: feature.description || `Feature ${feature.feature_id}`, - weight: 3.0, - mode: 'additive_code', - }] - }) - }, []) - - const handleUpdateIntervention = useCallback((featureId, updates) => { - setInterventions(prev => - prev.map(iv => iv.feature_id === featureId ? { ...iv, ...updates } : iv) - ) - }, []) - - const handleRemoveIntervention = useCallback((featureId) => { - setInterventions(prev => prev.filter(iv => iv.feature_id !== featureId)) - }, []) - - const handleClearInterventions = useCallback(() => { - setInterventions([]) - }, []) - - const handleSendMessage = useCallback(async (content) => { - const newMessages = [...messages, { role: 'user', content }] - setMessages(newMessages) - setLoading(true) - - // Prepare steered (and optionally baseline) placeholders - const steeredMsg = { role: 'assistant', content: '', source: 'steered' } - const baselineMsg = compare ? { role: 'assistant', content: '', source: 'baseline' } : null - - setMessages(prev => [ - ...prev, - steeredMsg, - ...(baselineMsg ? [baselineMsg] : []), - ]) - - try { - const response = await fetch(`${API_BASE}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: newMessages, - interventions: interventions.map(iv => ({ - feature_id: iv.feature_id, - weight: iv.weight, - mode: iv.mode, - })), - compare, - max_tokens: 256, - }), - }) - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - let steeredText = '' - let baselineText = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)) - if (data.token !== undefined) { - if (data.source === 'steered') { - steeredText += data.token - } else if (data.source === 'baseline') { - baselineText += data.token - } - - // Update messages in place - setMessages(prev => { - const updated = [...prev] - // Find and update the steered message - const steeredIdx = updated.findIndex( - (m, i) => i >= newMessages.length && m.source === 'steered' - ) - if (steeredIdx >= 0) { - updated[steeredIdx] = { ...updated[steeredIdx], content: steeredText } - } - // Find and update the baseline message - if (compare) { - const baselineIdx = updated.findIndex( - (m, i) => i >= newMessages.length && m.source === 'baseline' - ) - if (baselineIdx >= 0) { - updated[baselineIdx] = { ...updated[baselineIdx], content: baselineText } - } - } - return updated - }) - } - } catch (e) { - // Skip malformed data lines - } - } - } - } - } catch (err) { - console.error('Chat error:', err) - setMessages(prev => [ - ...prev.slice(0, -1 - (compare ? 1 : 0)), - { role: 'assistant', content: `Error: ${err.message}`, source: 'error' }, - ]) - } - - setLoading(false) - }, [messages, interventions, compare]) - - const handleClearChat = useCallback(() => { - setMessages([]) - }, []) - - return ( -
-
-
-
SAE Steering
-
- Select features and adjust weights to steer model behavior -
-
-
- - iv.feature_id))} - onSelect={handleAddIntervention} - /> -
-
- -
-
-
-
setCompare(c => !c)} - style={{ - ...styles.toggleSwitch, - background: compare ? '#76b900' : '#ccc', - }} - > -
-
- Compare with baseline -
- {modelInfo && ( - - {modelInfo.model} | layer {modelInfo.layer} | {modelInfo.n_features} features - - )} -
- -
-
- ) -} diff --git a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/ChatPanel.jsx b/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/ChatPanel.jsx deleted file mode 100644 index c7ab22feb2..0000000000 --- a/bionemo-recipes/interpretability/sparse_autoencoders/sae/src/sae/steering_ui/src/ChatPanel.jsx +++ /dev/null @@ -1,326 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react' - -const styles = { - container: { - flex: 1, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - background: '#f5f5f5', - }, - messages: { - flex: 1, - overflowY: 'auto', - padding: '20px', - display: 'flex', - flexDirection: 'column', - gap: '16px', - }, - empty: { - flex: 1, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - color: '#bbb', - gap: '8px', - }, - emptyTitle: { - fontSize: '18px', - fontWeight: '600', - color: '#999', - }, - emptyHint: { - fontSize: '13px', - maxWidth: '360px', - textAlign: 'center', - lineHeight: '1.5', - }, - userMessage: { - alignSelf: 'flex-end', - maxWidth: '70%', - padding: '10px 14px', - background: '#1a1a1a', - color: '#fff', - borderRadius: '16px 16px 4px 16px', - fontSize: '14px', - lineHeight: '1.5', - whiteSpace: 'pre-wrap', - }, - assistantRow: { - alignSelf: 'flex-start', - maxWidth: '90%', - width: '100%', - }, - compareRow: { - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: '12px', - }, - singleRow: { - display: 'flex', - }, - responseCard: { - padding: '12px 14px', - background: '#fff', - borderRadius: '12px', - border: '1px solid #e0e0e0', - fontSize: '14px', - lineHeight: '1.6', - whiteSpace: 'pre-wrap', - }, - responseLabel: { - fontSize: '10px', - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: '0.5px', - marginBottom: '6px', - display: 'flex', - alignItems: 'center', - gap: '6px', - }, - steeredLabel: { - color: '#76b900', - }, - baselineLabel: { - color: '#888', - }, - dot: { - width: '6px', - height: '6px', - borderRadius: '50%', - display: 'inline-block', - }, - errorCard: { - padding: '12px 14px', - background: '#fff5f5', - borderRadius: '12px', - border: '1px solid #ffcdd2', - color: '#c62828', - fontSize: '13px', - }, - inputArea: { - padding: '12px 20px 16px', - background: '#fff', - borderTop: '1px solid #e0e0e0', - display: 'flex', - gap: '10px', - alignItems: 'flex-end', - flexShrink: 0, - }, - textarea: { - flex: 1, - padding: '10px 14px', - fontSize: '14px', - border: '1px solid #ddd', - borderRadius: '12px', - outline: 'none', - resize: 'none', - fontFamily: 'inherit', - lineHeight: '1.4', - maxHeight: '120px', - minHeight: '42px', - }, - sendBtn: { - padding: '10px 20px', - fontSize: '13px', - fontWeight: '600', - border: 'none', - borderRadius: '10px', - cursor: 'pointer', - flexShrink: 0, - transition: 'background 0.15s', - }, - clearBtn: { - padding: '10px 14px', - fontSize: '12px', - background: 'none', - border: '1px solid #ddd', - borderRadius: '10px', - cursor: 'pointer', - color: '#888', - flexShrink: 0, - }, - cursor: { - display: 'inline-block', - width: '2px', - height: '14px', - background: '#76b900', - marginLeft: '1px', - verticalAlign: 'text-bottom', - animation: 'blink 1s step-end infinite', - }, -} - -// Add blink keyframe -if (typeof document !== 'undefined' && !document.getElementById('steering-blink-style')) { - const style = document.createElement('style') - style.id = 'steering-blink-style' - style.textContent = '@keyframes blink { 50% { opacity: 0; } }' - document.head.appendChild(style) -} - -export default function ChatPanel({ messages, loading, compare, interventionCount, onSend, onClear }) { - const [input, setInput] = useState('') - const messagesEndRef = useRef(null) - const textareaRef = useRef(null) - - // Auto-scroll on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - // Auto-resize textarea - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto' - textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px' - } - }, [input]) - - const handleSubmit = () => { - if (!input.trim() || loading) return - onSend(input.trim()) - setInput('') - } - - const handleKeyDown = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSubmit() - } - } - - // Group messages for compare mode rendering - const renderMessages = () => { - const elements = [] - let i = 0 - while (i < messages.length) { - const msg = messages[i] - - if (msg.role === 'user') { - elements.push( -
{msg.content}
- ) - i++ - } else if (msg.source === 'error') { - elements.push( -
-
{msg.content}
-
- ) - i++ - } else if (msg.source === 'steered') { - const steered = msg - const baseline = (i + 1 < messages.length && messages[i + 1].source === 'baseline') - ? messages[i + 1] - : null - const isStreaming = loading && i >= messages.length - (baseline ? 2 : 1) - - if (baseline) { - // Compare mode: side by side - elements.push( -
-
-
-
- - Steered -
-
- {steered.content || '\u00A0'} - {isStreaming && !steered.content && } -
-
-
-
- - Baseline -
-
- {baseline.content || '\u00A0'} - {isStreaming && steered.content && !baseline.content && } -
-
-
-
- ) - i += 2 - } else { - // Single mode - elements.push( -
-
-
- {steered.content || '\u00A0'} - {isStreaming && } -
-
-
- ) - i++ - } - } else { - // Fallback for any other message type - elements.push( -
-
{msg.content}
-
- ) - i++ - } - } - return elements - } - - return ( -
-
- {messages.length === 0 ? ( -
-
SAE Feature Steering
-
- {interventionCount > 0 - ? `${interventionCount} intervention${interventionCount > 1 ? 's' : ''} active. Send a message to see how steering affects the model's output.` - : 'Add feature interventions from the sidebar, then start chatting to see their effect on generation.'} -
-
- ) : ( - renderMessages() - )} -
-
- -
-