sida commited on
Commit
34d520a
·
1 Parent(s): 1ee0cf3

Deploy ICA explorer app

Browse files
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__
3
+ *.pyc
4
+ .pytest_cache
5
+ .venv
6
+ artifacts/fetched/.cache
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ ICA_EXPLORER_MODEL_NAME=gpt2 \
6
+ ICA_EXPLORER_ENABLED_MODELS=gpt2 \
7
+ ICA_EXPLORER_DEVICE=cpu \
8
+ ICA_EXPLORER_DTYPE=float32
9
+
10
+ WORKDIR /app
11
+
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ COPY . .
16
+
17
+ EXPOSE 7860
18
+
19
+ CMD ["python", "-m", "server.app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -3,8 +3,8 @@ title: ICAExplorer
3
  emoji: 📈
4
  colorFrom: red
5
  colorTo: gray
6
- sdk: static
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
3
  emoji: 📈
4
  colorFrom: red
5
  colorTo: gray
6
+ sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ ICA Lens Explorer Space.
configs/comparisons/gemma2_2b.toml ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [model]
2
+ short_name = "gemma2_2b"
3
+ hidden_size = 2304
4
+ num_hidden_layers = 26
5
+
6
+ [ica]
7
+ artifact_set = "v3_frozen_ica"
8
+ artifact_root = "artifacts/fetched/models"
9
+ preprocess = "with_normalization"
10
+
11
+ [sae]
12
+ repo_id = "google/gemma-scope-2b-pt-res"
13
+ checkpoint_format = "npz"
14
+ hook_name_template = "model.layers.{layer}"
15
+ layers = "all_transformer_layers"
16
+ width = 16384
17
+ direction_source = "decoder"
18
+ decoder_key = "W_dec"
19
+
20
+ # Gemma Scope stores concrete average_l0 folders rather than stable canonical files.
21
+ # These width_16k residual SAEs match the v3 comparison configuration.
22
+ [sae.layer_checkpoints]
23
+ "0" = "layer_0/width_16k/average_l0_25/params.npz"
24
+ "1" = "layer_1/width_16k/average_l0_20/params.npz"
25
+ "2" = "layer_2/width_16k/average_l0_24/params.npz"
26
+ "3" = "layer_3/width_16k/average_l0_28/params.npz"
27
+ "4" = "layer_4/width_16k/average_l0_31/params.npz"
28
+ "5" = "layer_5/width_16k/average_l0_34/params.npz"
29
+ "6" = "layer_6/width_16k/average_l0_36/params.npz"
30
+ "7" = "layer_7/width_16k/average_l0_36/params.npz"
31
+ "8" = "layer_8/width_16k/average_l0_37/params.npz"
32
+ "9" = "layer_9/width_16k/average_l0_37/params.npz"
33
+ "10" = "layer_10/width_16k/average_l0_39/params.npz"
34
+ "11" = "layer_11/width_16k/average_l0_41/params.npz"
35
+ "12" = "layer_12/width_16k/average_l0_41/params.npz"
36
+ "13" = "layer_13/width_16k/average_l0_43/params.npz"
37
+ "14" = "layer_14/width_16k/average_l0_43/params.npz"
38
+ "15" = "layer_15/width_16k/average_l0_41/params.npz"
39
+ "16" = "layer_16/width_16k/average_l0_42/params.npz"
40
+ "17" = "layer_17/width_16k/average_l0_42/params.npz"
41
+ "18" = "layer_18/width_16k/average_l0_40/params.npz"
42
+ "19" = "layer_19/width_16k/average_l0_40/params.npz"
43
+ "20" = "layer_20/width_16k/average_l0_38/params.npz"
44
+ "21" = "layer_21/width_16k/average_l0_38/params.npz"
45
+ "22" = "layer_22/width_16k/average_l0_38/params.npz"
46
+ "23" = "layer_23/width_16k/average_l0_38/params.npz"
47
+ "24" = "layer_24/width_16k/average_l0_38/params.npz"
48
+ "25" = "layer_25/width_16k/average_l0_28/params.npz"
49
+
50
+ [direction_overlap]
51
+ top_k = 1
52
+ sae_chunk_size = 4096
53
+ nearest_metric = "maximum_absolute_cosine"
54
+
55
+ [saebench_tpp]
56
+ enabled = true
57
+ saebench_model_name = "gemma-2-2b"
58
+ hook_name_template = "blocks.{layer}.hook_resid_post"
59
+ llm_dtype = "bfloat16"
60
+ llm_batch_size = 32
61
+ sae_batch_size = 125
62
+ random_seed = 42
63
+ lower_vram_usage = false
64
+ row_normalize = false
65
+ signed_identity = false
66
+ n_values = [2, 5, 10, 20, 50, 100, 500]
67
+ dataset_names = [
68
+ "LabHC/bias_in_bios_class_set1",
69
+ "canrager/amazon_reviews_mcauley_1and5",
70
+ ]
71
+
72
+ [saebench_tpp.sae_baseline]
73
+ source = "sae_lens_registry"
74
+ release_pattern = "gemma-scope-2b-pt-res"
75
+ id_from_sae_layer_checkpoints = true
76
+ allow_multiple = false
configs/comparisons/gpt2.toml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [model]
2
+ short_name = "gpt2"
3
+ hidden_size = 768
4
+ num_hidden_layers = 12
5
+
6
+ [ica]
7
+ artifact_set = "v3_frozen_ica"
8
+ artifact_root = "artifacts/fetched/models"
9
+ preprocess = "with_normalization"
10
+
11
+ [sae]
12
+ repo_id = "jbloom/GPT2-Small-OAI-v5-32k-resid-post-SAEs"
13
+ checkpoint_template = "v5_32k_layer_{layer}.pt/sae_weights.safetensors"
14
+ checkpoint_format = "safetensors"
15
+ hook_name_template = "blocks.{layer}.hook_resid_post"
16
+ layers = "all_transformer_layers"
17
+ width = 32768
18
+ direction_source = "decoder"
19
+
20
+ [direction_overlap]
21
+ top_k = 1
22
+ sae_chunk_size = 4096
23
+ nearest_metric = "maximum_absolute_cosine"
24
+
25
+ [saebench_tpp]
26
+ enabled = true
27
+ saebench_model_name = "gpt2-small"
28
+ hook_name_template = "blocks.{layer}.hook_resid_post"
29
+ llm_dtype = "float32"
30
+ llm_batch_size = 256
31
+ sae_batch_size = 250
32
+ random_seed = 42
33
+ lower_vram_usage = false
34
+ row_normalize = false
35
+ signed_identity = false
36
+ n_values = [2, 5, 10, 20, 50, 100, 500]
37
+ dataset_names = [
38
+ "LabHC/bias_in_bios_class_set1",
39
+ "canrager/amazon_reviews_mcauley_1and5",
40
+ ]
41
+
42
+ [saebench_tpp.sae_baseline]
43
+ source = "sae_lens_registry"
44
+ release_pattern = "gpt2-small-resid-post-v5-32k"
45
+ id_pattern_template = "blocks\\.{layer}\\.hook_resid_post"
46
+ allow_multiple = false
configs/comparisons/qwen3_5_2b_base.toml ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [model]
2
+ short_name = "qwen3_5_2b_base"
3
+ hidden_size = 2048
4
+ num_hidden_layers = 24
5
+
6
+ [ica]
7
+ artifact_set = "v3_frozen_ica"
8
+ artifact_root = "artifacts/fetched/models"
9
+ preprocess = "with_normalization"
10
+
11
+ [sae]
12
+ repo_id = "Qwen/SAE-Res-Qwen3.5-2B-Base-W32K-L0_50"
13
+ activation = "topk"
14
+ checkpoint_template = "layer{layer}.sae.pt"
15
+ checkpoint_format = "torch"
16
+ hook_name_template = "model.layers.{layer}"
17
+ layers = "all_transformer_layers"
18
+ width = 32768
19
+ direction_source = "decoder"
20
+ decoder_key = "W_dec"
21
+ top_k = 50
22
+
23
+ [direction_overlap]
24
+ top_k = 1
25
+ sae_chunk_size = 4096
26
+ nearest_metric = "maximum_absolute_cosine"
27
+
28
+ [saebench_tpp]
29
+ enabled = true
30
+ saebench_model_name = "Qwen/Qwen3.5-2B-Base"
31
+ hook_name_template = "blocks.{layer}.hook_resid_post"
32
+ llm_dtype = "bfloat16"
33
+ llm_batch_size = 16
34
+ sae_batch_size = 125
35
+ random_seed = 42
36
+ lower_vram_usage = false
37
+ row_normalize = false
38
+ signed_identity = false
39
+ n_values = [2, 5, 10, 20, 50, 100, 500]
40
+ dataset_names = [
41
+ "LabHC/bias_in_bios_class_set1",
42
+ "canrager/amazon_reviews_mcauley_1and5",
43
+ ]
44
+
45
+ [saebench_tpp.sae_baseline]
46
+ source = "custom_checkpoint"
47
+ release_name = "qwen_scope_res_w32k_l0_50_layer_{layer}"
48
+ hook_name_template = "blocks.{layer}.hook_resid_post"
49
+ allow_multiple = false
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ huggingface-hub>=0.23.0
4
+ pydantic>=2.10.0
5
+ numpy>=1.26.0
6
+ torch>=2.5.0
7
+ transformers>=4.44.0
8
+ datasets>=2.20.0
9
+ safetensors>=0.4.0
server/README.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ICA Lens Explorer and Annotator
2
+
3
+ This server is the public `v6` explorer/annotator for released ICA Lens
4
+ artifacts. It serves component statistics, examples, annotations, random
5
+ component samples, and live text probing when the corresponding model weights
6
+ and ICA artifacts are available.
7
+
8
+ Default local inputs:
9
+
10
+ ```text
11
+ artifacts/fetched/models/
12
+ artifacts/fetched/databases/ica_probe_mini.sqlite
13
+ ```
14
+
15
+ The fetched mini database is the default server database. Use
16
+ `ICA_EXPLORER_DB_PATH` only when you want to point the explorer at another
17
+ SQLite file, such as `artifacts/fetched/databases/ica_probe_full.sqlite` or a
18
+ locally reproduced demo database.
19
+
20
+ Run from `v6`:
21
+
22
+ ```bash
23
+ uv run python -m server.app --port 8001
24
+ ```
25
+
26
+ Pages:
27
+
28
+ ```text
29
+ / GPT-2 text probe explorer
30
+ /stats component statistics overview
31
+ /component component examples page
32
+ /annotate manual annotation editor
33
+ /random-components random 05.1 component sample list
34
+ ```
35
+
36
+ Set `ICA_EXPLORER_DB_PATH`, `ICA_EXPLORER_ICA_ROOT`,
37
+ `ICA_EXPLORER_ICA_DIR`, `ICA_EXPLORER_DEVICE`, or `ICA_EXPLORER_DTYPE` to
38
+ override defaults. If local inputs are missing, the server attempts to fetch
39
+ released artifacts from the configured Hugging Face dataset unless
40
+ `ICA_EXPLORER_DOWNLOAD_MISSING=0`.
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,816 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import gc
5
+ import html
6
+ import json
7
+ import threading
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import FileResponse, HTMLResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ from .artifacts import resolve_db_path, resolve_ica_dir, validate_ica_dir
20
+ from .config import Settings, load_settings
21
+ from .model_runtime import load_model_and_tokenizer, load_tokenizer
22
+ from .probe import _decode_byte_level_token, fastica_artifact_path, interpret_text_probe, list_ica_layer_keys
23
+ from .sae_probe import interpret_text_sae_probe, list_sae_layers, load_sae_config
24
+ from .store import (
25
+ connect,
26
+ get_annotation,
27
+ get_component_row,
28
+ get_examples_by_region,
29
+ infer_default_annotation_sign,
30
+ init_db,
31
+ list_annotated_components,
32
+ list_component_example_details,
33
+ list_component_examples,
34
+ list_component_metadata,
35
+ list_component_neighbors,
36
+ list_component_stats,
37
+ list_components,
38
+ list_chosen_random_components,
39
+ list_layers,
40
+ list_models,
41
+ pick_default_region,
42
+ search_components,
43
+ update_annotation,
44
+ validate_db,
45
+ )
46
+
47
+
48
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
49
+ V6_ROOT = Path(__file__).resolve().parents[1]
50
+ GPT2_LAYER11_PATCH_ROOT = V6_ROOT / "patch_gpt2_layer11"
51
+ GPT2_LAYER11_PATCH_DB = GPT2_LAYER11_PATCH_ROOT / "data" / "server" / "db" / "ica_probe_gpt2_layer11_patch.sqlite"
52
+ GPT2_LAYER11_PATCH_ICA_ROOT = GPT2_LAYER11_PATCH_ROOT / "data" / "ica"
53
+ ANNOTATION_TYPES = ["Form", "Word", "Phrase", "Sentence", "Long-Range Context", "Global", "Position", "Sophisticated"]
54
+
55
+
56
+ class ProbeRequest(BaseModel):
57
+ text: str = Field(..., min_length=1, max_length=100_000)
58
+ model_name: str = "gpt2"
59
+ layer: str
60
+ top_k: int = Field(5, ge=1, le=32)
61
+ highlights: list[int] = Field(default_factory=list, max_length=256)
62
+ max_length: int | None = Field(None, ge=16, le=8192)
63
+ keep_models: bool = False
64
+
65
+
66
+ class SaeProbeRequest(BaseModel):
67
+ text: str = Field(..., min_length=1, max_length=100_000)
68
+ model_name: str = "gpt2"
69
+ layer: str
70
+ top_k: int = Field(5, ge=1, le=128)
71
+ max_length: int | None = Field(None, ge=16, le=8192)
72
+ keep_models: bool = False
73
+
74
+
75
+ class AnnotationUpdate(BaseModel):
76
+ model_config = ConfigDict(extra="forbid")
77
+
78
+ model_name: str
79
+ layer: str
80
+ component: int
81
+ positive_label: str = ""
82
+ positive_confidence: str = "unclear"
83
+ positive_interpretation_types: list[str] = Field(default_factory=list)
84
+ negative_label: str = ""
85
+ negative_confidence: str = "unclear"
86
+ negative_interpretation_types: list[str] = Field(default_factory=list)
87
+ summary: str = ""
88
+ notes: str = ""
89
+ include_as_case_study: bool = False
90
+
91
+
92
+ def create_app(settings: Settings | None = None) -> FastAPI:
93
+ settings = settings or load_settings()
94
+ db_path = resolve_db_path(settings)
95
+ ica_dirs = {}
96
+ for model_name, model_settings in settings.models.items():
97
+ try:
98
+ ica_dirs[model_name] = resolve_ica_dir(settings, model_name=model_name, ica_dir=model_settings.ica_dir)
99
+ except FileNotFoundError:
100
+ continue
101
+ if not ica_dirs:
102
+ raise FileNotFoundError("No ICA artifact directories are available for the configured models.")
103
+
104
+ @asynccontextmanager
105
+ async def lifespan(app: FastAPI):
106
+ conn = connect(db_path)
107
+ init_db(conn)
108
+ validate_db(conn, None)
109
+ app.state.conn = conn
110
+ app.state.settings = settings
111
+ app.state.ica_dirs = ica_dirs
112
+ app.state.ica_dir = ica_dirs.get(settings.model_name, next(iter(ica_dirs.values())))
113
+ app.state.use_gpt2_layer11_patch = bool(getattr(settings, "use_gpt2_layer11_patch", False)) or _db_marks_gpt2_layer11_raw_hook(conn)
114
+ app.state.db_lock = threading.RLock()
115
+ app.state.runtime_lock = threading.Lock()
116
+ app.state.runtimes = {}
117
+ app.state.runtime = None
118
+ app.state.runtime_key = None
119
+ app.state.full_context_cache = {}
120
+ try:
121
+ yield
122
+ finally:
123
+ app.state.runtimes.clear()
124
+ app.state.runtime = None
125
+ app.state.runtime_key = None
126
+ conn.close()
127
+ _collect_memory()
128
+
129
+ app = FastAPI(title="ICA Lens Explorer", lifespan=lifespan)
130
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
131
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
132
+
133
+ @app.get("/api/health")
134
+ def health() -> dict[str, str]:
135
+ return {"status": "ok"}
136
+
137
+ @app.get("/api/meta")
138
+ def meta(model: str | None = None) -> dict[str, Any]:
139
+ model_name = model or app.state.settings.model_name
140
+ return _model_meta(app, model_name)
141
+
142
+ @app.post("/api/probe")
143
+ def probe(body: ProbeRequest) -> dict[str, Any]:
144
+ model_settings = _model_settings(app, body.model_name)
145
+ ica_dir = app.state.ica_dirs.get(model_settings.model_name)
146
+ if ica_dir is None:
147
+ raise HTTPException(status_code=404, detail=f"No ICA artifacts for model {model_settings.model_name!r}")
148
+ artifact = fastica_artifact_path(ica_dir, body.layer)
149
+ if not artifact.is_file():
150
+ raise HTTPException(status_code=404, detail=f"No ICA artifact for {model_settings.model_name!r} layer {body.layer!r}")
151
+ try:
152
+ with app.state.runtime_lock:
153
+ runtimes = app.state.runtimes
154
+ if not body.keep_models:
155
+ for loaded_name in list(runtimes):
156
+ if loaded_name != model_settings.model_name:
157
+ del runtimes[loaded_name]
158
+ _collect_memory()
159
+ runtime = runtimes.get(model_settings.model_name)
160
+ if runtime is None:
161
+ runtime = load_model_and_tokenizer(
162
+ model_settings.model_id,
163
+ device=app.state.settings.device,
164
+ dtype=model_settings.dtype,
165
+ )
166
+ runtimes[model_settings.model_name] = runtime
167
+ app.state.runtime = runtime
168
+ app.state.runtime_key = model_settings.model_name
169
+ model, tokenizer = runtime
170
+ result = interpret_text_probe(
171
+ model=model,
172
+ tokenizer=tokenizer,
173
+ text=body.text,
174
+ layer=body.layer,
175
+ ica_artifact_path=artifact,
176
+ top_k=body.top_k,
177
+ highlight_components=body.highlights,
178
+ max_length=body.max_length or model_settings.context_length,
179
+ raw_gpt2_block_index=_raw_gpt2_block_index_for_probe(app, model_settings.model_name, body.layer),
180
+ )
181
+ except ValueError as exc:
182
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
183
+ except RuntimeError as exc:
184
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
185
+ components = sorted({int(pair["component"]) for token in result.get("tokens", []) for pair in token.get("top", [])})
186
+ with app.state.db_lock:
187
+ annotated = list_component_metadata(app.state.conn, model_settings.model_name, body.layer, components)
188
+ return {**result, "annotated_components": annotated}
189
+
190
+ @app.get("/api/sae-meta")
191
+ def sae_meta(model: str | None = None) -> dict[str, Any]:
192
+ model_name = model or app.state.settings.model_name
193
+ model_settings = _model_settings(app, model_name)
194
+ try:
195
+ sae_config = load_sae_config(model_settings.model_name)
196
+ layers = list_sae_layers(model_settings.model_name)
197
+ except (FileNotFoundError, KeyError, ValueError) as exc:
198
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
199
+ return {
200
+ "model_name": model_settings.model_name,
201
+ "display_name": model_settings.display_name,
202
+ "model_id": model_settings.model_id,
203
+ "context_length": model_settings.context_length,
204
+ "layers": layers,
205
+ "device": app.state.settings.device,
206
+ "dtype": model_settings.dtype,
207
+ "sae": {
208
+ "repo_id": sae_config.get("repo_id"),
209
+ "width": sae_config.get("width"),
210
+ "top_k": sae_config.get("top_k"),
211
+ "activation": sae_config.get("activation"),
212
+ },
213
+ "model_loaded": model_settings.model_name in getattr(app.state, "runtimes", {}),
214
+ }
215
+
216
+ @app.post("/api/sae-probe")
217
+ def sae_probe(body: SaeProbeRequest) -> dict[str, Any]:
218
+ model_settings = _model_settings(app, body.model_name)
219
+ try:
220
+ if body.layer not in list_sae_layers(model_settings.model_name):
221
+ raise ValueError(f"No SAE configured for {model_settings.model_name!r} layer {body.layer!r}")
222
+ with app.state.runtime_lock:
223
+ runtimes = app.state.runtimes
224
+ if not body.keep_models:
225
+ for loaded_name in list(runtimes):
226
+ if loaded_name != model_settings.model_name:
227
+ del runtimes[loaded_name]
228
+ _collect_memory()
229
+ runtime = runtimes.get(model_settings.model_name)
230
+ if runtime is None:
231
+ runtime = load_model_and_tokenizer(
232
+ model_settings.model_id,
233
+ device=app.state.settings.device,
234
+ dtype=model_settings.dtype,
235
+ )
236
+ runtimes[model_settings.model_name] = runtime
237
+ app.state.runtime = runtime
238
+ app.state.runtime_key = model_settings.model_name
239
+ model, tokenizer = runtime
240
+ return interpret_text_sae_probe(
241
+ model=model,
242
+ tokenizer=tokenizer,
243
+ text=body.text,
244
+ model_name=model_settings.model_name,
245
+ layer=body.layer,
246
+ top_k=body.top_k,
247
+ max_length=body.max_length or model_settings.context_length,
248
+ )
249
+ except ValueError as exc:
250
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
251
+ except (FileNotFoundError, KeyError) as exc:
252
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
253
+ except RuntimeError as exc:
254
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
255
+
256
+ @app.get("/api/component-stats")
257
+ def component_stats(model: str | None = None) -> dict[str, Any]:
258
+ model_name = model or app.state.settings.model_name
259
+ with app.state.db_lock:
260
+ rows = list_component_stats(app.state.conn, model_name)
261
+ layers: list[dict[str, Any]] = []
262
+ for row in rows:
263
+ if not layers or layers[-1]["layer"] != row["layer"]:
264
+ layers.append({"layer": row["layer"], "components": []})
265
+ layers[-1]["components"].append({key: value for key, value in row.items() if key != "layer"})
266
+ return {"model_name": model_name, "layers": layers}
267
+
268
+ @app.get("/api/component-examples")
269
+ def component_examples(layer: str, component: int, model: str | None = None) -> dict[str, Any]:
270
+ model_name = model or app.state.settings.model_name
271
+ with app.state.db_lock:
272
+ rows = list_components(app.state.conn, model_name, layer=layer, component=component)
273
+ if not rows:
274
+ raise HTTPException(status_code=404, detail="Unknown layer/component")
275
+ examples = list_component_example_details(app.state.conn, model_name, layer=layer, component=component)
276
+ tokenizer = None
277
+ model_settings = app.state.settings.models.get(model_name)
278
+ if model_settings is not None:
279
+ tokenizer = load_tokenizer(model_settings.model_id)
280
+ for example in examples:
281
+ example["token"] = _visible_token_text(example.get("token"), token_id=example.get("token_id"), tokenizer=tokenizer)
282
+ bands: dict[str, list[dict[str, Any]]] = {}
283
+ for example in examples:
284
+ bands.setdefault(str(example["region"] or "examples"), []).append(example)
285
+ return {
286
+ "model_name": model_name,
287
+ "layer": layer,
288
+ "component": component,
289
+ "excess_kurtosis": rows[0]["excess_kurtosis"],
290
+ "bands": [{"region": region, "examples": items} for region, items in sorted(bands.items(), key=lambda item: _example_band_sort_key(item[0]))],
291
+ }
292
+
293
+ @app.get("/api/component-token-stats")
294
+ def component_token_stats(layer: str, component: int, model: str | None = None) -> dict[str, Any]:
295
+ model_name = model or app.state.settings.model_name
296
+ with app.state.db_lock:
297
+ rows = list_components(app.state.conn, model_name, layer=layer, component=component)
298
+ if not rows:
299
+ raise HTTPException(status_code=404, detail="Unknown layer/component")
300
+ examples = list_component_examples(app.state.conn, model_name, layer=layer, component=component).get((layer, component), [])
301
+ tokenizer = None
302
+ model_settings = app.state.settings.models.get(model_name)
303
+ if model_settings is not None:
304
+ tokenizer = load_tokenizer(model_settings.model_id)
305
+ counts: dict[str, int] = {}
306
+ first_seen: dict[str, int] = {}
307
+ total_count = 0
308
+ for example in examples:
309
+ if example["region"] not in {"top_abs", "top_abs_sample_500"}:
310
+ continue
311
+ token = _visible_token_text(example["token"], token_id=example.get("token_id"), tokenizer=tokenizer)
312
+ if not token:
313
+ continue
314
+ total_count += 1
315
+ first_seen.setdefault(token, total_count)
316
+ counts[token] = counts.get(token, 0) + 1
317
+ ordered_tokens = sorted(counts.items(), key=lambda item: (-item[1], first_seen[item[0]]))
318
+ return {"model_name": model_name, "layer": layer, "component": component, "total_count": total_count, "tokens": [{"token": token, "count": count} for token, count in ordered_tokens]}
319
+
320
+ @app.get("/api/component-neighbors")
321
+ def component_neighbors(layer: str, component: int, model: str | None = None) -> dict[str, Any]:
322
+ model_name = model or app.state.settings.model_name
323
+ with app.state.db_lock:
324
+ rows = list_components(app.state.conn, model_name, layer=layer, component=component)
325
+ if not rows:
326
+ raise HTTPException(status_code=404, detail="Unknown layer/component")
327
+ neighbors = list_component_neighbors(app.state.conn, model_name, layer=layer, component=component)
328
+ return {"model_name": model_name, "layer": layer, "component": component, "neighbors": neighbors}
329
+
330
+ @app.get("/api/models")
331
+ def api_models() -> dict[str, Any]:
332
+ models = []
333
+ with app.state.db_lock:
334
+ model_names = list_models(app.state.conn)
335
+ for model_name in model_names:
336
+ model_settings = app.state.settings.models.get(model_name)
337
+ ica_dir = app.state.ica_dirs.get(model_name)
338
+ models.append(
339
+ {
340
+ "model_name": model_name,
341
+ "display_name": model_settings.display_name if model_settings else model_name,
342
+ "model_id": model_settings.model_id if model_settings else "",
343
+ "context_length": model_settings.context_length if model_settings else None,
344
+ "has_examples": True,
345
+ "probe_supported": model_settings is not None and ica_dir is not None,
346
+ "ica_layers": list_ica_layer_keys(ica_dir) if ica_dir else [],
347
+ }
348
+ )
349
+ return {"models": models}
350
+
351
+ @app.get("/api/layers")
352
+ def api_layers(model: str) -> dict[str, Any]:
353
+ with app.state.db_lock:
354
+ layers = list_layers(app.state.conn, model)
355
+ return {"layers": layers}
356
+
357
+ @app.get("/api/components")
358
+ def api_components(model: str, layer: str | None = None, search: str | None = None) -> dict[str, Any]:
359
+ with app.state.db_lock:
360
+ components = list_components(app.state.conn, model_name=model, layer=layer, search=search)
361
+ return {"components": components}
362
+
363
+ @app.get("/api/search/components")
364
+ def api_search_components(model: str | None = None, q: str | None = None, confidence: str | None = None, type: str | None = None, include_examples: bool = False, limit: int = 200) -> dict[str, Any]:
365
+ with app.state.db_lock:
366
+ results = search_components(app.state.conn, model_name=model, query=q or "", confidence=confidence or "", annotation_type=type or "", include_examples=include_examples, limit=limit)
367
+ return {"results": results}
368
+
369
+ @app.get("/api/random-components")
370
+ def api_random_components(model: str | None = None, selection: str | None = None) -> dict[str, Any]:
371
+ with app.state.db_lock:
372
+ rows = list_chosen_random_components(app.state.conn, model_name=model, selection_name=selection)
373
+ runs_by_key: dict[tuple[str, str], dict[str, Any]] = {}
374
+ for row in rows:
375
+ model_name = str(row["model_name"])
376
+ selection_name = str(row["selection_name"])
377
+ key = (model_name, selection_name)
378
+ run = runs_by_key.setdefault(
379
+ key,
380
+ {
381
+ "model": model_name,
382
+ "selection_name": selection_name,
383
+ "source_json": row.get("source_json"),
384
+ "settings": {
385
+ "n": row.get("requested_n"),
386
+ "seed": row.get("seed"),
387
+ },
388
+ "inventory_size": row.get("inventory_size"),
389
+ "selected_size": row.get("selected_size"),
390
+ "selected_components": [],
391
+ },
392
+ )
393
+ component = int(row["component"])
394
+ layer = str(row["layer"])
395
+ default_sign = infer_default_annotation_sign(app.state.conn, model_name, layer, component)
396
+ run["selected_components"].append(
397
+ {
398
+ **row,
399
+ "component_index": component,
400
+ "top_abs_sign": default_sign,
401
+ "annotation": _annotation_response(row),
402
+ "annotate_url": f"/annotate?model={model_name}&layer={layer}&component={component}",
403
+ }
404
+ )
405
+ return {"runs": list(runs_by_key.values())}
406
+
407
+ @app.get("/api/annotations/component")
408
+ def api_get_annotation(model: str, layer: str, component: int) -> dict[str, Any]:
409
+ with app.state.db_lock:
410
+ row = get_annotation(app.state.conn, model, layer, component)
411
+ if not row:
412
+ sign = infer_default_annotation_sign(app.state.conn, model, layer, component)
413
+ return _blank_annotation(model, layer, component, default_sign=sign)
414
+ return _annotation_response(row)
415
+
416
+ @app.post("/api/annotations/component")
417
+ def api_post_annotation(body: AnnotationUpdate) -> dict[str, str]:
418
+ with app.state.db_lock:
419
+ if get_component_row(app.state.conn, body.model_name, body.layer, body.component) is None:
420
+ raise HTTPException(status_code=404, detail="Unknown model/layer/component")
421
+ update_annotation(
422
+ app.state.conn,
423
+ model_name=body.model_name,
424
+ layer=body.layer,
425
+ component=body.component,
426
+ positive_label=body.positive_label,
427
+ positive_confidence=body.positive_confidence,
428
+ positive_interpretation_types=body.positive_interpretation_types,
429
+ negative_label=body.negative_label,
430
+ negative_confidence=body.negative_confidence,
431
+ negative_interpretation_types=body.negative_interpretation_types,
432
+ summary=body.summary,
433
+ notes=body.notes,
434
+ include_as_case_study=body.include_as_case_study,
435
+ )
436
+ return {"status": "ok"}
437
+
438
+ @app.get("/api/examples/component")
439
+ def api_examples_component(model: str, layer: str, component: int) -> dict[str, Any]:
440
+ with app.state.db_lock:
441
+ if get_component_row(app.state.conn, model, layer, component) is None:
442
+ raise HTTPException(status_code=404, detail="Unknown model/layer/component")
443
+ regions, examples_by_region = get_examples_by_region(app.state.conn, model, layer, component)
444
+ tokenizer = None
445
+ model_settings = app.state.settings.models.get(model)
446
+ if model_settings is not None:
447
+ tokenizer = load_tokenizer(model_settings.model_id)
448
+ for examples in examples_by_region.values():
449
+ for example in examples:
450
+ example["token"] = _visible_token_text(example.get("token"), token_id=example.get("token_id"), tokenizer=tokenizer)
451
+ return {"model_name": model, "layer": layer, "component": component, "regions": regions, "default_region": pick_default_region(regions, examples_by_region), "examples_by_region": examples_by_region}
452
+
453
+ @app.get("/api/annotation-types")
454
+ def api_annotation_types() -> dict[str, Any]:
455
+ return {"types": ANNOTATION_TYPES}
456
+
457
+ @app.get("/context")
458
+ def full_context_page(model: str, doc_id: int, position: int | None = None, target: str | None = None) -> HTMLResponse:
459
+ model_settings = _model_settings(app, model)
460
+ text = _load_dataset_doc_text(app, model_settings, doc_id)
461
+ target_span = _target_token_char_span(model_settings, text, position)
462
+ if target_span is None:
463
+ target_span = _fallback_target_char_span(text, target)
464
+ return HTMLResponse(
465
+ _render_full_context_page(
466
+ model_name=model_settings.model_name,
467
+ doc_id=doc_id,
468
+ position=position,
469
+ text=text,
470
+ target_span=target_span,
471
+ )
472
+ )
473
+
474
+ @app.get("/")
475
+ def index() -> FileResponse:
476
+ return FileResponse(STATIC_DIR / "index.html")
477
+
478
+ @app.get("/stats")
479
+ def stats() -> FileResponse:
480
+ return FileResponse(STATIC_DIR / "stats.html")
481
+
482
+ @app.get("/component")
483
+ def component_page() -> FileResponse:
484
+ return FileResponse(STATIC_DIR / "component.html")
485
+
486
+ @app.get("/annotate")
487
+ def annotate_page() -> FileResponse:
488
+ return FileResponse(STATIC_DIR / "annotate.html")
489
+
490
+ @app.get("/random-components")
491
+ def random_components_page() -> FileResponse:
492
+ return FileResponse(STATIC_DIR / "random_components.html")
493
+
494
+ @app.get("/sae-explorer")
495
+ def sae_explorer_page() -> FileResponse:
496
+ return FileResponse(STATIC_DIR / "sae_explorer.html")
497
+
498
+ return app
499
+
500
+
501
+ def _model_settings(app: FastAPI, model_name: str):
502
+ model_settings = app.state.settings.models.get(model_name)
503
+ if model_settings is None:
504
+ raise HTTPException(status_code=404, detail=f"Model {model_name!r} is not configured for text probing.")
505
+ return model_settings
506
+
507
+
508
+ def _raw_gpt2_block_index_for_probe(app: FastAPI, model_name: str, layer: str) -> int | None:
509
+ if not getattr(app.state, "use_gpt2_layer11_patch", False):
510
+ return None
511
+ if model_name == "gpt2" and layer == "layer_11":
512
+ return 11
513
+ return None
514
+
515
+
516
+ def _db_marks_gpt2_layer11_raw_hook(conn) -> bool:
517
+ try:
518
+ row = conn.execute(
519
+ """
520
+ SELECT value
521
+ FROM import_meta
522
+ WHERE model_name = ?
523
+ AND key = ?
524
+ """,
525
+ ("gpt2", "gpt2_layer11_probe_site"),
526
+ ).fetchone()
527
+ except Exception:
528
+ return False
529
+ if row is None:
530
+ return False
531
+ value = str(row[0]).strip().lower()
532
+ return value in {"raw_block_11_resid_post", "patched_raw_block_11_resid_post"}
533
+
534
+
535
+ def _settings_with_gpt2_layer11_patch(settings: Settings) -> Settings:
536
+ gpt2_settings = settings.models.get("gpt2")
537
+ if gpt2_settings is None:
538
+ raise RuntimeError("GPT-2 settings are required for --use-gpt2-layer11-patch.")
539
+ if not GPT2_LAYER11_PATCH_DB.is_file():
540
+ raise FileNotFoundError(f"Patch SQLite database does not exist: {GPT2_LAYER11_PATCH_DB}")
541
+ if not (GPT2_LAYER11_PATCH_ICA_ROOT / "gpt2" / "layer_11_fastica.pt").is_file():
542
+ raise FileNotFoundError(f"Patch ICA artifact does not exist: {GPT2_LAYER11_PATCH_ICA_ROOT / 'gpt2' / 'layer_11_fastica.pt'}")
543
+ models = dict(settings.models)
544
+ models["gpt2"] = replace(gpt2_settings, ica_dir=GPT2_LAYER11_PATCH_ICA_ROOT / "gpt2")
545
+ patched = replace(
546
+ settings,
547
+ db_path=GPT2_LAYER11_PATCH_DB,
548
+ ica_root=GPT2_LAYER11_PATCH_ICA_ROOT,
549
+ ica_dir=GPT2_LAYER11_PATCH_ICA_ROOT / "gpt2",
550
+ model_name="gpt2",
551
+ download_missing=False,
552
+ models=models,
553
+ use_gpt2_layer11_patch=True,
554
+ )
555
+ return patched
556
+
557
+
558
+ def _model_meta(app: FastAPI, model_name: str) -> dict[str, Any]:
559
+ model_settings = _model_settings(app, model_name)
560
+ ica_dir = app.state.ica_dirs.get(model_settings.model_name)
561
+ layers = list_ica_layer_keys(ica_dir) if ica_dir else []
562
+ with app.state.db_lock:
563
+ db_layers = set(list_layers(app.state.conn, model_settings.model_name))
564
+ return {
565
+ "model_name": model_settings.model_name,
566
+ "display_name": model_settings.display_name,
567
+ "model_id": model_settings.model_id,
568
+ "context_length": model_settings.context_length,
569
+ "layers": [layer for layer in layers if layer in db_layers],
570
+ "device": app.state.settings.device,
571
+ "dtype": model_settings.dtype,
572
+ "model_loaded": model_settings.model_name in getattr(app.state, "runtimes", {}),
573
+ }
574
+
575
+
576
+ def _blank_annotation(model: str, layer: str, component: int, *, default_sign: int) -> dict[str, Any]:
577
+ positive_label = "" if default_sign >= 0 else "?"
578
+ negative_label = "?" if default_sign >= 0 else ""
579
+ return {
580
+ "model_name": model,
581
+ "layer": layer,
582
+ "component": component,
583
+ "positive_label": positive_label,
584
+ "positive_confidence": "unclear",
585
+ "positive_interpretation_types": [],
586
+ "negative_label": negative_label,
587
+ "negative_confidence": "unclear",
588
+ "negative_interpretation_types": [],
589
+ "summary": "",
590
+ "notes": "",
591
+ "include_as_case_study": False,
592
+ "updated_at": None,
593
+ }
594
+
595
+
596
+ def _annotation_response(row: dict[str, Any]) -> dict[str, Any]:
597
+ return {
598
+ "model_name": row["model_name"],
599
+ "layer": row["layer"],
600
+ "component": int(row["component"]),
601
+ "positive_label": row.get("positive_label") or "",
602
+ "positive_confidence": row.get("positive_confidence") or "unclear",
603
+ "positive_interpretation_types": _json_list(row.get("positive_interpretation_types_json")),
604
+ "negative_label": row.get("negative_label") or "",
605
+ "negative_confidence": row.get("negative_confidence") or "unclear",
606
+ "negative_interpretation_types": _json_list(row.get("negative_interpretation_types_json")),
607
+ "summary": row.get("summary") or "",
608
+ "notes": row.get("notes") or "",
609
+ "include_as_case_study": bool(row.get("include_as_case_study")),
610
+ "updated_at": row.get("updated_at"),
611
+ }
612
+
613
+
614
+ def _json_list(value: Any) -> list[str]:
615
+ import json
616
+ try:
617
+ parsed = json.loads(value or "[]")
618
+ except json.JSONDecodeError:
619
+ return []
620
+ return [str(item) for item in parsed] if isinstance(parsed, list) else []
621
+
622
+
623
+ def _load_dataset_doc_text(app: FastAPI, model_settings: Any, doc_id: int) -> str:
624
+ if doc_id < 0:
625
+ raise HTTPException(status_code=400, detail="doc_id must be non-negative")
626
+ key = (model_settings.model_name, doc_id)
627
+ cached = app.state.full_context_cache.get(key)
628
+ if cached is not None:
629
+ return cached
630
+ try:
631
+ from datasets import load_dataset
632
+ except ImportError as exc:
633
+ raise HTTPException(status_code=500, detail="datasets is required to open full document context") from exc
634
+ dataset_kwargs: dict[str, Any] = {
635
+ "path": model_settings.dataset_path,
636
+ "split": model_settings.dataset_split,
637
+ "streaming": model_settings.dataset_streaming,
638
+ }
639
+ if model_settings.dataset_name:
640
+ dataset_kwargs["name"] = model_settings.dataset_name
641
+ try:
642
+ dataset = load_dataset(**dataset_kwargs)
643
+ if model_settings.dataset_streaming:
644
+ for idx, row in enumerate(dataset):
645
+ if idx == doc_id:
646
+ text = str(row[model_settings.dataset_text_column])
647
+ app.state.full_context_cache[key] = text
648
+ return text
649
+ if idx > doc_id:
650
+ break
651
+ else:
652
+ row = dataset[doc_id]
653
+ text = str(row[model_settings.dataset_text_column])
654
+ app.state.full_context_cache[key] = text
655
+ return text
656
+ except IndexError as exc:
657
+ raise HTTPException(status_code=404, detail=f"Document {doc_id} not found in {model_settings.dataset_path}/{model_settings.dataset_split}") from exc
658
+ except KeyError as exc:
659
+ raise HTTPException(status_code=500, detail=f"Dataset row does not contain text column {model_settings.dataset_text_column!r}") from exc
660
+ raise HTTPException(status_code=404, detail=f"Document {doc_id} not found in {model_settings.dataset_path}/{model_settings.dataset_split}")
661
+
662
+
663
+ def _target_token_char_span(model_settings: Any, text: str, position: int | None) -> tuple[int, int] | None:
664
+ if position is None or position < 0:
665
+ return None
666
+ tokenizer = load_tokenizer(model_settings.model_id)
667
+ try:
668
+ encoded = tokenizer(
669
+ text,
670
+ truncation=True,
671
+ max_length=model_settings.context_length,
672
+ return_offsets_mapping=True,
673
+ )
674
+ except (NotImplementedError, TypeError, ValueError):
675
+ return None
676
+ offsets = encoded.get("offset_mapping")
677
+ if offsets is None or position >= len(offsets):
678
+ return None
679
+ start, stop = offsets[position]
680
+ start = int(start)
681
+ stop = int(stop)
682
+ if stop <= start:
683
+ return None
684
+ return start, stop
685
+
686
+
687
+ def _fallback_target_char_span(text: str, target: str | None) -> tuple[int, int] | None:
688
+ if not target:
689
+ return None
690
+ candidates = [target.replace("\ufffd", "")]
691
+ stripped = candidates[0].strip()
692
+ if stripped and stripped != candidates[0]:
693
+ candidates.append(stripped)
694
+ for candidate in candidates:
695
+ if not candidate:
696
+ continue
697
+ start = text.find(candidate)
698
+ if start >= 0:
699
+ return start, start + len(candidate)
700
+ return None
701
+
702
+
703
+ def _highlighted_text_html(text: str, target_span: tuple[int, int] | None) -> str:
704
+ if target_span is None:
705
+ return html.escape(text)
706
+ start, stop = target_span
707
+ start = max(0, min(start, len(text)))
708
+ stop = max(start, min(stop, len(text)))
709
+ return f'{html.escape(text[:start])}<mark id="target-token">{html.escape(text[start:stop])}</mark>{html.escape(text[stop:])}'
710
+
711
+
712
+ def _render_full_context_page(*, model_name: str, doc_id: int, position: int | None, text: str, target_span: tuple[int, int] | None) -> str:
713
+ pos = "" if position is None else f" - pos={html.escape(str(position))}"
714
+ title = f"{model_name} - doc={doc_id}{pos}"
715
+ content = _highlighted_text_html(text, target_span)
716
+ target_status = "" if target_span is not None else '<span class="target-status">target token not located</span>'
717
+ return f'''<!doctype html>
718
+ <html lang="en">
719
+ <head>
720
+ <meta charset="utf-8" />
721
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
722
+ <title>{html.escape(title)}</title>
723
+ <style>
724
+ body {{ margin:0; background:#f8fafc; color:#0f172a; font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
725
+ header {{ position:sticky; top:0; background:#fff; border-bottom:1px solid #cbd5e1; padding:14px 18px; color:#475569; font-size:13px; box-shadow:0 1px 2px rgb(15 23 42 / .08); }}
726
+ pre {{ max-width:980px; margin:24px auto; padding:0 20px 48px; white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; }}
727
+ mark {{ background:#bfdbfe; color:#172554; border-radius:4px; padding:1px 3px; box-shadow:0 0 0 1px #93c5fd inset; }}
728
+ .target-status {{ margin-left:10px; color:#b45309; }}
729
+ </style>
730
+ </head>
731
+ <body>
732
+ <header>{html.escape(title)}{target_status}</header>
733
+ <pre>{content}</pre>
734
+ <script>const target=document.getElementById("target-token"); if(target) target.scrollIntoView({{block:"center"}});</script>
735
+ </body>
736
+ </html>'''
737
+
738
+
739
+ def _collect_memory() -> None:
740
+ gc.collect()
741
+ try:
742
+ import torch
743
+ if torch.cuda.is_available():
744
+ torch.cuda.empty_cache()
745
+ except Exception:
746
+ pass
747
+
748
+
749
+ def _example_band_sort_key(region: str) -> tuple[int, str]:
750
+ name = str(region or "").lower().replace("-", "_")
751
+ compact = name.replace("_", "")
752
+ if name == "top_abs" or compact == "topabs":
753
+ return (0, name)
754
+ if "sample" in name:
755
+ return (1, name)
756
+ if "near_zero" in name or "nearzero" in compact:
757
+ return (2, name)
758
+ if "opposite" in name:
759
+ return (3, name)
760
+ return (4, name)
761
+
762
+
763
+ def _visible_token_text(token: Any, *, token_id: Any = None, tokenizer: Any = None) -> str:
764
+ raw = str(token or "")
765
+ text = raw.strip()
766
+ if (not text or "\ufffd" in text) and tokenizer is not None and token_id is not None:
767
+ try:
768
+ token_id_int = int(token_id)
769
+ decoded = str(tokenizer.decode([token_id_int], skip_special_tokens=False, clean_up_tokenization_spaces=False))
770
+ except Exception:
771
+ decoded = ""
772
+ if decoded and "\ufffd" not in decoded:
773
+ raw = decoded
774
+ text = raw.strip()
775
+ else:
776
+ try:
777
+ raw_token = str(tokenizer.convert_ids_to_tokens(token_id_int))
778
+ except Exception:
779
+ raw_token = ""
780
+ byte_decoded = _decode_byte_level_token(raw_token) if raw_token else None
781
+ if byte_decoded:
782
+ raw = byte_decoded
783
+ text = raw.strip()
784
+ elif raw_token and "\ufffd" not in raw_token:
785
+ raw = raw_token
786
+ text = raw.strip()
787
+ if text:
788
+ return text
789
+ if raw == " ":
790
+ return "[space]"
791
+ if raw == "\n":
792
+ return "[newline]"
793
+ if raw == "\t":
794
+ return "[tab]"
795
+ return raw.replace("\ufffd", "[invalid]")
796
+
797
+
798
+ def main() -> None:
799
+ import uvicorn
800
+ parser = argparse.ArgumentParser(description="Run the ICA Lens explorer/annotator server.")
801
+ parser.add_argument("--host", default="127.0.0.1")
802
+ parser.add_argument("--port", type=int, default=8764)
803
+ parser.add_argument(
804
+ "--use-gpt2-layer11-patch",
805
+ action="store_true",
806
+ help="Use the isolated corrected GPT-2 layer-11 patch DB/artifacts and raw block hook for live layer-11 probes.",
807
+ )
808
+ args = parser.parse_args()
809
+ settings = load_settings()
810
+ if args.use_gpt2_layer11_patch:
811
+ settings = _settings_with_gpt2_layer11_patch(settings)
812
+ uvicorn.run(create_app(settings), host=args.host, port=args.port, log_level="info")
813
+
814
+
815
+ if __name__ == "__main__":
816
+ main()
server/artifacts.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from huggingface_hub import hf_hub_download, snapshot_download
6
+
7
+ from .config import Settings
8
+
9
+
10
+ RELEASED_DATABASES = {"ica_probe_mini.sqlite", "ica_probe_full.sqlite"}
11
+
12
+
13
+ def resolve_db_path(settings: Settings) -> Path:
14
+ if settings.db_path.is_file():
15
+ _validate_readable_file(settings.db_path)
16
+ return settings.db_path
17
+ if not settings.download_missing:
18
+ raise FileNotFoundError(f"SQLite database does not exist: {settings.db_path}")
19
+ if settings.db_path.name not in RELEASED_DATABASES:
20
+ raise FileNotFoundError(
21
+ f"SQLite database does not exist: {settings.db_path}. "
22
+ "Automatic download is only supported for released mini/full databases."
23
+ )
24
+ settings.db_path.parent.mkdir(parents=True, exist_ok=True)
25
+ local_dir = settings.db_path.parent.parent if settings.db_path.parent.name == "databases" else settings.db_path.parent
26
+ downloaded = Path(
27
+ hf_hub_download(
28
+ repo_id=settings.db_repo,
29
+ repo_type="dataset",
30
+ filename=f"databases/{settings.db_path.name}",
31
+ revision=settings.hf_revision,
32
+ local_dir=local_dir,
33
+ )
34
+ )
35
+ _validate_readable_file(downloaded)
36
+ return downloaded
37
+
38
+
39
+ def resolve_ica_dir(settings: Settings, *, model_name: str | None = None, ica_dir: Path | None = None) -> Path:
40
+ target_model = model_name or settings.model_name
41
+ target_dir = ica_dir or settings.ica_dir
42
+ if _has_fastica_artifacts(target_dir):
43
+ return target_dir
44
+ if not settings.download_missing:
45
+ raise FileNotFoundError(f"ICA artifacts do not exist: {target_dir}")
46
+ target_dir.parent.parent.mkdir(parents=True, exist_ok=True)
47
+ snapshot_download(
48
+ repo_id=settings.artifact_repo,
49
+ repo_type="dataset",
50
+ revision=settings.hf_revision,
51
+ allow_patterns=[f"models/{target_model}/**"],
52
+ local_dir=target_dir.parent.parent,
53
+ )
54
+ if not _has_fastica_artifacts(target_dir):
55
+ raise FileNotFoundError(f"Downloaded artifacts but found no FastICA files in {target_dir}")
56
+ return target_dir
57
+
58
+
59
+ def validate_ica_dir(ica_dir: Path) -> None:
60
+ if not _has_fastica_artifacts(ica_dir):
61
+ raise FileNotFoundError(f"No *_fastica.pt artifacts found in {ica_dir}")
62
+
63
+
64
+ def _has_fastica_artifacts(path: Path) -> bool:
65
+ return path.is_dir() and any(path.glob("*_fastica.pt"))
66
+
67
+
68
+ def _validate_readable_file(path: Path) -> None:
69
+ try:
70
+ with path.open("rb"):
71
+ pass
72
+ except OSError as exc:
73
+ raise RuntimeError(f"File exists but cannot be accessed: {path}") from exc
server/config.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ V6_ROOT = Path(__file__).resolve().parents[1]
9
+ DEFAULT_ARTIFACT_REPO = "sida/ica-lens-paper"
10
+ DEFAULT_DB_REPO = "sida/ica-lens-paper"
11
+ DEFAULT_DB_FILENAME = "ica_probe_mini.sqlite"
12
+ DEFAULT_MODEL_ID = "openai-community/gpt2"
13
+ DEFAULT_MODEL_NAME = "gpt2"
14
+ DEFAULT_MODEL_REGISTRY = {
15
+ "gpt2": {"model_id": "openai-community/gpt2", "display_name": "GPT-2", "context_length": 1024, "dtype": "bfloat16", "dataset_path": "NeelNanda/pile-10k", "dataset_name": "", "dataset_split": "train", "dataset_text_column": "text", "dataset_streaming": False},
16
+ "gemma2_2b": {"model_id": "google/gemma-2-2b", "display_name": "Gemma 2 2B", "context_length": 1024, "dtype": "bfloat16", "dataset_path": "NeelNanda/pile-10k", "dataset_name": "", "dataset_split": "train", "dataset_text_column": "text", "dataset_streaming": False},
17
+ "qwen3_5_2b_base": {"model_id": "Qwen/Qwen3.5-2B-Base", "display_name": "Qwen3.5 2B Base", "context_length": 1024, "dtype": "bfloat16", "dataset_path": "NeelNanda/pile-10k", "dataset_name": "", "dataset_split": "train", "dataset_text_column": "text", "dataset_streaming": False},
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ModelSettings:
23
+ model_name: str
24
+ model_id: str
25
+ display_name: str
26
+ ica_dir: Path
27
+ context_length: int
28
+ dtype: str
29
+ dataset_path: str
30
+ dataset_name: str | None
31
+ dataset_split: str
32
+ dataset_text_column: str
33
+ dataset_streaming: bool
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Settings:
38
+ db_path: Path
39
+ ica_dir: Path
40
+ ica_root: Path
41
+ artifact_repo: str
42
+ db_repo: str
43
+ hf_revision: str | None
44
+ model_id: str
45
+ model_name: str
46
+ device: str
47
+ dtype: str
48
+ context_length: int
49
+ download_missing: bool
50
+ models: dict[str, ModelSettings]
51
+ use_gpt2_layer11_patch: bool = False
52
+
53
+
54
+ def load_settings() -> Settings:
55
+ fetched_root = V6_ROOT / "artifacts" / "fetched"
56
+ db_path = Path(os.environ.get("ICA_EXPLORER_DB_PATH", str(fetched_root / "databases" / DEFAULT_DB_FILENAME))).expanduser()
57
+ ica_root = Path(os.environ.get("ICA_EXPLORER_ICA_ROOT", str(fetched_root / "models"))).expanduser()
58
+ ica_dir = Path(os.environ.get("ICA_EXPLORER_ICA_DIR", str(ica_root / DEFAULT_MODEL_NAME))).expanduser()
59
+ enabled_models = _enabled_model_names()
60
+ models = {
61
+ model_name: ModelSettings(
62
+ model_name=model_name,
63
+ model_id=str(meta["model_id"]),
64
+ display_name=str(meta["display_name"]),
65
+ ica_dir=ica_root / model_name,
66
+ context_length=int(meta["context_length"]),
67
+ dtype=str(meta["dtype"]),
68
+ dataset_path=str(meta["dataset_path"]),
69
+ dataset_name=str(meta["dataset_name"]) or None,
70
+ dataset_split=str(meta["dataset_split"]),
71
+ dataset_text_column=str(meta["dataset_text_column"]),
72
+ dataset_streaming=bool(meta["dataset_streaming"]),
73
+ )
74
+ for model_name, meta in DEFAULT_MODEL_REGISTRY.items()
75
+ if model_name in enabled_models
76
+ }
77
+ if os.environ.get("ICA_EXPLORER_ICA_DIR"):
78
+ default = models[DEFAULT_MODEL_NAME]
79
+ models[DEFAULT_MODEL_NAME] = ModelSettings(
80
+ model_name=default.model_name,
81
+ model_id=default.model_id,
82
+ display_name=default.display_name,
83
+ ica_dir=ica_dir,
84
+ context_length=default.context_length,
85
+ dtype=default.dtype,
86
+ dataset_path=default.dataset_path,
87
+ dataset_name=default.dataset_name,
88
+ dataset_split=default.dataset_split,
89
+ dataset_text_column=default.dataset_text_column,
90
+ dataset_streaming=default.dataset_streaming,
91
+ )
92
+ return Settings(
93
+ db_path=db_path,
94
+ ica_dir=ica_dir,
95
+ ica_root=ica_root,
96
+ artifact_repo=os.environ.get("ICA_EXPLORER_ARTIFACT_REPO", DEFAULT_ARTIFACT_REPO),
97
+ db_repo=os.environ.get("ICA_EXPLORER_DB_REPO", DEFAULT_DB_REPO),
98
+ hf_revision=os.environ.get("ICA_EXPLORER_HF_REVISION") or None,
99
+ model_id=os.environ.get("ICA_EXPLORER_MODEL_ID", DEFAULT_MODEL_ID),
100
+ model_name=os.environ.get("ICA_EXPLORER_MODEL_NAME", DEFAULT_MODEL_NAME),
101
+ device=os.environ.get("ICA_EXPLORER_DEVICE", "auto"),
102
+ dtype=os.environ.get("ICA_EXPLORER_DTYPE", "bfloat16"),
103
+ context_length=int(os.environ.get("ICA_EXPLORER_CONTEXT_LENGTH", "1024")),
104
+ download_missing=os.environ.get("ICA_EXPLORER_DOWNLOAD_MISSING", "1").strip().lower() not in {"0", "false", "no"},
105
+ models=models,
106
+ )
107
+
108
+
109
+ def _enabled_model_names() -> set[str]:
110
+ raw = os.environ.get("ICA_EXPLORER_ENABLED_MODELS")
111
+ if not raw:
112
+ return set(DEFAULT_MODEL_REGISTRY)
113
+ names = {name.strip() for name in raw.split(",") if name.strip()}
114
+ unknown = names - set(DEFAULT_MODEL_REGISTRY)
115
+ if unknown:
116
+ raise ValueError(f"Unknown ICA_EXPLORER_ENABLED_MODELS value(s): {', '.join(sorted(unknown))}")
117
+ if not names:
118
+ raise ValueError("ICA_EXPLORER_ENABLED_MODELS did not contain any model names.")
119
+ return names
server/model_runtime.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import Any
5
+
6
+ import torch
7
+ from transformers import AutoModelForCausalLM, AutoTokenizer
8
+
9
+
10
+ def torch_dtype(name: str) -> torch.dtype:
11
+ aliases = {
12
+ "float16": torch.float16,
13
+ "fp16": torch.float16,
14
+ "bfloat16": torch.bfloat16,
15
+ "bf16": torch.bfloat16,
16
+ "float32": torch.float32,
17
+ "fp32": torch.float32,
18
+ }
19
+ try:
20
+ return aliases[name.lower()]
21
+ except KeyError as exc:
22
+ raise ValueError(f"Unsupported torch dtype {name!r}") from exc
23
+
24
+
25
+ def resolve_device(requested: str) -> torch.device:
26
+ if requested == "auto":
27
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
28
+ if requested.startswith("cuda") and not torch.cuda.is_available():
29
+ raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false.")
30
+ return torch.device(requested)
31
+
32
+
33
+ @lru_cache(maxsize=None)
34
+ def load_tokenizer(model_id: str) -> Any:
35
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
36
+ ensure_pad_token(tokenizer)
37
+ return tokenizer
38
+
39
+
40
+ def load_model_and_tokenizer(model_id: str, *, device: str, dtype: str) -> tuple[torch.nn.Module, Any]:
41
+ torch_device = resolve_device(device)
42
+ tokenizer = load_tokenizer(model_id)
43
+
44
+ model_dtype = torch_dtype(dtype)
45
+ if torch_device.type == "cpu" and model_dtype in {torch.float16, torch.bfloat16}:
46
+ model_dtype = torch.float32
47
+
48
+ model = AutoModelForCausalLM.from_pretrained(
49
+ model_id,
50
+ torch_dtype=model_dtype,
51
+ low_cpu_mem_usage=True,
52
+ )
53
+ model.to(torch_device)
54
+ model.eval()
55
+ return model, tokenizer
56
+
57
+
58
+ def ensure_pad_token(tokenizer: Any) -> None:
59
+ if tokenizer.pad_token_id is None:
60
+ if tokenizer.eos_token_id is None:
61
+ raise ValueError("Tokenizer has neither pad_token_id nor eos_token_id.")
62
+ tokenizer.pad_token = tokenizer.eos_token
server/probe.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Collection
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import torch
9
+
10
+
11
+ def list_ica_layer_keys(ica_dir: Path) -> list[str]:
12
+ if not ica_dir.is_dir():
13
+ return []
14
+ layers = [path.name[: -len("_fastica.pt")] for path in ica_dir.glob("*_fastica.pt")]
15
+ return sorted(layers, key=_layer_sort_key)
16
+
17
+
18
+ def fastica_artifact_path(ica_dir: Path, layer: str) -> Path:
19
+ return ica_dir / f"{layer}_fastica.pt"
20
+
21
+
22
+ def interpret_text_probe(
23
+ *,
24
+ model: torch.nn.Module,
25
+ tokenizer: Any,
26
+ text: str,
27
+ layer: str,
28
+ ica_artifact_path: Path,
29
+ top_k: int,
30
+ highlight_components: Collection[int] | None,
31
+ max_length: int,
32
+ raw_gpt2_block_index: int | None = None,
33
+ ) -> dict[str, Any]:
34
+ num_transformer_layers = int(model.config.num_hidden_layers)
35
+ hidden_index = _layer_key_to_hidden_index(layer, num_transformer_layers=num_transformer_layers)
36
+ show_predictions = layer == f"layer_{num_transformer_layers - 1:02d}"
37
+ full_ids = tokenizer.encode(text, add_special_tokens=True)
38
+ truncated = len(full_ids) > max_length
39
+ encoded = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
40
+ input_ids = encoded["input_ids"].to(next(model.parameters()).device)
41
+ attention_mask = encoded.get("attention_mask")
42
+ if attention_mask is not None:
43
+ attention_mask = attention_mask.to(input_ids.device)
44
+
45
+ predictions: list[dict[str, Any]] | None = None
46
+ with torch.inference_mode():
47
+ if layer == "embedding":
48
+ hidden = model.get_input_embeddings()(input_ids)[0]
49
+ elif raw_gpt2_block_index is not None:
50
+ captured: dict[str, torch.Tensor] = {}
51
+ blocks = getattr(getattr(model, "transformer", None), "h", None)
52
+ if blocks is None:
53
+ raise RuntimeError("raw_gpt2_block_index requires a GPT-2 style model.transformer.h module list.")
54
+
55
+ def hook(_module: Any, _inputs: Any, output: Any) -> None:
56
+ captured["hidden"] = output[0].detach() if isinstance(output, tuple) else output.detach()
57
+
58
+ handle = blocks[int(raw_gpt2_block_index)].register_forward_hook(hook)
59
+ try:
60
+ outputs = model(
61
+ input_ids=input_ids,
62
+ attention_mask=attention_mask,
63
+ use_cache=False,
64
+ )
65
+ finally:
66
+ handle.remove()
67
+ captured_hidden = captured.get("hidden")
68
+ if captured_hidden is None:
69
+ raise RuntimeError("GPT-2 raw block hook did not capture hidden states.")
70
+ hidden = captured_hidden[0]
71
+ if show_predictions:
72
+ pred_ids = torch.argmax(outputs.logits[0], dim=-1).detach().cpu().tolist()
73
+ predictions = [
74
+ {
75
+ "token_id": int(pred_id),
76
+ "token": tokenizer.convert_ids_to_tokens(int(pred_id)),
77
+ "token_text": _decode_token_text(tokenizer, int(pred_id)),
78
+ }
79
+ for pred_id in pred_ids
80
+ ]
81
+ else:
82
+ outputs = model(
83
+ input_ids=input_ids,
84
+ attention_mask=attention_mask,
85
+ output_hidden_states=True,
86
+ use_cache=False,
87
+ )
88
+ hidden_states = outputs.hidden_states
89
+ if hidden_states is None:
90
+ raise RuntimeError("Model did not return hidden states.")
91
+ hidden = hidden_states[hidden_index][0]
92
+ if show_predictions:
93
+ pred_ids = torch.argmax(outputs.logits[0], dim=-1).detach().cpu().tolist()
94
+ predictions = [
95
+ {
96
+ "token_id": int(pred_id),
97
+ "token": tokenizer.convert_ids_to_tokens(int(pred_id)),
98
+ "token_text": _decode_token_text(tokenizer, int(pred_id)),
99
+ }
100
+ for pred_id in pred_ids
101
+ ]
102
+
103
+ artifact = _load_fastica_artifact(ica_artifact_path)
104
+ scores = _all_source_scores(hidden, **artifact)
105
+ idx, vals = _topk_components_per_token(scores, top_k=top_k)
106
+ forced = sorted({int(component) for component in (highlight_components or [])})
107
+ out_of_range = [component for component in forced if component < 0 or component >= int(scores.shape[1])]
108
+ if out_of_range:
109
+ raise ValueError(f"highlight component out of range: {out_of_range[0]}")
110
+
111
+ ids = input_ids[0].detach().cpu().tolist()
112
+ idx_cpu = idx.detach().cpu().tolist()
113
+ vals_cpu = vals.detach().cpu().tolist()
114
+ forced_scores = scores[:, forced].detach().cpu().tolist() if forced else []
115
+
116
+ tokens = []
117
+ for pos, token_id in enumerate(ids):
118
+ top = [
119
+ {"component": int(idx_cpu[pos][j]), "score": float(vals_cpu[pos][j])}
120
+ for j in range(len(idx_cpu[pos]))
121
+ ]
122
+ seen = {item["component"] for item in top}
123
+ for j, component in enumerate(forced):
124
+ if component not in seen:
125
+ top.append({"component": component, "score": float(forced_scores[pos][j]), "highlighted": True})
126
+ token_item = {
127
+ "position": pos,
128
+ "token_id": int(token_id),
129
+ "token": tokenizer.convert_ids_to_tokens(int(token_id)),
130
+ "token_text": _decode_token_text(tokenizer, int(token_id)),
131
+ "top": top,
132
+ }
133
+ if predictions is not None:
134
+ token_item["prediction"] = predictions[pos]
135
+ tokens.append(token_item)
136
+
137
+ return {
138
+ "layer": layer,
139
+ "top_k": int(top_k),
140
+ "max_length": int(max_length),
141
+ "tokens": tokens,
142
+ "seq_len": len(tokens),
143
+ "truncated": truncated,
144
+ "n_components": int(scores.shape[1]),
145
+ "predictions_available": predictions is not None,
146
+ }
147
+
148
+
149
+ @lru_cache(maxsize=None)
150
+ def _load_fastica_artifact(path: Path) -> dict[str, torch.Tensor | float]:
151
+ blob = torch.load(path, map_location="cpu")
152
+ tensors = blob["tensors"]
153
+ meta = blob.get("metadata") or {}
154
+ mean = tensors["mean"].to(torch.float32)
155
+ if mean.dim() == 1:
156
+ mean = mean.unsqueeze(0)
157
+ return {
158
+ "mean": mean,
159
+ "components": tensors["components"].to(torch.float32),
160
+ "norm_eps": float(meta.get("norm_eps", 1e-12)),
161
+ }
162
+
163
+
164
+ def _all_source_scores(
165
+ activations: torch.Tensor,
166
+ *,
167
+ mean: torch.Tensor,
168
+ components: torch.Tensor,
169
+ norm_eps: float,
170
+ ) -> torch.Tensor:
171
+ x = activations.to(dtype=torch.float32)
172
+ normalized = x / torch.linalg.vector_norm(x, dim=1, keepdim=True).clamp_min(norm_eps)
173
+ return (normalized - mean.to(x.device)) @ components.to(x.device).T
174
+
175
+
176
+ def _topk_components_per_token(scores: torch.Tensor, *, top_k: int) -> tuple[torch.Tensor, torch.Tensor]:
177
+ k = min(int(top_k), int(scores.shape[1]))
178
+ if k <= 0:
179
+ raise ValueError("top_k must be positive.")
180
+ idx = torch.topk(scores.abs(), k, dim=1).indices
181
+ row_idx = torch.arange(scores.shape[0], device=scores.device, dtype=torch.long).unsqueeze(1).expand_as(idx)
182
+ return idx, scores[row_idx, idx]
183
+
184
+
185
+ def _layer_key_to_hidden_index(layer: str, *, num_transformer_layers: int) -> int:
186
+ if layer == "embedding":
187
+ return 0
188
+ if not layer.startswith("layer_"):
189
+ raise ValueError(f"Unknown layer key: {layer!r}")
190
+ idx = int(layer.split("_", maxsplit=1)[1])
191
+ if idx < 0 or idx >= num_transformer_layers:
192
+ raise ValueError(f"Layer index out of range: {layer!r}")
193
+ return idx + 1
194
+
195
+
196
+ def _layer_sort_key(layer: str) -> tuple[int, int | str]:
197
+ if layer == "embedding":
198
+ return (0, 0)
199
+ if layer.startswith("layer_"):
200
+ return (1, int(layer.removeprefix("layer_")))
201
+ return (2, layer)
202
+
203
+
204
+ def _decode_token_text(tokenizer: Any, token_id: int) -> str:
205
+ try:
206
+ decoded = str(tokenizer.decode([token_id], skip_special_tokens=False, clean_up_tokenization_spaces=False))
207
+ except Exception:
208
+ decoded = ""
209
+ raw_token = str(tokenizer.convert_ids_to_tokens(token_id))
210
+ if "\ufffd" in decoded:
211
+ byte_text = _decode_byte_level_token(raw_token)
212
+ if byte_text is not None:
213
+ return byte_text
214
+ return decoded or raw_token
215
+
216
+
217
+ def _decode_byte_level_token(token: str) -> str | None:
218
+ byte_decoder = _gpt2_byte_decoder()
219
+ try:
220
+ raw = bytes(byte_decoder[ch] for ch in token)
221
+ except KeyError:
222
+ return None
223
+ try:
224
+ return raw.decode("utf-8")
225
+ except UnicodeDecodeError:
226
+ return "".join(f"\\x{byte:02X}" for byte in raw)
227
+
228
+
229
+ def _gpt2_byte_decoder() -> dict[str, int]:
230
+ bs = (
231
+ list(range(ord("!"), ord("~") + 1))
232
+ + list(range(ord("¡"), ord("¬") + 1))
233
+ + list(range(ord("®"), ord("ÿ") + 1))
234
+ )
235
+ cs = bs[:]
236
+ n = 0
237
+ for byte in range(256):
238
+ if byte not in bs:
239
+ bs.append(byte)
240
+ cs.append(256 + n)
241
+ n += 1
242
+ return {chr(char): byte for byte, char in zip(bs, cs, strict=True)}
server/sae_probe.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import torch
10
+ from huggingface_hub import hf_hub_download
11
+ from safetensors.torch import load_file
12
+
13
+ from .probe import _decode_token_text, _layer_key_to_hidden_index
14
+
15
+
16
+ V6_ROOT = Path(__file__).resolve().parents[1]
17
+ CONFIG_ROOT = V6_ROOT / "configs" / "comparisons"
18
+
19
+
20
+ def list_sae_layers(model_name: str) -> list[str]:
21
+ config = load_sae_config(model_name)
22
+ model_config = load_comparison_config(model_name).get("model", {})
23
+ n_layers = int(model_config.get("num_hidden_layers") or 0)
24
+ layer_checkpoints = config.get("layer_checkpoints")
25
+ if isinstance(layer_checkpoints, dict) and layer_checkpoints:
26
+ indices = sorted(int(key) for key in layer_checkpoints)
27
+ else:
28
+ indices = list(range(n_layers))
29
+ return [f"layer_{idx:02d}" for idx in indices]
30
+
31
+
32
+ def load_comparison_config(model_name: str) -> dict[str, Any]:
33
+ path = CONFIG_ROOT / f"{model_name}.toml"
34
+ if not path.is_file():
35
+ raise FileNotFoundError(f"No SAE comparison config for {model_name!r}: {path}")
36
+ return tomllib.loads(path.read_text(encoding="utf-8"))
37
+
38
+
39
+ def load_sae_config(model_name: str) -> dict[str, Any]:
40
+ data = load_comparison_config(model_name)
41
+ if "sae" not in data:
42
+ raise KeyError(f"Comparison config for {model_name!r} has no [sae] section.")
43
+ return dict(data["sae"])
44
+
45
+
46
+ def interpret_text_sae_probe(
47
+ *,
48
+ model: torch.nn.Module,
49
+ tokenizer: Any,
50
+ text: str,
51
+ model_name: str,
52
+ layer: str,
53
+ top_k: int,
54
+ max_length: int,
55
+ ) -> dict[str, Any]:
56
+ if layer == "embedding":
57
+ raise ValueError("SAE Explorer currently supports transformer layers, not embedding.")
58
+ layer_index = int(layer.removeprefix("layer_"))
59
+ hidden_index = _layer_key_to_hidden_index(layer, num_transformer_layers=int(model.config.num_hidden_layers))
60
+ full_ids = tokenizer.encode(text, add_special_tokens=True)
61
+ truncated = len(full_ids) > max_length
62
+ encoded = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
63
+ input_ids = encoded["input_ids"].to(next(model.parameters()).device)
64
+ attention_mask = encoded.get("attention_mask")
65
+ if attention_mask is not None:
66
+ attention_mask = attention_mask.to(input_ids.device)
67
+
68
+ with torch.inference_mode():
69
+ outputs = model(
70
+ input_ids=input_ids,
71
+ attention_mask=attention_mask,
72
+ output_hidden_states=True,
73
+ use_cache=False,
74
+ )
75
+ hidden_states = outputs.hidden_states
76
+ if hidden_states is None:
77
+ raise RuntimeError("Model did not return hidden states.")
78
+ hidden = hidden_states[hidden_index][0].to(dtype=torch.float32)
79
+ sae = load_sae(model_name, layer_index, hidden_size=int(hidden.shape[-1]), device=hidden.device)
80
+ pre, acts = sae_forward(hidden, sae=sae)
81
+ idx, vals = topk_features(acts, top_k=top_k)
82
+
83
+ ids = input_ids[0].detach().cpu().tolist()
84
+ idx_cpu = idx.detach().cpu().tolist()
85
+ vals_cpu = vals.detach().cpu().tolist()
86
+ pre_cpu = pre.detach().cpu()
87
+ tokens = []
88
+ for pos, token_id in enumerate(ids):
89
+ top = []
90
+ for j in range(len(idx_cpu[pos])):
91
+ feature = int(idx_cpu[pos][j])
92
+ top.append(
93
+ {
94
+ "feature": feature,
95
+ "activation": float(vals_cpu[pos][j]),
96
+ "preactivation": float(pre_cpu[pos, feature].item()),
97
+ }
98
+ )
99
+ tokens.append(
100
+ {
101
+ "position": pos,
102
+ "token_id": int(token_id),
103
+ "token": tokenizer.convert_ids_to_tokens(int(token_id)),
104
+ "token_text": _decode_token_text(tokenizer, int(token_id)),
105
+ "top": top,
106
+ }
107
+ )
108
+ return {
109
+ "model_name": model_name,
110
+ "layer": layer,
111
+ "top_k": int(top_k),
112
+ "max_length": int(max_length),
113
+ "tokens": tokens,
114
+ "seq_len": len(tokens),
115
+ "truncated": truncated,
116
+ "sae_width": int(sae["w_enc"].shape[1]),
117
+ "activation_rule": sae["activation_rule"],
118
+ }
119
+
120
+
121
+ @lru_cache(maxsize=64)
122
+ def _load_sae_cpu(model_name: str, layer: int, hidden_size: int) -> dict[str, Any]:
123
+ config = load_sae_config(model_name)
124
+ path = _resolve_sae_weights_path(config, layer=layer)
125
+ weights = _load_sae_weights(path, config=config)
126
+ w_dec = _decoder_weight(weights, hidden_size=hidden_size, preferred_key=config.get("decoder_key")).to(dtype=torch.float32)
127
+ w_enc = _encoder_weight(weights, hidden_size=hidden_size, d_sae=int(w_dec.shape[0])).to(dtype=torch.float32).contiguous()
128
+ b_enc = _vector_weight(weights, names=("b_enc", "encoder.bias"), length=int(w_enc.shape[1]))
129
+ b_dec = _vector_weight(weights, names=("b_dec", "decoder.bias", "bias"), length=hidden_size)
130
+ threshold = weights.get("threshold")
131
+ return {
132
+ "w_enc": w_enc,
133
+ "b_enc": b_enc.to(dtype=torch.float32).contiguous(),
134
+ "b_dec": b_dec.to(dtype=torch.float32).contiguous(),
135
+ "threshold": threshold.to(dtype=torch.float32).contiguous() if threshold is not None else None,
136
+ "top_k": _sae_top_k(config),
137
+ "activation_rule": _activation_rule_label(config),
138
+ }
139
+
140
+
141
+ def load_sae(model_name: str, layer: int, *, hidden_size: int, device: torch.device) -> dict[str, Any]:
142
+ cpu = _load_sae_cpu(model_name, layer, hidden_size)
143
+ return {
144
+ key: value.to(device=device, dtype=torch.float32, non_blocking=True) if isinstance(value, torch.Tensor) else value
145
+ for key, value in cpu.items()
146
+ }
147
+
148
+
149
+ def sae_forward(hidden: torch.Tensor, *, sae: dict[str, Any]) -> tuple[torch.Tensor, torch.Tensor]:
150
+ centered = hidden.to(dtype=torch.float32) - sae["b_dec"].unsqueeze(0)
151
+ pre = centered @ sae["w_enc"] + sae["b_enc"].unsqueeze(0)
152
+ threshold = sae["threshold"]
153
+ if threshold is not None:
154
+ return pre, torch.where(pre > threshold.unsqueeze(0), pre, torch.zeros_like(pre))
155
+ top_k = sae["top_k"]
156
+ if top_k is not None:
157
+ k = min(int(top_k), int(pre.shape[1]))
158
+ values, indices = torch.topk(pre, k=k, dim=1)
159
+ acts = torch.zeros_like(pre)
160
+ acts.scatter_(1, indices, torch.clamp(values, min=0.0))
161
+ return pre, acts
162
+ return pre, torch.relu(pre)
163
+
164
+
165
+ def topk_features(acts: torch.Tensor, *, top_k: int) -> tuple[torch.Tensor, torch.Tensor]:
166
+ k = min(int(top_k), int(acts.shape[1]))
167
+ if k <= 0:
168
+ raise ValueError("top_k must be positive.")
169
+ vals, idx = torch.topk(acts, k=k, dim=1)
170
+ return idx, vals
171
+
172
+
173
+ def _resolve_sae_weights_path(config: dict[str, Any], *, layer: int) -> str:
174
+ layer_checkpoints = config.get("layer_checkpoints")
175
+ if isinstance(layer_checkpoints, dict) and str(layer) in layer_checkpoints:
176
+ filename = str(layer_checkpoints[str(layer)])
177
+ else:
178
+ filename = str(config["checkpoint_template"]).format(layer=layer)
179
+ return hf_hub_download(repo_id=str(config["repo_id"]), filename=filename, revision=config.get("revision"))
180
+
181
+
182
+ def _load_sae_weights(path: str, *, config: dict[str, Any]) -> dict[str, torch.Tensor]:
183
+ checkpoint_format = str(config.get("checkpoint_format", "")).lower()
184
+ if checkpoint_format == "safetensors":
185
+ return load_file(path, device="cpu")
186
+ if checkpoint_format == "npz":
187
+ with np.load(path) as arrays:
188
+ return {key: torch.from_numpy(arrays[key]) for key in arrays.files}
189
+ if checkpoint_format in {"torch", "pt", "pth"}:
190
+ try:
191
+ loaded = torch.load(path, map_location="cpu", weights_only=True)
192
+ except TypeError:
193
+ loaded = torch.load(path, map_location="cpu")
194
+ if not isinstance(loaded, dict):
195
+ raise TypeError(f"Expected torch checkpoint {path} to contain a dict, got {type(loaded).__name__}.")
196
+ return _flatten_tensor_dict(loaded)
197
+ raise ValueError(f"Unsupported SAE checkpoint_format {checkpoint_format!r}.")
198
+
199
+
200
+ def _flatten_tensor_dict(payload: dict[str, Any], prefix: str = "") -> dict[str, torch.Tensor]:
201
+ flattened: dict[str, torch.Tensor] = {}
202
+ for key, value in payload.items():
203
+ name = f"{prefix}.{key}" if prefix else str(key)
204
+ if isinstance(value, torch.Tensor):
205
+ flattened[name] = value
206
+ flattened[str(key)] = value
207
+ elif isinstance(value, dict):
208
+ flattened.update(_flatten_tensor_dict(value, name))
209
+ return flattened
210
+
211
+
212
+ def _encoder_weight(weights: dict[str, torch.Tensor], *, hidden_size: int, d_sae: int) -> torch.Tensor:
213
+ for key in ("W_enc", "encoder.weight", "W_enc.weight", "encoder.W_enc"):
214
+ tensor = weights.get(key)
215
+ if tensor is not None:
216
+ return _orient_encoder(tensor, hidden_size=hidden_size, d_sae=d_sae)
217
+ return _decoder_weight(weights, hidden_size=hidden_size).T.contiguous()
218
+
219
+
220
+ def _decoder_weight(
221
+ weights: dict[str, torch.Tensor],
222
+ *,
223
+ hidden_size: int,
224
+ preferred_key: object = None,
225
+ ) -> torch.Tensor:
226
+ if preferred_key is not None:
227
+ key = str(preferred_key)
228
+ if key not in weights:
229
+ raise KeyError(f"Configured decoder_key {key!r} not found. Available keys: {sorted(weights)}")
230
+ return _orient_decoder(weights[key], hidden_size=hidden_size)
231
+ for key in ("W_dec", "decoder.weight", "W_dec.weight", "decoder.W_dec"):
232
+ tensor = weights.get(key)
233
+ if tensor is not None:
234
+ return _orient_decoder(tensor, hidden_size=hidden_size)
235
+ candidates = [tensor for tensor in weights.values() if tensor.ndim == 2 and hidden_size in tensor.shape]
236
+ if len(candidates) == 1:
237
+ return _orient_decoder(candidates[0], hidden_size=hidden_size)
238
+ raise KeyError(f"Could not identify SAE decoder weight. Available keys: {sorted(weights)}")
239
+
240
+
241
+ def _vector_weight(weights: dict[str, torch.Tensor], *, names: tuple[str, ...], length: int) -> torch.Tensor:
242
+ for name in names:
243
+ tensor = weights.get(name)
244
+ if tensor is not None:
245
+ if int(tensor.numel()) != int(length):
246
+ raise ValueError(f"SAE vector {name!r} has length {tensor.numel()}, expected {length}.")
247
+ return tensor.reshape(length).to(dtype=torch.float32)
248
+ return torch.zeros(length, dtype=torch.float32)
249
+
250
+
251
+ def _orient_decoder(tensor: torch.Tensor, *, hidden_size: int) -> torch.Tensor:
252
+ if int(tensor.shape[1]) == hidden_size:
253
+ return tensor
254
+ if int(tensor.shape[0]) == hidden_size:
255
+ return tensor.T
256
+ raise ValueError(f"Decoder weight shape {tuple(tensor.shape)} does not contain hidden size {hidden_size}.")
257
+
258
+
259
+ def _orient_encoder(tensor: torch.Tensor, *, hidden_size: int, d_sae: int) -> torch.Tensor:
260
+ if tuple(tensor.shape) == (hidden_size, d_sae):
261
+ return tensor
262
+ if tuple(tensor.shape) == (d_sae, hidden_size):
263
+ return tensor.T
264
+ if int(tensor.shape[0]) == hidden_size:
265
+ return tensor
266
+ if int(tensor.shape[1]) == hidden_size:
267
+ return tensor.T
268
+ raise ValueError(f"Encoder weight shape {tuple(tensor.shape)} does not match hidden={hidden_size}, d_sae={d_sae}.")
269
+
270
+
271
+ def _sae_top_k(config: dict[str, Any]) -> int | None:
272
+ if config.get("top_k") is not None:
273
+ return int(config["top_k"])
274
+ repo = str(config.get("repo_id") or "")
275
+ if "GPT2-Small-OAI-v5-32k-resid-post-SAEs" in repo:
276
+ return 32
277
+ return None
278
+
279
+
280
+ def _activation_rule_label(config: dict[str, Any]) -> str:
281
+ if config.get("top_k") is not None:
282
+ return f"topk-{int(config['top_k'])}"
283
+ if str(config.get("activation") or "").lower() == "topk":
284
+ return "topk"
285
+ return "relu_or_threshold"
server/static/annotate.html ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ICA Annotator</title>
7
+ <style>
8
+ :root { --bg:#f8fafc; --panel:#fff; --subtle:#f1f5f9; --text:#0f172a; --muted:#64748b; --border:#cbd5e1; --accent:#2563eb; --good:#059669; --bad:#dc2626; --shadow:0 1px 2px rgb(15 23 42 / .08),0 8px 24px rgb(15 23 42 / .05); }
9
+ * { box-sizing:border-box; }
10
+ body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
11
+ header { position:sticky; top:0; z-index:5; background:#fff; border-bottom:1px solid var(--border); padding:12px 16px; box-shadow:var(--shadow); }
12
+ .top { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
13
+ h1 { margin:0; font-size:20px; }
14
+ nav { display:flex; gap:6px; }
15
+ nav a { color:var(--muted); text-decoration:none; padding:7px 10px; border-radius:7px; font-weight:650; }
16
+ nav a:hover, nav a.active { color:var(--accent); background:#eff6ff; }
17
+ .toolbar { display:flex; align-items:end; flex-wrap:wrap; gap:10px; margin-top:10px; }
18
+ label { color:#475569; font-size:12px; font-weight:700; }
19
+ select,input,textarea,button { font:inherit; border:1px solid var(--border); border-radius:7px; padding:7px 9px; background:#fff; color:var(--text); }
20
+ textarea { width:100%; resize:vertical; }
21
+ button { cursor:pointer; background:var(--subtle); }
22
+ button:hover { color:var(--accent); border-color:var(--accent); background:#eff6ff; }
23
+ .primary { color:#fff; background:var(--accent); border-color:#1d4ed8; font-weight:800; }
24
+ .primary:hover { color:#fff; background:#1d4ed8; }
25
+ main { display:grid; grid-template-columns:320px minmax(0,1fr) 360px; gap:14px; padding:14px; }
26
+ aside,.panel { background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:var(--shadow); }
27
+ aside { max-height:calc(100vh - 108px); overflow:auto; padding:10px; position:sticky; top:94px; }
28
+ .panel { padding:12px; }
29
+ .panel-title-row { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:10px; }
30
+ .panel-title-row h2 { margin:0; }
31
+ .component-metrics { display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end; }
32
+ .metric-badge { display:inline-flex; align-items:center; min-height:24px; border:1px solid var(--border); border-radius:999px; padding:2px 8px; background:#f8fafc; color:#475569; font-size:12px; font-weight:800; font-variant-numeric:tabular-nums; }
33
+ .component-list { display:flex; flex-direction:column; gap:7px; margin-top:10px; }
34
+ .component-button { text-align:left; background:#fff; width:100%; border-radius:7px; }
35
+ .component-button.active { border-color:var(--accent); box-shadow:inset 3px 0 0 var(--accent); }
36
+ .component-title { display:flex; justify-content:space-between; gap:8px; font-weight:800; }
37
+ .label-line { color:#334155; margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
38
+ .muted { color:var(--muted); }
39
+ .pill { display:inline-flex; border:1px solid var(--border); border-radius:999px; padding:1px 7px; color:var(--muted); font-size:12px; }
40
+ .examples { display:grid; grid-template-columns:repeat(auto-fit,minmax(310px,1fr)); gap:10px; }
41
+ .example { border:1px solid #e2e8f0; border-radius:8px; padding:10px; background:#fff; }
42
+ .example-head { display:flex; justify-content:space-between; gap:8px; color:var(--muted); font-size:12px; margin-bottom:8px; }
43
+ .example-meta { display:flex; flex-wrap:wrap; align-items:center; gap:6px; min-width:0; }
44
+ .doc-link { color:#2563eb; text-decoration:none; font-weight:800; }
45
+ .doc-link:hover { text-decoration:underline; }
46
+ .score { font-weight:850; color:#b91c1c; font-variant-numeric:tabular-nums; }
47
+ .score.neg { color:#1d4ed8; }
48
+ .token { background:#fee2e2; color:#7f1d1d; border-radius:5px; padding:1px 5px; font-weight:800; }
49
+ pre { white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; margin:8px 0 0; }
50
+ .context { margin:8px 0 0; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:14px; line-height:1.45; }
51
+ .context-token { border-radius:3px; padding:0 1px; }
52
+ .context-token.target { outline:1px solid rgb(15 23 42 / .28); outline-offset:1px; }
53
+ .tabs { display:flex; flex-wrap:wrap; gap:7px; margin:10px 0; }
54
+ .tabs button.active { color:#fff; background:var(--accent); border-color:var(--accent); }
55
+ .editor { position:sticky; top:94px; max-height:calc(100vh - 108px); overflow:auto; }
56
+ .editor h2,.panel h2 { margin:0 0 10px; font-size:16px; }
57
+ .neighbor-row { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:12px; }
58
+ .neighbor-card { display:grid; gap:4px; min-height:68px; border:1px solid var(--border); border-radius:8px; padding:8px; background:#fff; color:var(--text); text-decoration:none; }
59
+ .neighbor-card:hover { border-color:var(--accent); color:var(--text); text-decoration:none; background:#eff6ff; }
60
+ .neighbor-card.missing { visibility:hidden; }
61
+ .neighbor-head { display:flex; justify-content:space-between; align-items:start; gap:8px; }
62
+ .neighbor-target { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; color:var(--muted); font-size:11px; font-weight:750; }
63
+ .neighbor-link { min-width:0; color:var(--muted); text-decoration:none; }
64
+ .neighbor-link:hover { color:var(--accent); text-decoration:underline; }
65
+ .neighbor-copy { width:auto; min-height:24px; padding:2px 7px; border-radius:6px; font-size:11px; font-weight:800; white-space:nowrap; }
66
+ .neighbor-label { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; color:var(--text); font-size:14px; font-weight:850; line-height:1.2; }
67
+ .neighbor-metrics { color:var(--muted); font-size:11px; font-weight:500; font-variant-numeric:tabular-nums; }
68
+ .neighbor-cos { min-height:20px; border:1px solid #d7dee9; border-radius:5px; padding:2px 6px; background:linear-gradient(90deg,rgb(37 99 235 / .14) 0,rgb(37 99 235 / .14) var(--cos-width,0%),transparent var(--cos-width,0%),transparent 100%); color:var(--muted); font-size:12px; font-weight:800; font-variant-numeric:tabular-nums; }
69
+ .field { margin-bottom:10px; }
70
+ .direction-block { border:1px solid #e2e8f0; border-left-width:4px; border-radius:8px; padding:10px; margin-bottom:10px; }
71
+ .direction-block.positive { border-left-color:#e2e8f0; }
72
+ .direction-block.negative { border-left-color:#e2e8f0; }
73
+ .direction-block.positive.active-direction { border-left-color:#dc2626; background:#fef2f2; }
74
+ .direction-block.negative.active-direction { border-left-color:#2563eb; background:#eff6ff; }
75
+ .direction-block h3 { margin:0 0 9px; font-size:14px; }
76
+ .field-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
77
+ .field-grid select { width:100%; min-width:0; }
78
+ .label-confidence-high { border-color:#16a34a; box-shadow:0 0 0 2px rgb(22 163 74 / .12); }
79
+ .label-confidence-high:focus { border-color:#16a34a; outline:2px solid rgb(22 163 74 / .22); outline-offset:1px; box-shadow:0 0 0 2px rgb(22 163 74 / .12); }
80
+ .label-confidence-medium { border-color:#d9b94e; box-shadow:0 0 0 2px rgb(217 185 78 / .16); }
81
+ .label-confidence-medium:focus { border-color:#d9b94e; outline:2px solid rgb(217 185 78 / .25); outline-offset:1px; box-shadow:0 0 0 2px rgb(217 185 78 / .16); }
82
+ .label-confidence-low { border-color:#f9a8d4; box-shadow:0 0 0 2px rgb(249 168 212 / .18); }
83
+ .label-confidence-low:focus { border-color:#f9a8d4; outline:2px solid rgb(249 168 212 / .28); outline-offset:1px; box-shadow:0 0 0 2px rgb(249 168 212 / .18); }
84
+ .label-confidence-unclear { border-color:#cbd5e1; box-shadow:none; }
85
+ .label-confidence-unclear:focus { border-color:#94a3b8; outline:2px solid rgb(148 163 184 / .28); outline-offset:1px; box-shadow:none; }
86
+ .status { margin-left:auto; color:var(--muted); }
87
+ .empty,.error { border:1px dashed var(--border); border-radius:8px; padding:18px; color:var(--muted); background:#fff; }
88
+ .error { color:#991b1b; border-color:#fecaca; background:#fef2f2; }
89
+ @media(max-width:1050px){ main{grid-template-columns:1fr;} aside,.editor{position:static;max-height:none;} }
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <header>
94
+ <div class="top">
95
+ <h1>ICA Annotator</h1>
96
+ <nav>
97
+ <a href="/">Explorer</a>
98
+ <a href="/sae-explorer">SAE Explorer</a>
99
+ <a href="/stats">Stats</a>
100
+ <a href="/annotate" class="active">Annotate</a>
101
+ <a href="/random-components">Random</a>
102
+ </nav>
103
+ </div>
104
+ <div class="toolbar">
105
+ <label>Model<br><select id="model"></select></label>
106
+ <label>Layer<br><select id="layer"></select></label>
107
+ <label style="flex:1;min-width:220px">Search<br><input id="search" type="search" placeholder="component id or label" /></label>
108
+ <button id="refresh">Refresh</button>
109
+ <span id="status" class="status"></span>
110
+ </div>
111
+ </header>
112
+ <main>
113
+ <aside>
114
+ <div class="muted" id="count">Loading...</div>
115
+ <div id="components" class="component-list"></div>
116
+ </aside>
117
+ <section class="panel">
118
+ <div class="panel-title-row">
119
+ <h2 id="detailTitle">Examples</h2>
120
+ <div id="componentMetrics" class="component-metrics"></div>
121
+ </div>
122
+ <div id="tabs" class="tabs"></div>
123
+ <div id="examples" class="examples"><div class="empty">Choose a component.</div></div>
124
+ </section>
125
+ <section class="panel editor">
126
+ <div id="neighborCards" class="neighbor-row" hidden></div>
127
+ <h2>Manual Annotation</h2>
128
+ <div id="editorEmpty" class="empty">Choose a component to annotate.</div>
129
+ <form id="form" hidden>
130
+ <div id="positiveBlock" class="direction-block positive">
131
+ <h3>Positive direction</h3>
132
+ <div class="field"><label>Label<br><textarea id="positiveLabel" rows="2"></textarea></label></div>
133
+ <div class="field-grid">
134
+ <label>Confidence<br><select id="positiveConfidence"></select></label>
135
+ <label>Interpretation type<br><select id="positiveTypes"></select></label>
136
+ </div>
137
+ </div>
138
+ <div id="negativeBlock" class="direction-block negative">
139
+ <h3>Negative direction</h3>
140
+ <div class="field"><label>Label<br><textarea id="negativeLabel" rows="2"></textarea></label></div>
141
+ <div class="field-grid">
142
+ <label>Confidence<br><select id="negativeConfidence"></select></label>
143
+ <label>Interpretation type<br><select id="negativeTypes"></select></label>
144
+ </div>
145
+ </div>
146
+ <div class="field"><label>Summary<br><textarea id="summary" rows="3"></textarea></label></div>
147
+ <div class="field"><label>Notes<br><textarea id="notes" rows="4"></textarea></label></div>
148
+ <div class="field"><label style="display:flex;gap:6px;align-items:center;color:var(--text);font-weight:600"><input id="caseStudy" type="checkbox"> include as case study</label></div>
149
+ <button class="primary" type="submit">Save annotation</button>
150
+ </form>
151
+ </section>
152
+ </main>
153
+ <script>
154
+ const TYPES = ["Form", "Word", "Phrase", "Sentence", "Long-Range Context", "Global", "Position", "Sophisticated"];
155
+ const CONF = ["high", "medium", "low", "unclear"];
156
+ const state = { model:"", layer:"", components:[], selected:null, examples:null, region:"", annotation:null, neighbors:null };
157
+ const el = Object.fromEntries(["model","layer","search","refresh","status","count","components","detailTitle","componentMetrics","tabs","examples","neighborCards","editorEmpty","form","positiveBlock","negativeBlock","positiveLabel","positiveConfidence","negativeLabel","negativeConfidence","positiveTypes","negativeTypes","summary","notes","caseStudy"].map(id=>[id,document.getElementById(id)]));
158
+ async function api(path, options){ const r=await fetch(path, options); if(!r.ok){let msg=await r.text(); try{msg=JSON.parse(msg).detail||msg}catch{} throw new Error(msg)} return r.json(); }
159
+ function esc(s){return String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));}
160
+ function scoreClass(v){return Number(v)<0?'score neg':'score';}
161
+ function setStatus(s){el.status.textContent=s||"";}
162
+ function fillSelect(select, values, current){select.innerHTML=values.map(v=>`<option value="${esc(v)}" ${v===current?'selected':''}>${esc(v)}</option>`).join('');}
163
+ function fillConfidence(){ for(const id of ['positiveConfidence','negativeConfidence']) fillSelect(el[id], CONF, 'unclear'); }
164
+ function fillTypes(){ for(const id of ['positiveTypes','negativeTypes']) fillSelect(el[id], ["", ...TYPES], ""); el.positiveTypes.options[0].textContent="none"; el.negativeTypes.options[0].textContent="none"; }
165
+ function selectedTypes(select){ return select.value ? [select.value] : []; }
166
+ function setTypes(select, values){ const first=(values||[]).find(Boolean)||""; select.value=first; }
167
+ async function init(){ fillConfidence(); fillTypes(); const params=new URLSearchParams(location.search); state.targetComponent=Number(params.get('component')); const requestedModel=params.get('model')||'gpt2'; const requestedLayer=params.get('layer')||''; const models=await api('/api/models'); fillSelect(el.model, models.models.map(m=>m.model_name), requestedModel); state.model=el.model.value; state.layer=requestedLayer; await loadLayers({component:Number.isFinite(state.targetComponent)?state.targetComponent:null}); bind(); }
168
+ function bind(){ el.model.onchange=async()=>{state.model=el.model.value; state.targetComponent=null; await loadLayers();}; el.layer.onchange=async()=>{state.layer=el.layer.value; state.targetComponent=null; await loadComponents();}; el.search.oninput=()=>renderComponents(); el.refresh.onclick=loadComponents; el.form.onsubmit=saveAnnotation; el.positiveConfidence.onchange=updateLabelConfidenceStyles; el.negativeConfidence.onchange=updateLabelConfidenceStyles; el.positiveBlock.onclick=()=>setActiveDirection('positive'); el.negativeBlock.onclick=()=>setActiveDirection('negative'); el.positiveBlock.onfocusin=()=>setActiveDirection('positive'); el.negativeBlock.onfocusin=()=>setActiveDirection('negative'); }
169
+ async function loadLayers(options={}){ const out=await api(`/api/layers?model=${encodeURIComponent(state.model)}`); fillSelect(el.layer,out.layers,out.layers.includes(state.layer)?state.layer:out.layers[0]); state.layer=el.layer.value; await loadComponents(options); }
170
+ async function loadComponents(options={}){ setStatus('Loading components...'); const out=await api(`/api/components?model=${encodeURIComponent(state.model)}&layer=${encodeURIComponent(state.layer)}`); state.components=out.components; state.selected=null; renderComponents(); clearDetail(); setStatus(''); const component=options.component; if(Number.isFinite(component) && state.components.some(c=>c.component===component)) await selectComponent(component); }
171
+ function renderComponents(){ const q=el.search.value.trim().toLowerCase(); const items=state.components.filter(c=>!q||String(c.component).includes(q)||[c.positive_label,c.negative_label,c.summary,c.notes].join(' ').toLowerCase().includes(q)); el.count.textContent=`${items.length} components`; el.components.innerHTML=items.map(c=>`<button class="component-button ${state.selected&&state.selected.component===c.component?'active':''}" data-c="${c.component}"><div class="component-title"><span>${esc(c.layer)} / ${c.component}</span><span class="pill">k=${c.excess_kurtosis==null?'?':Number(c.excess_kurtosis).toFixed(1)}</span></div><div class="label-line">+ ${esc(c.positive_label||'')} </div><div class="label-line">- ${esc(c.negative_label||'')}</div></button>`).join('') || '<div class="empty">No components.</div>'; el.components.querySelectorAll('button[data-c]').forEach(b=>b.onclick=()=>selectComponent(Number(b.dataset.c))); }
172
+ async function selectComponent(component){ state.selected=state.components.find(c=>c.component===component); renderComponents(); setStatus('Loading examples...'); const q=new URLSearchParams({model:state.model,layer:state.layer,component:String(component)}); const [examples, annotation, neighbors]=await Promise.all([api(`/api/examples/component?${q}`), api(`/api/annotations/component?${q}`), api(`/api/component-neighbors?${q}`)]); state.examples=examples; state.annotation=annotation; state.neighbors=neighbors; state.region=examples.default_region||examples.regions[0]||''; renderExamples(); renderEditor(); setStatus(''); }
173
+ function clearDetail(){ el.detailTitle.textContent='Examples'; el.componentMetrics.innerHTML=''; el.tabs.innerHTML=''; el.examples.innerHTML='<div class="empty">Choose a component.</div>'; el.neighborCards.hidden=true; el.neighborCards.innerHTML=''; el.form.hidden=true; el.editorEmpty.hidden=false; setActiveDirection(null); }
174
+ function renderExamples(){ const c=state.selected; el.detailTitle.textContent=`${state.model} ${state.layer} / ${c.component}`; renderComponentMetrics(c); el.tabs.innerHTML=(state.examples.regions||[]).map(r=>`<button class="${r===state.region?'active':''}" data-r="${esc(r)}">${esc(r)}</button>`).join(''); el.tabs.querySelectorAll('button').forEach(b=>b.onclick=()=>{state.region=b.dataset.r; renderExamples();}); const items=(state.examples.examples_by_region||{})[state.region]||[]; el.examples.innerHTML=items.map(renderExample).join('') || '<div class="empty">No examples in this band.</div>'; }
175
+ function renderExample(x){ return `<article class="example"><div class="example-head"><span class="example-meta"><span>#${esc(x.rank)}</span>${docPositionLink(x)}<span class="token">${esc(visibleToken(x.token))}</span></span><span class="${scoreClass(x.source_score)}">${Number(x.source_score||0).toFixed(3)}</span></div><div class="context">${renderContext(x)}</div></article>`; }
176
+ function docPositionLink(x){ if(x.doc_id==null||Number(x.doc_id)<0) return x.position==null?'':`<span>pos ${esc(x.position)}</span>`; const q=new URLSearchParams({model:state.model,doc_id:String(x.doc_id)}); if(x.position!=null) q.set('position',String(x.position)); const target=visibleToken(x.token); if(target) q.set('target',target); const text=`doc ${x.doc_id}${x.position==null?'':` · pos ${x.position}`}`; return `<a class="doc-link" href="/context?${esc(q.toString())}" target="_blank" rel="noopener" title="Open full document context">${esc(text)}</a>`; }
177
+ function renderContext(x){ const tokens=Array.isArray(x.context_token_scores)?x.context_token_scores:[]; if(!tokens.length) return esc(x.context_to_target||x.context||''); const maxAbs=Math.max(1e-9, Number(x.context_score_max_abs||0), ...tokens.map(t=>Math.abs(Number(t.source_score||0)))); return tokens.map(t=>{ const score=Number(t.source_score||0); const alpha=tokenAlpha(score,maxAbs); const color=alpha===0?'transparent':score>0?`rgba(220,38,38,${alpha})`:`rgba(37,99,235,${alpha})`; const title=`score=${Number.isFinite(score)?score.toFixed(4):'n/a'}`; return `<span class="context-token ${t.is_target?'target':''}" style="background:${color}" title="${esc(title)}">${esc(visibleContextToken(t.token))}</span>`; }).join(''); }
178
+ function tokenAlpha(score,maxAbs){ const magnitude=Math.abs(Number(score||0)); if(magnitude<1e-6) return 0; return Math.min(0.55,0.47*Math.log1p(magnitude)/Math.log1p(maxAbs)); }
179
+ function visibleToken(value){ const text=String(value||''); if(text===' ') return '[space]'; if(text==='\n') return '[newline]'; if(text==='\f') return '\\f'; return text.replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t').replace(/\f/g,'\\f'); }
180
+ function visibleContextToken(value){ return String(value||'').replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t').replace(/\f/g,'\\f'); }
181
+ function renderComponentMetrics(component){ const parts=[]; const erf=Number(component.effective_context_mean); const k=Number(component.excess_kurtosis); if(Number.isFinite(erf)) parts.push(`<span class="metric-badge" title="Effective Receptive Field (ERF): estimated mean number of token positions in the local context that materially affect this component.">ERF=${erf.toFixed(1)}</span>`); if(Number.isFinite(k)) parts.push(`<span class="metric-badge" title="Kurtosis (K): excess kurtosis of this component's source scores over the sampled activation rows; larger values indicate a heavier-tailed direction.">K=${k.toFixed(1)}</span>`); el.componentMetrics.innerHTML=parts.join(''); }
182
+ function renderEditor(){ const a=state.annotation; el.form.hidden=false; el.editorEmpty.hidden=true; el.positiveLabel.value=a.positive_label||''; el.positiveConfidence.value=a.positive_confidence||'unclear'; el.negativeLabel.value=a.negative_label||''; el.negativeConfidence.value=a.negative_confidence||'unclear'; el.summary.value=a.summary||''; el.notes.value=a.notes||''; el.caseStudy.checked=!!a.include_as_case_study; setTypes(el.positiveTypes,a.positive_interpretation_types||[]); setTypes(el.negativeTypes,a.negative_interpretation_types||[]); updateLabelConfidenceStyles(); const direction=topAbsDirection(); setActiveDirection(direction); renderNeighborCards(direction); }
183
+ function topAbsDirection(){ const top=(state.examples?.examples_by_region?.top_abs||[])[0]; const score=Number(top?.source_score); if(!Number.isFinite(score) || score===0) return null; return score>0?'positive':'negative'; }
184
+ function setActiveDirection(direction){ el.positiveBlock.classList.toggle('active-direction', direction==='positive'); el.negativeBlock.classList.toggle('active-direction', direction==='negative'); }
185
+ function renderNeighborCards(sourceDirection){ const rows=state.neighbors?.neighbors||[]; const byDirection=new Map(rows.map(n=>[n.direction,n])); const sourceLabel=sourceDirection==='negative'?visibleAnnotationLabel(state.annotation?.negative_label,state.annotation?.negative_confidence):visibleAnnotationLabel(state.annotation?.positive_label,state.annotation?.positive_confidence); el.neighborCards.hidden=false; el.neighborCards.innerHTML=`${neighborCard(byDirection.get('prev'),sourceDirection,sourceLabel)}${neighborCard(byDirection.get('next'),sourceDirection,sourceLabel)}`; el.neighborCards.querySelectorAll('.neighbor-copy').forEach(b=>b.onclick=()=>copyNeighborAnnotation(b.dataset.direction)); }
186
+ function neighborCard(n, sourceDirection, sourceLabel){ if(!n) return '<div class="neighbor-card missing" aria-hidden="true"></div>'; const layer=String(n.neighbor_layer||''); const component=Number(n.neighbor_component); const label=neighborLabel(n,sourceDirection,sourceLabel); const cos=Number(n.abs_cosine); const q=new URLSearchParams({model:state.model,layer,component:String(component)}); return `<div class="neighbor-card" title="${esc(layer)} C${component}"><div class="neighbor-head"><a class="neighbor-link" href="/annotate?${esc(q.toString())}"><span class="neighbor-target">${esc(layer)} C${component}</span></a><button class="neighbor-copy" type="button" data-direction="${esc(n.direction)}" title="Copy this neighbor annotation into the active direction">Copy</button></div><span class="neighbor-label">${esc(label)}</span><span class="neighbor-metrics">${neighborMetrics(n)}</span><span class="neighbor-cos" style="--cos-width:${cosWidth(cos)}%">cos=${Number.isFinite(cos)?cos.toFixed(3):'?'}</span></div>`; }
187
+ function neighborLabel(n, sourceDirection, sourceLabel){ return neighborAnnotationSide(n,sourceDirection,sourceLabel).label || 'unlabeled'; }
188
+ function neighborAnnotationSide(n, sourceDirection, sourceLabel){ const positive=visibleAnnotationLabel(n.positive_label,n.positive_confidence); const negative=visibleAnnotationLabel(n.negative_label,n.negative_confidence); const pm=labelSimilarity(sourceLabel,positive); const nm=labelSimilarity(sourceLabel,negative); if(pm>nm&&pm>0) return {side:'positive', label:positive, confidence:n.positive_confidence||'unclear', types:n.positive_types||[]}; if(nm>pm&&nm>0) return {side:'negative', label:negative, confidence:n.negative_confidence||'unclear', types:n.negative_types||[]}; const sourceSign=sourceDirection==='negative'?-1:1; const neighborSign=Number(n.neighbor_sign)<0?-1:1; if(sourceSign*neighborSign<0) return {side:'negative', label:negative, confidence:n.negative_confidence||'unclear', types:n.negative_types||[]}; return {side:'positive', label:positive, confidence:n.positive_confidence||'unclear', types:n.positive_types||[]}; }
189
+ function copyNeighborAnnotation(direction){ const n=(state.neighbors?.neighbors||[]).find(x=>x.direction===direction); if(!n) return; const active=currentAnnotationDirection(); const sourceLabel=active==='negative'?visibleAnnotationLabel(state.annotation?.negative_label,state.annotation?.negative_confidence):visibleAnnotationLabel(state.annotation?.positive_label,state.annotation?.positive_confidence); const picked=neighborAnnotationSide(n,active,sourceLabel); if(active==='negative'){ el.negativeLabel.value=picked.label||''; el.negativeConfidence.value=picked.confidence||'unclear'; setTypes(el.negativeTypes,picked.types||[]); } else { el.positiveLabel.value=picked.label||''; el.positiveConfidence.value=picked.confidence||'unclear'; setTypes(el.positiveTypes,picked.types||[]); } updateLabelConfidenceStyles(); setActiveDirection(active); setStatus(`Copied ${direction} neighbor into ${active} direction.`); }
190
+ function currentAnnotationDirection(){ if(el.negativeBlock.classList.contains('active-direction')) return 'negative'; if(el.positiveBlock.classList.contains('active-direction')) return 'positive'; return topAbsDirection()||'positive'; }
191
+ function visibleAnnotationLabel(value, confidence){ const text=String(value||'').trim(); if(!text) return ''; if(text==='?' && String(confidence||'unclear')==='unclear') return ''; return text.replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t'); }
192
+ function labelSimilarity(a,b){ const aa=labelTokens(a); const bb=labelTokens(b); if(!aa.size||!bb.size) return 0; let overlap=0; aa.forEach(t=>{if(bb.has(t)) overlap+=1;}); return overlap/Math.max(aa.size,bb.size); }
193
+ function labelTokens(value){ const text=String(value||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim(); if(!text||text==='?') return new Set(); return new Set(text.split(/\s+/).filter(Boolean)); }
194
+ function cosWidth(value){ const cos=Math.max(0,Math.min(1,Number(value)||0)); return (100*cos).toFixed(1); }
195
+ function neighborMetrics(n){ const parts=[]; const erf=Number(n?.effective_context_mean); const k=Number(n?.excess_kurtosis); if(Number.isFinite(erf)) parts.push(`ERF=${erf.toFixed(1)}`); if(Number.isFinite(k)) parts.push(`K=${k.toFixed(1)}`); return esc(parts.join(' · ')); }
196
+ function updateLabelConfidenceStyles(){ setLabelConfidenceStyle(el.positiveLabel, el.positiveConfidence.value); setLabelConfidenceStyle(el.negativeLabel, el.negativeConfidence.value); }
197
+ function setLabelConfidenceStyle(node, confidence){ node.classList.remove('label-confidence-high','label-confidence-medium','label-confidence-low','label-confidence-unclear'); node.classList.add(`label-confidence-${CONF.includes(confidence)?confidence:'unclear'}`); }
198
+ async function saveAnnotation(ev){
199
+ ev.preventDefault();
200
+ if(!state.selected) return;
201
+ const component=state.selected.component;
202
+ const body={model_name:state.model, layer:state.layer, component, positive_label:el.positiveLabel.value, positive_confidence:el.positiveConfidence.value, positive_interpretation_types:selectedTypes(el.positiveTypes), negative_label:el.negativeLabel.value, negative_confidence:el.negativeConfidence.value, negative_interpretation_types:selectedTypes(el.negativeTypes), summary:el.summary.value, notes:el.notes.value, include_as_case_study:el.caseStudy.checked};
203
+ setStatus('Saving...');
204
+ await api('/api/annotations/component',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
205
+ const q=new URLSearchParams({model:state.model,layer:state.layer,component:String(component)});
206
+ const [annotation, components]=await Promise.all([
207
+ api(`/api/annotations/component?${q}`),
208
+ api(`/api/components?model=${encodeURIComponent(state.model)}&layer=${encodeURIComponent(state.layer)}`),
209
+ ]);
210
+ state.annotation=annotation;
211
+ state.components=components.components;
212
+ state.selected=state.components.find(c=>c.component===component) || state.selected;
213
+ renderComponents();
214
+ renderEditor();
215
+ setStatus('Saved.');
216
+ }
217
+ init().catch(e=>{setStatus(e.message); el.components.innerHTML=`<div class="error">${esc(e.message)}</div>`});
218
+ </script>
219
+ </body>
220
+ </html>
server/static/component.html ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Component Examples - GPT-2 Explorer</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f7f9;
10
+ --panel: #fff;
11
+ --text: #151922;
12
+ --muted: #647084;
13
+ --border: #cbd3df;
14
+ --accent: #1f6feb;
15
+ --positive: #b91c1c;
16
+ --negative: #1d4ed8;
17
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
18
+ --shadow: 0 1px 2px rgb(20 25 34 / .08), 0 10px 30px rgb(20 25 34 / .06);
19
+ }
20
+ * { box-sizing: border-box; }
21
+ body {
22
+ margin: 0;
23
+ background: var(--bg);
24
+ color: var(--text);
25
+ font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
+ }
27
+ header {
28
+ position: sticky;
29
+ top: 0;
30
+ z-index: 5;
31
+ background: var(--panel);
32
+ border-bottom: 1px solid var(--border);
33
+ box-shadow: var(--shadow);
34
+ padding: 12px 18px;
35
+ }
36
+ .top {
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ gap: 14px;
41
+ }
42
+ h1 { margin: 0; font-size: 19px; letter-spacing: 0; }
43
+ .nav {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 14px;
47
+ }
48
+ a {
49
+ color: var(--accent);
50
+ font-weight: 750;
51
+ text-decoration: none;
52
+ }
53
+ a:hover { text-decoration: underline; }
54
+ main {
55
+ width: 100%;
56
+ margin: 0;
57
+ padding: 12px;
58
+ }
59
+ .summary {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ align-items: center;
63
+ gap: 8px 14px;
64
+ margin-bottom: 12px;
65
+ color: var(--muted);
66
+ font-size: 12px;
67
+ font-weight: 750;
68
+ }
69
+ .band {
70
+ margin-bottom: 14px;
71
+ background: var(--panel);
72
+ border: 1px solid var(--border);
73
+ border-radius: 8px;
74
+ box-shadow: var(--shadow);
75
+ overflow: hidden;
76
+ }
77
+ .band-toggle {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ gap: 12px;
82
+ width: 100%;
83
+ margin: 0;
84
+ padding: 9px 12px;
85
+ border-bottom: 1px solid #e5eaf2;
86
+ border-left: 0;
87
+ border-right: 0;
88
+ border-top: 0;
89
+ font-size: 14px;
90
+ font-weight: 850;
91
+ letter-spacing: 0;
92
+ background: #fbfcfe;
93
+ color: var(--text);
94
+ text-align: left;
95
+ cursor: pointer;
96
+ font-family: inherit;
97
+ }
98
+ .band-toggle:hover { background: #f1f5f9; }
99
+ .band-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
100
+ .band-title { min-width: 0; overflow-wrap: anywhere; }
101
+ .band-state { color: var(--muted); font-size: 12px; white-space: nowrap; }
102
+ .band.collapsed .examples { display: none; }
103
+ .examples {
104
+ display: grid;
105
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
106
+ gap: 0;
107
+ }
108
+ .example {
109
+ min-width: 0;
110
+ border-right: 1px solid #e5eaf2;
111
+ border-bottom: 1px solid #e5eaf2;
112
+ padding: 10px 12px;
113
+ }
114
+ .example-head {
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: space-between;
118
+ gap: 10px;
119
+ margin-bottom: 6px;
120
+ color: var(--muted);
121
+ font-size: 12px;
122
+ font-weight: 750;
123
+ }
124
+ .example-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
125
+ .doc-link { color: var(--accent); text-decoration: none; font-weight: 850; }
126
+ .doc-link:hover { text-decoration: underline; }
127
+ .score {
128
+ font-variant-numeric: tabular-nums;
129
+ white-space: nowrap;
130
+ }
131
+ .score.positive { color: var(--positive); }
132
+ .score.negative { color: var(--negative); }
133
+ .token {
134
+ display: inline-block;
135
+ margin-bottom: 6px;
136
+ padding: 2px 6px;
137
+ border: 1px solid var(--border);
138
+ border-radius: 5px;
139
+ background: #f8fafc;
140
+ font-weight: 850;
141
+ font-family: var(--mono);
142
+ font-size: 12px;
143
+ overflow-wrap: anywhere;
144
+ }
145
+ .token.positive { color: var(--positive); border-color: #fecaca; background: #fff1f2; }
146
+ .token.negative { color: var(--negative); border-color: #bfdbfe; background: #eff6ff; }
147
+ .context {
148
+ margin: 0;
149
+ white-space: pre-wrap;
150
+ overflow-wrap: anywhere;
151
+ font-family: var(--mono);
152
+ font-size: 12px;
153
+ line-height: 1.55;
154
+ }
155
+ .context-token {
156
+ border-radius: 3px;
157
+ padding: 0 1px;
158
+ }
159
+ .context-token.target {
160
+ box-shadow: inset 0 -2px 0 currentColor;
161
+ font-weight: 850;
162
+ }
163
+ .context-token.positive { color: var(--positive); }
164
+ .context-token.negative { color: var(--negative); }
165
+ .empty, .error {
166
+ border: 1px dashed var(--border);
167
+ border-radius: 8px;
168
+ padding: 18px;
169
+ color: var(--muted);
170
+ background: #fff;
171
+ }
172
+ .error { color: #a41414; border-color: #f0b9b9; background: #fff7f7; }
173
+ @media (max-width: 760px) {
174
+ main { padding: 8px; }
175
+ header { padding: 10px 12px; }
176
+ .top { align-items: flex-start; flex-direction: column; }
177
+ .examples { grid-template-columns: 1fr; }
178
+ }
179
+ </style>
180
+ </head>
181
+ <body>
182
+ <header>
183
+ <div class="top">
184
+ <h1 id="title">Component Examples</h1>
185
+ <nav class="nav" aria-label="Primary">
186
+ <a href="/">Explorer</a>
187
+ <a href="/sae-explorer">SAE Explorer</a>
188
+ <a href="/stats">Stats</a>
189
+ <a href="/annotate">Annotate</a>
190
+ <a href="/random-components">Random</a>
191
+ </nav>
192
+ </div>
193
+ </header>
194
+
195
+ <main>
196
+ <div id="summary" class="summary">Loading component examples...</div>
197
+ <div id="message" class="empty">Loading...</div>
198
+ <div id="content" hidden></div>
199
+ </main>
200
+
201
+ <script>
202
+ const els = {
203
+ title: document.getElementById("title"),
204
+ summary: document.getElementById("summary"),
205
+ message: document.getElementById("message"),
206
+ content: document.getElementById("content"),
207
+ };
208
+
209
+ async function api(path) {
210
+ const res = await fetch(path, { headers: { "content-type": "application/json" } });
211
+ if (!res.ok) {
212
+ let detail = res.statusText;
213
+ try { detail = (await res.json()).detail || detail; } catch {}
214
+ throw new Error(detail);
215
+ }
216
+ return res.json();
217
+ }
218
+
219
+ async function init() {
220
+ const params = new URLSearchParams(location.search);
221
+ const model = params.get("model") || "gpt2";
222
+ const layer = params.get("layer") || "";
223
+ const component = params.get("component") || "";
224
+ if (!layer || !component) {
225
+ showError("Missing layer or component.");
226
+ return;
227
+ }
228
+ try {
229
+ const query = new URLSearchParams({ model, layer, component });
230
+ const data = await api(`/api/component-examples?${query.toString()}`);
231
+ render(data);
232
+ } catch (err) {
233
+ showError(err.message);
234
+ }
235
+ }
236
+
237
+ function render(data) {
238
+ const bands = data.bands || [];
239
+ const total = bands.reduce((count, band) => count + (band.examples || []).length, 0);
240
+ const kurtosis = Number.isFinite(Number(data.excess_kurtosis)) ? `kurtosis ${Number(data.excess_kurtosis).toFixed(3)}` : "kurtosis n/a";
241
+ els.title.textContent = `${data.layer} C${data.component}`;
242
+ els.summary.textContent = `${data.model_name} - ${data.layer} component ${data.component} - ${kurtosis} - ${bands.length} bands - ${total} examples`;
243
+ if (!bands.length) {
244
+ els.message.hidden = false;
245
+ els.message.className = "empty";
246
+ els.message.textContent = "No examples found for this component.";
247
+ els.content.hidden = true;
248
+ return;
249
+ }
250
+ els.message.hidden = true;
251
+ els.content.hidden = false;
252
+ els.content.innerHTML = bands.map(renderBand).join("");
253
+ wireBandToggles();
254
+ }
255
+
256
+ function renderBand(band) {
257
+ const examples = band.examples || [];
258
+ const collapsed = shouldCollapseBand(band.region);
259
+ return `
260
+ <section class="band ${collapsed ? "collapsed" : ""}">
261
+ <button class="band-toggle" type="button" aria-expanded="${collapsed ? "false" : "true"}">
262
+ <span class="band-title">${escapeHtml(band.region)}</span>
263
+ <span class="band-state">${collapsed ? "show" : "hide"}</span>
264
+ </button>
265
+ <div class="examples">${examples.map(renderExample).join("")}</div>
266
+ </section>
267
+ `;
268
+ }
269
+
270
+ function wireBandToggles() {
271
+ els.content.querySelectorAll(".band-toggle").forEach(button => {
272
+ button.addEventListener("click", () => {
273
+ const band = button.closest(".band");
274
+ const collapsed = band.classList.toggle("collapsed");
275
+ button.setAttribute("aria-expanded", String(!collapsed));
276
+ const state = button.querySelector(".band-state");
277
+ if (state) state.textContent = collapsed ? "show" : "hide";
278
+ });
279
+ });
280
+ }
281
+
282
+ function shouldCollapseBand(region) {
283
+ const name = String(region || "").toLowerCase().replace(/[_-]+/g, " ");
284
+ return name.includes("opposite") || name.includes("near zero") || name.includes("nearzero");
285
+ }
286
+
287
+ function renderExample(example) {
288
+ const score = Number(example.source_score);
289
+ const scoreClass = score > 0 ? "positive" : score < 0 ? "negative" : "";
290
+ return `
291
+ <article class="example">
292
+ <div class="example-head">
293
+ <span class="example-meta"><span>#${escapeHtml(example.rank)}</span>${docPositionLink(example)}</span>
294
+ <span class="score ${scoreClass}">${formatScore(score)}</span>
295
+ </div>
296
+ <div class="token ${scoreClass}">${escapeHtml(visibleToken(example.token))}</div>
297
+ <p class="context">${renderContext(example)}</p>
298
+ </article>
299
+ `;
300
+ }
301
+
302
+ function docPositionLink(example) {
303
+ if (example.doc_id == null || Number(example.doc_id) < 0) {
304
+ return example.position == null ? "" : `<span>pos ${escapeHtml(example.position)}</span>`;
305
+ }
306
+ const query = new URLSearchParams({ model: state.model, doc_id: String(example.doc_id) });
307
+ if (example.position != null) query.set("position", String(example.position));
308
+ const target = visibleToken(example.token);
309
+ if (target) query.set("target", target);
310
+ const text = `doc ${example.doc_id}${example.position == null ? "" : ` · pos ${example.position}`}`;
311
+ return `<a class="doc-link" href="/context?${escapeAttr(query.toString())}" target="_blank" rel="noopener" title="Open full document context">${escapeHtml(text)}</a>`;
312
+ }
313
+
314
+ function renderContext(example) {
315
+ const tokens = Array.isArray(example.context_token_scores) ? example.context_token_scores : [];
316
+ if (!tokens.length) return escapeHtml(example.context_to_target || example.context || "");
317
+ const maxAbs = Math.max(
318
+ 1e-9,
319
+ Number(example.context_score_max_abs || 0),
320
+ ...tokens.map(token => Math.abs(Number(token.source_score || 0)))
321
+ );
322
+ return tokens.map(token => {
323
+ const score = Number(token.source_score || 0);
324
+ const alpha = tokenAlpha(score, maxAbs);
325
+ const color = alpha === 0 ? "transparent" : score > 0 ? `rgba(220,38,38,${alpha})` : `rgba(37,99,235,${alpha})`;
326
+ const signClass = score > 0 ? "positive" : score < 0 ? "negative" : "";
327
+ const title = `score=${formatScore(score)}`;
328
+ return `<span class="context-token ${token.is_target ? "target" : ""} ${signClass}" style="background:${color}" title="${escapeAttr(title)}">${escapeHtml(visibleContextToken(token.token))}</span>`;
329
+ }).join("");
330
+ }
331
+
332
+ function tokenAlpha(score, maxAbs) {
333
+ const magnitude = Math.abs(Number(score || 0));
334
+ if (magnitude < 1e-6) return 0;
335
+ return Math.min(0.55, 0.47 * Math.log1p(magnitude) / Math.log1p(maxAbs));
336
+ }
337
+
338
+ function visibleToken(value) {
339
+ const text = String(value || "");
340
+ if (text === " ") return "[space]";
341
+ if (text === "\n") return "[newline]";
342
+ if (text === "\f") return "\\f";
343
+ return text.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\f/g, "\\f");
344
+ }
345
+
346
+ function visibleContextToken(value) {
347
+ return String(value || "").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\f/g, "\\f");
348
+ }
349
+
350
+ function formatScore(value) {
351
+ return Number.isFinite(value) ? value.toFixed(4).replace(/0+$/, "").replace(/\.$/, "") : "n/a";
352
+ }
353
+
354
+ function showError(message) {
355
+ els.summary.textContent = "";
356
+ els.message.hidden = false;
357
+ els.message.className = "error";
358
+ els.message.textContent = message;
359
+ els.content.hidden = true;
360
+ }
361
+
362
+ function escapeHtml(value) {
363
+ return String(value).replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
364
+ }
365
+
366
+ function escapeAttr(value) {
367
+ return escapeHtml(value);
368
+ }
369
+
370
+ init();
371
+ </script>
372
+ </body>
373
+ </html>
server/static/index.html ADDED
@@ -0,0 +1,1546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ICA Explorer</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f7f9;
10
+ --panel: #fff;
11
+ --text: #151922;
12
+ --muted: #647084;
13
+ --border: #cbd3df;
14
+ --accent: #1f6feb;
15
+ --hot: #b42318;
16
+ --cold: #1e5bb8;
17
+ --good: #087443;
18
+ --warn: #a15c00;
19
+ --shadow: 0 1px 2px rgb(20 25 34 / .08), 0 10px 30px rgb(20 25 34 / .06);
20
+ --token-card-width: 140px;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body {
24
+ margin: 0;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
28
+ }
29
+ header {
30
+ position: sticky;
31
+ top: 0;
32
+ z-index: 5;
33
+ background: var(--panel);
34
+ border-bottom: 1px solid var(--border);
35
+ box-shadow: var(--shadow);
36
+ padding: 12px 18px;
37
+ }
38
+ .top {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 14px;
43
+ }
44
+ .nav {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 14px;
48
+ }
49
+ .nav a {
50
+ color: var(--accent);
51
+ font-weight: 750;
52
+ text-decoration: none;
53
+ }
54
+ .nav a:hover { text-decoration: underline; }
55
+ h1 { margin: 0; font-size: 19px; letter-spacing: 0; }
56
+ main {
57
+ max-width: 1240px;
58
+ margin: 0 auto;
59
+ padding: 18px;
60
+ }
61
+ footer {
62
+ max-width: 1240px;
63
+ margin: 0 auto;
64
+ padding: 0 18px 22px;
65
+ color: var(--muted);
66
+ font-size: 12px;
67
+ }
68
+ footer a {
69
+ color: var(--accent);
70
+ font-weight: 750;
71
+ text-decoration: none;
72
+ }
73
+ footer a:hover { text-decoration: underline; }
74
+ .controls {
75
+ display: grid;
76
+ grid-template-columns: minmax(0, 1fr) 450px;
77
+ gap: 12px;
78
+ align-items: stretch;
79
+ }
80
+ .control-stack {
81
+ display: flex;
82
+ flex-wrap: wrap;
83
+ align-content: flex-start;
84
+ align-items: center;
85
+ gap: 10px;
86
+ }
87
+ .inline-control {
88
+ display: grid;
89
+ grid-template-columns: max-content max-content;
90
+ align-items: center;
91
+ column-gap: 8px;
92
+ }
93
+ .inline-control > span { white-space: nowrap; }
94
+ .model-control { flex: 0 0 auto; }
95
+ .layer-control { flex: 0 0 auto; }
96
+ .control-break { flex-basis: 100%; height: 0; }
97
+ .topk-control,
98
+ .card-width-control,
99
+ .opacity-control { flex: 0 0 auto; }
100
+ .run-button {
101
+ width: auto;
102
+ min-height: 32px;
103
+ padding: 6px 12px;
104
+ border-color: #1458c8;
105
+ background: var(--accent);
106
+ color: #fff;
107
+ font-weight: 850;
108
+ }
109
+ .run-button:hover { background: #1458c8; color: #fff; }
110
+ .memory-control {
111
+ position: fixed;
112
+ right: 12px;
113
+ bottom: 10px;
114
+ z-index: 4;
115
+ display: inline-flex;
116
+ align-items: center;
117
+ gap: 5px;
118
+ width: auto;
119
+ min-height: 24px;
120
+ padding: 3px 6px;
121
+ border: 1px solid #d7dee9;
122
+ border-radius: 6px;
123
+ background: rgb(255 255 255 / .78);
124
+ color: #94a3b8;
125
+ font-size: 11px;
126
+ font-weight: 650;
127
+ white-space: nowrap;
128
+ }
129
+ .memory-control input { width: auto; margin: 0; padding: 0; accent-color: #94a3b8; }
130
+ .memory-control:hover { color: #64748b; border-color: #cbd5e1; background: #fff; }
131
+ #topK { width: 48px; min-width: 0; }
132
+ #cardWidth { width: 78px; min-width: 0; }
133
+ #weakRatio { width: 58px; min-width: 0; }
134
+ .model-control select,
135
+ .layer-control select { width: 100%; }
136
+ label {
137
+ display: grid;
138
+ gap: 5px;
139
+ color: #435066;
140
+ font-size: 12px;
141
+ font-weight: 700;
142
+ }
143
+ textarea, select, input, button {
144
+ width: 100%;
145
+ border: 1px solid var(--border);
146
+ border-radius: 7px;
147
+ background: #fff;
148
+ color: var(--text);
149
+ font: inherit;
150
+ padding: 8px 10px;
151
+ }
152
+ textarea {
153
+ height: 92px;
154
+ min-height: 92px;
155
+ resize: vertical;
156
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
157
+ }
158
+ button {
159
+ cursor: pointer;
160
+ min-height: 39px;
161
+ background: #eef2f7;
162
+ font-weight: 750;
163
+ }
164
+ button:hover { border-color: var(--accent); color: var(--accent); background: #edf5ff; }
165
+ button.primary { background: var(--accent); border-color: #1458c8; color: #fff; }
166
+ button.primary:hover { background: #1458c8; color: #fff; }
167
+ .panel {
168
+ margin-top: 14px;
169
+ background: var(--panel);
170
+ border: 1px solid var(--border);
171
+ border-radius: 8px;
172
+ box-shadow: var(--shadow);
173
+ padding: 12px;
174
+ }
175
+ .results {
176
+ display: grid;
177
+ grid-template-columns: repeat(auto-fill, minmax(var(--token-card-width), 1fr));
178
+ gap: 10px;
179
+ margin-top: 12px;
180
+ }
181
+ .token-card {
182
+ position: relative;
183
+ border: 1px solid var(--border);
184
+ border-radius: 8px;
185
+ padding: 6px;
186
+ background: #fff;
187
+ min-width: 0;
188
+ text-align: center;
189
+ }
190
+ .token-text {
191
+ min-height: 25px;
192
+ font-weight: 850;
193
+ text-align: center;
194
+ overflow-wrap: anywhere;
195
+ margin-bottom: 6px;
196
+ }
197
+ .score-row {
198
+ position: relative;
199
+ min-height: 24px;
200
+ margin-top: 3px;
201
+ }
202
+ .badge {
203
+ position: relative;
204
+ display: flex;
205
+ align-items: center;
206
+ width: 100%;
207
+ min-height: 24px;
208
+ padding: 3px 5px;
209
+ border: 1px solid #d5dce7;
210
+ border-radius: 5px;
211
+ background:
212
+ linear-gradient(
213
+ 90deg,
214
+ var(--score-bg, #edf2f7) 0,
215
+ var(--score-bg, #edf2f7) var(--score-width, 100%),
216
+ transparent var(--score-width, 100%),
217
+ transparent 100%
218
+ );
219
+ color: var(--text);
220
+ font-size: 10px;
221
+ font-weight: 400;
222
+ text-align: left;
223
+ cursor: pointer;
224
+ }
225
+ .badge:hover {
226
+ border-color: var(--component-color, var(--accent));
227
+ color: var(--text);
228
+ }
229
+ .badge.hot {
230
+ border-color: var(--component-color, var(--accent));
231
+ --score-bg: color-mix(in srgb, var(--component-color, var(--accent)) 16%, white);
232
+ box-shadow: inset 3px 0 0 var(--component-color, var(--accent));
233
+ }
234
+ .badge.hot:hover {
235
+ color: var(--text);
236
+ }
237
+ .badge.weak {
238
+ opacity: .1;
239
+ }
240
+ .badge.weak:hover,
241
+ .badge.weak.hot {
242
+ opacity: 1;
243
+ }
244
+ .badge-main {
245
+ display: inline-flex;
246
+ align-items: center;
247
+ gap: 3px;
248
+ min-width: 0;
249
+ max-width: calc(100% - 28px);
250
+ overflow: hidden;
251
+ white-space: nowrap;
252
+ text-overflow: ellipsis;
253
+ }
254
+ .badge-label {
255
+ min-width: 0;
256
+ overflow: hidden;
257
+ white-space: nowrap;
258
+ text-overflow: ellipsis;
259
+ }
260
+ .annotation-dot {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ justify-content: center;
264
+ min-width: 13px;
265
+ height: 13px;
266
+ border-radius: 4px;
267
+ flex: 0 0 auto;
268
+ background: #e5e7eb;
269
+ box-shadow: 0 0 0 1px rgb(255 255 255 / .85);
270
+ color: #fff;
271
+ font-size: 9px;
272
+ font-weight: 850;
273
+ line-height: 1;
274
+ }
275
+ .annotation-dot.high { background: #16a34a; box-shadow: 0 0 0 1px #15803d; }
276
+ .annotation-dot.medium { color: #166534; background: #fef3c7; box-shadow: 0 0 0 1px #d9b94e; }
277
+ .annotation-dot.low { color: #9f1239; background: #ffe4e6; box-shadow: 0 0 0 1px #f9a8d4; }
278
+ .annotation-dot.unclear { color: #475569; background: #e5e7eb; box-shadow: 0 0 0 1px #cbd5e1; }
279
+ .score {
280
+ position: absolute;
281
+ left: auto;
282
+ right: 5px;
283
+ top: 50%;
284
+ transform: translateY(-50%);
285
+ width: 28px;
286
+ text-align: right;
287
+ color: var(--muted);
288
+ font-size: 9px;
289
+ font-variant-numeric: tabular-nums;
290
+ pointer-events: none;
291
+ }
292
+ .prediction-row {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 5px;
296
+ min-height: 24px;
297
+ margin-top: 5px;
298
+ padding: 3px 5px;
299
+ border: 1px solid #d8e0eb;
300
+ border-radius: 5px;
301
+ background: #f6f8fb;
302
+ color: #314158;
303
+ font-size: 10px;
304
+ font-weight: 650;
305
+ text-align: left;
306
+ }
307
+ .prediction-label {
308
+ color: var(--muted);
309
+ font-weight: 850;
310
+ }
311
+ .prediction-token {
312
+ min-width: 0;
313
+ overflow: hidden;
314
+ text-overflow: ellipsis;
315
+ white-space: nowrap;
316
+ }
317
+ .empty, .error {
318
+ margin-top: 12px;
319
+ border: 1px dashed var(--border);
320
+ border-radius: 8px;
321
+ padding: 18px;
322
+ color: var(--muted);
323
+ background: #fff;
324
+ }
325
+ .error { color: #a41414; border-color: #f0b9b9; background: #fff7f7; }
326
+ .request-indicator {
327
+ position: fixed;
328
+ left: 14px;
329
+ top: 62px;
330
+ z-index: 8;
331
+ display: none;
332
+ align-items: center;
333
+ gap: 8px;
334
+ min-height: 34px;
335
+ padding: 8px 10px;
336
+ border: 1px solid var(--border);
337
+ border-radius: 8px;
338
+ background: var(--panel);
339
+ box-shadow: var(--shadow);
340
+ color: #435066;
341
+ font-size: 12px;
342
+ font-weight: 800;
343
+ }
344
+ .request-indicator.visible { display: inline-flex; }
345
+ .request-spinner {
346
+ width: 14px;
347
+ height: 14px;
348
+ border: 2px solid #cbd5e1;
349
+ border-top-color: var(--accent);
350
+ border-radius: 50%;
351
+ animation: request-spin .8s linear infinite;
352
+ }
353
+ @keyframes request-spin { to { transform: rotate(360deg); } }
354
+ .selection-panel {
355
+ position: fixed;
356
+ right: 14px;
357
+ top: 82px;
358
+ z-index: 6;
359
+ display: none;
360
+ width: 500px;
361
+ max-width: calc(100vw - 28px);
362
+ max-height: calc(100vh - 104px);
363
+ overflow: auto;
364
+ border: 1px solid var(--border);
365
+ border-radius: 8px;
366
+ background: var(--panel);
367
+ box-shadow: var(--shadow);
368
+ padding: 12px;
369
+ }
370
+ .selection-panel.visible { display: block; }
371
+ .selection-title {
372
+ display: flex;
373
+ align-items: center;
374
+ justify-content: space-between;
375
+ gap: 8px;
376
+ margin-bottom: 10px;
377
+ color: var(--muted);
378
+ font-size: 12px;
379
+ font-weight: 850;
380
+ }
381
+ .selection-close {
382
+ display: inline-flex;
383
+ align-items: center;
384
+ justify-content: center;
385
+ width: 18px;
386
+ height: 18px;
387
+ min-height: 18px;
388
+ padding: 0;
389
+ border-radius: 5px;
390
+ color: var(--muted);
391
+ font-size: 13px;
392
+ line-height: 1;
393
+ }
394
+ .selection-actions {
395
+ display: inline-flex;
396
+ align-items: center;
397
+ gap: 5px;
398
+ }
399
+ .selection-export-toggle {
400
+ display: none;
401
+ width: auto;
402
+ min-height: 18px;
403
+ padding: 2px 7px;
404
+ border-radius: 5px;
405
+ color: var(--muted);
406
+ font-size: 11px;
407
+ font-weight: 800;
408
+ line-height: 1.2;
409
+ }
410
+ .selection-export-toggle.visible { display: inline-flex; align-items: center; }
411
+ .selection-list {
412
+ display: flex;
413
+ flex-direction: column;
414
+ gap: 8px;
415
+ }
416
+ .selection-entry {
417
+ display: grid;
418
+ grid-template-columns: minmax(0, 1fr) 145px;
419
+ gap: 8px;
420
+ align-items: stretch;
421
+ }
422
+ .selection-link {
423
+ display: flex;
424
+ align-items: center;
425
+ justify-content: space-between;
426
+ gap: 8px;
427
+ min-height: 30px;
428
+ padding: 7px 10px 5px;
429
+ color: var(--text);
430
+ font-weight: 850;
431
+ text-decoration: none;
432
+ }
433
+ .selection-link:hover {
434
+ color: var(--accent);
435
+ text-decoration: none;
436
+ }
437
+ .selection-item {
438
+ border: 1px solid var(--component-color, var(--accent));
439
+ border-radius: 7px;
440
+ background: color-mix(in srgb, var(--component-color, var(--accent)) 5%, white);
441
+ overflow: hidden;
442
+ box-shadow: inset 3px 0 0 var(--component-color, var(--accent));
443
+ }
444
+ .selection-item .selection-link {
445
+ border: 0;
446
+ border-radius: 0;
447
+ background: transparent;
448
+ }
449
+ .selection-score {
450
+ color: var(--muted);
451
+ font-size: 11px;
452
+ font-weight: 800;
453
+ font-variant-numeric: tabular-nums;
454
+ }
455
+ .selection-token-stats {
456
+ display: flex;
457
+ flex-wrap: wrap;
458
+ gap: 4px;
459
+ padding: 0 10px 10px;
460
+ color: var(--muted);
461
+ font-size: 11px;
462
+ line-height: 1.25;
463
+ }
464
+ .selection-neighbors {
465
+ display: grid;
466
+ grid-template-rows: repeat(2, minmax(58px, auto));
467
+ gap: 6px;
468
+ }
469
+ .neighbor-card {
470
+ display: grid;
471
+ gap: 3px;
472
+ min-height: 58px;
473
+ padding: 7px;
474
+ border: 1px solid #d7dee9;
475
+ border-radius: 6px;
476
+ background: #fff;
477
+ color: var(--text);
478
+ text-decoration: none;
479
+ }
480
+ .neighbor-card:hover {
481
+ border-color: var(--accent);
482
+ color: var(--text);
483
+ text-decoration: none;
484
+ }
485
+ .neighbor-card.missing {
486
+ visibility: hidden;
487
+ }
488
+ .neighbor-card.loading {
489
+ color: var(--muted);
490
+ background: #f8fafc;
491
+ cursor: default;
492
+ }
493
+ .neighbor-target {
494
+ min-width: 0;
495
+ overflow: hidden;
496
+ white-space: nowrap;
497
+ text-overflow: ellipsis;
498
+ color: var(--muted);
499
+ font-size: 10px;
500
+ font-weight: 750;
501
+ }
502
+ .neighbor-label {
503
+ min-width: 0;
504
+ overflow: hidden;
505
+ white-space: nowrap;
506
+ text-overflow: ellipsis;
507
+ color: var(--text);
508
+ font-size: 13px;
509
+ font-weight: 850;
510
+ line-height: 1.2;
511
+ }
512
+ .neighbor-cos {
513
+ position: relative;
514
+ min-height: 18px;
515
+ padding: 2px 5px;
516
+ border: 1px solid #d7dee9;
517
+ border-radius: 5px;
518
+ background:
519
+ linear-gradient(
520
+ 90deg,
521
+ color-mix(in srgb, var(--accent) 18%, white) 0,
522
+ color-mix(in srgb, var(--accent) 18%, white) var(--cos-width, 0%),
523
+ transparent var(--cos-width, 0%),
524
+ transparent 100%
525
+ );
526
+ color: var(--muted);
527
+ font-size: 11px;
528
+ font-weight: 800;
529
+ font-variant-numeric: tabular-nums;
530
+ }
531
+ .neighbor-metrics {
532
+ color: var(--muted);
533
+ font-size: 10px;
534
+ font-weight: 500;
535
+ font-variant-numeric: tabular-nums;
536
+ }
537
+ .selection-token-chip {
538
+ display: inline-flex;
539
+ align-items: center;
540
+ gap: 4px;
541
+ min-height: 20px;
542
+ max-width: 100%;
543
+ padding: 2px 6px;
544
+ border: 1px solid #d7dee9;
545
+ border-radius: 999px;
546
+ background: #fff;
547
+ color: #435066;
548
+ font-size: var(--token-font-size, 11px);
549
+ }
550
+ .selection-token-text {
551
+ min-width: 0;
552
+ overflow: hidden;
553
+ text-overflow: ellipsis;
554
+ white-space: nowrap;
555
+ }
556
+ .selection-export {
557
+ position: fixed;
558
+ left: 14px;
559
+ bottom: 10px;
560
+ z-index: 4;
561
+ display: none;
562
+ width: min(520px, calc(100vw - 180px));
563
+ height: 150px;
564
+ border: 1px solid var(--border);
565
+ border-radius: 7px;
566
+ background: rgb(255 255 255 / .94);
567
+ box-shadow: var(--shadow);
568
+ color: #334155;
569
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
570
+ resize: vertical;
571
+ }
572
+ .selection-export.visible { display: block; }
573
+ .selection-export-copy {
574
+ position: fixed;
575
+ left: 14px;
576
+ bottom: 164px;
577
+ z-index: 5;
578
+ display: none;
579
+ width: auto;
580
+ min-height: 24px;
581
+ padding: 3px 8px;
582
+ border-radius: 6px;
583
+ color: #64748b;
584
+ background: rgb(255 255 255 / .92);
585
+ font-size: 11px;
586
+ font-weight: 800;
587
+ }
588
+ .selection-export-copy.visible { display: inline-flex; align-items: center; }
589
+ @media (max-width: 760px) {
590
+ main { padding: 12px; }
591
+ footer { padding: 0 12px 18px; }
592
+ .controls { grid-template-columns: 1fr; }
593
+ .control-stack { align-content: flex-start; }
594
+ .memory-control { right: 8px; bottom: 8px; }
595
+ .top { align-items: flex-start; flex-direction: column; }
596
+ .selection-panel {
597
+ left: 8px;
598
+ right: 8px;
599
+ top: auto;
600
+ bottom: 8px;
601
+ width: auto;
602
+ }
603
+ .selection-entry {
604
+ grid-template-columns: minmax(0, 1fr) 128px;
605
+ }
606
+ .selection-export {
607
+ left: 8px;
608
+ bottom: 40px;
609
+ width: calc(100vw - 16px);
610
+ height: 120px;
611
+ }
612
+ .selection-export-copy {
613
+ left: 8px;
614
+ bottom: 164px;
615
+ }
616
+ }
617
+ </style>
618
+ </head>
619
+ <body>
620
+ <header>
621
+ <div class="top">
622
+ <h1>ICA Explorer</h1>
623
+ <nav class="nav" aria-label="Primary">
624
+ <a href="/">Explorer</a>
625
+ <a href="/sae-explorer">SAE Explorer</a>
626
+ <a href="/stats">Stats</a>
627
+ <a href="/annotate">Annotate</a>
628
+ <a href="/random-components">Random</a>
629
+ </nav>
630
+ </div>
631
+ </header>
632
+
633
+ <main>
634
+ <div class="panel">
635
+ <div class="controls">
636
+ <label>
637
+ Text
638
+ <textarea id="probeText" spellcheck="false">Maya stopped at the bank before the trip, waiting in line to deposit a check and withdraw enough cash for the weekend.</textarea>
639
+ </label>
640
+ <div class="control-stack">
641
+ <label class="inline-control model-control">
642
+ <span>Model</span>
643
+ <select id="modelSelect"></select>
644
+ </label>
645
+ <label class="inline-control layer-control">
646
+ <span>Layer</span>
647
+ <select id="layerSelect"></select>
648
+ </label>
649
+ <span class="control-break" aria-hidden="true"></span>
650
+ <label class="inline-control topk-control">
651
+ <span>Top K</span>
652
+ <input id="topK" type="number" min="1" max="32" value="5" />
653
+ </label>
654
+ <label class="inline-control card-width-control">
655
+ <span>Card Width</span>
656
+ <input id="cardWidth" type="number" min="100" max="360" step="20" value="140" />
657
+ </label>
658
+ <label class="inline-control opacity-control">
659
+ <span>Opacity Cutoff</span>
660
+ <input id="weakRatio" type="number" min="0" max="1" step="0.05" value="0.5" />
661
+ </label>
662
+ <button id="runProbe" class="run-button" type="button">Run</button>
663
+ </div>
664
+ <label class="memory-control" title="Keep loaded models in VRAM when switching.">
665
+ <input id="keepModels" type="checkbox" checked />
666
+ <span>Cache LLMs in VRAM</span>
667
+ </label>
668
+ </div>
669
+ </div>
670
+
671
+ <div id="message" class="empty">Choose a layer and run the probe.</div>
672
+ <div id="results" class="results"></div>
673
+ </main>
674
+ <aside id="selectionPanel" class="selection-panel" aria-live="polite">
675
+ <div class="selection-title">
676
+ <span>Selected components</span>
677
+ <div class="selection-actions">
678
+ <button id="selectionExportToggle" class="selection-export-toggle" type="button">Hide text</button>
679
+ <button id="selectionClose" class="selection-close" type="button" title="Hide selected components" aria-label="Hide selected components">×</button>
680
+ </div>
681
+ </div>
682
+ <div id="selectionList" class="selection-list"></div>
683
+ </aside>
684
+ <div id="requestIndicator" class="request-indicator" role="status" aria-live="polite">
685
+ <span class="request-spinner" aria-hidden="true"></span>
686
+ <span>Waiting for server...</span>
687
+ </div>
688
+ <button id="selectionExportCopy" class="selection-export-copy" type="button">Copy</button>
689
+ <textarea id="selectionExport" class="selection-export" readonly aria-label="Selected component summary"></textarea>
690
+
691
+ <script>
692
+ const STORAGE_KEYS = {
693
+ probeText: "icaExplorer.probeText",
694
+ model: "icaExplorer.model",
695
+ layer: "icaExplorer.layer",
696
+ topK: "icaExplorer.topK",
697
+ cardWidth: "icaExplorer.cardWidth",
698
+ weakRatio: "icaExplorer.opacityCutoff",
699
+ keepModels: "icaExplorer.keepModels",
700
+ highlights: "icaExplorer.selectedComponents",
701
+ };
702
+
703
+ const state = {
704
+ meta: null,
705
+ models: [],
706
+ highlights: new Set(),
707
+ currentScores: new Map(),
708
+ tokenStats: new Map(),
709
+ tokenStatsRetries: new Map(),
710
+ componentNeighbors: new Map(),
711
+ componentNeighborsRetries: new Map(),
712
+ layerComponentMeta: new Map(),
713
+ layerComponentMetaLoading: new Set(),
714
+ selectionPanelHidden: false,
715
+ selectionExportHidden: false,
716
+ requestId: 0,
717
+ textTimer: 0,
718
+ pendingRequests: 0,
719
+ lastProbeOutput: null,
720
+ };
721
+
722
+ const els = {
723
+ text: document.getElementById("probeText"),
724
+ model: document.getElementById("modelSelect"),
725
+ layer: document.getElementById("layerSelect"),
726
+ topK: document.getElementById("topK"),
727
+ cardWidth: document.getElementById("cardWidth"),
728
+ weakRatio: document.getElementById("weakRatio"),
729
+ runProbe: document.getElementById("runProbe"),
730
+ keepModels: document.getElementById("keepModels"),
731
+ message: document.getElementById("message"),
732
+ results: document.getElementById("results"),
733
+ selectionPanel: document.getElementById("selectionPanel"),
734
+ selectionList: document.getElementById("selectionList"),
735
+ selectionClose: document.getElementById("selectionClose"),
736
+ selectionExportCopy: document.getElementById("selectionExportCopy"),
737
+ selectionExportToggle: document.getElementById("selectionExportToggle"),
738
+ selectionExport: document.getElementById("selectionExport"),
739
+ requestIndicator: document.getElementById("requestIndicator"),
740
+ };
741
+
742
+ async function api(path, options = {}) {
743
+ beginRequest();
744
+ try {
745
+ const res = await fetch(path, {
746
+ headers: { "content-type": "application/json" },
747
+ ...options,
748
+ });
749
+ if (!res.ok) {
750
+ let detail = res.statusText;
751
+ try { detail = (await res.json()).detail || detail; } catch {}
752
+ throw new Error(detail);
753
+ }
754
+ return res.json();
755
+ } finally {
756
+ endRequest();
757
+ }
758
+ }
759
+
760
+ function beginRequest() {
761
+ state.pendingRequests += 1;
762
+ els.requestIndicator.classList.toggle("visible", state.pendingRequests > 0);
763
+ }
764
+
765
+ function endRequest() {
766
+ state.pendingRequests = Math.max(0, state.pendingRequests - 1);
767
+ els.requestIndicator.classList.toggle("visible", state.pendingRequests > 0);
768
+ }
769
+
770
+ async function init() {
771
+ const savedText = localStorage.getItem(STORAGE_KEYS.probeText);
772
+ restoreControlValues();
773
+ if (savedText !== null) els.text.value = savedText;
774
+ try {
775
+ const modelsOut = await api("/api/models");
776
+ state.models = (modelsOut.models || []).filter(model => model.probe_supported);
777
+ if (!state.models.length) throw new Error("No probe-supported models are available.");
778
+ els.model.innerHTML = state.models.map(model => `<option value="${escapeAttr(model.model_name)}">${escapeHtml(model.display_name || model.model_name)}</option>`).join("");
779
+ const savedModel = localStorage.getItem(STORAGE_KEYS.model);
780
+ if (savedModel && state.models.some(model => model.model_name === savedModel)) els.model.value = savedModel;
781
+ else if (state.models.some(model => model.model_name === "gpt2")) els.model.value = "gpt2";
782
+ await loadModelMeta({ restoreLayer: true });
783
+ runProbe();
784
+ } catch (err) {
785
+ showError(err.message);
786
+ }
787
+ }
788
+
789
+ async function runProbe() {
790
+ const requestId = ++state.requestId;
791
+ els.message.className = "empty";
792
+ if (!els.text.value.trim()) {
793
+ state.highlights.clear();
794
+ els.results.innerHTML = "";
795
+ els.message.hidden = false;
796
+ els.message.textContent = "Enter text to run the probe.";
797
+ return;
798
+ }
799
+ if (!els.results.children.length) {
800
+ els.message.hidden = false;
801
+ els.message.textContent = "Running probe...";
802
+ }
803
+ try {
804
+ const out = await api("/api/probe", {
805
+ method: "POST",
806
+ body: JSON.stringify({
807
+ text: els.text.value,
808
+ model_name: els.model.value,
809
+ layer: els.layer.value,
810
+ top_k: Number(els.topK.value || 5),
811
+ highlights: [],
812
+ keep_models: els.keepModels.checked,
813
+ }),
814
+ });
815
+ if (requestId !== state.requestId) return;
816
+ restoreHighlightsForContext(out);
817
+ renderResults(out);
818
+ } catch (err) {
819
+ if (requestId !== state.requestId) return;
820
+ showError(err.message);
821
+ }
822
+ }
823
+
824
+ function scheduleProbe() {
825
+ localStorage.setItem(STORAGE_KEYS.probeText, els.text.value);
826
+ persistControls();
827
+ persistHighlights();
828
+ renderSelectionExport();
829
+ }
830
+
831
+ function handleTextKeydown(event) {
832
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
833
+ event.preventDefault();
834
+ localStorage.setItem(STORAGE_KEYS.probeText, els.text.value);
835
+ persistControls();
836
+ runProbe();
837
+ }
838
+ }
839
+
840
+ function updateCardWidth() {
841
+ const value = Math.max(100, Math.min(360, Number(els.cardWidth.value || 140)));
842
+ document.documentElement.style.setProperty("--token-card-width", `${value}px`);
843
+ localStorage.setItem(STORAGE_KEYS.cardWidth, String(value));
844
+ }
845
+
846
+ function rerenderLastProbe() {
847
+ if (state.lastProbeOutput) renderResults(state.lastProbeOutput);
848
+ }
849
+
850
+ function renderResults(out) {
851
+ state.lastProbeOutput = out;
852
+ if (out.truncated) {
853
+ els.message.hidden = false;
854
+ els.message.className = "empty";
855
+ els.message.textContent = `Input was truncated to ${out.max_length} tokens.`;
856
+ } else {
857
+ els.message.hidden = true;
858
+ }
859
+ const annotationMeta = annotationMetaMap(out.annotated_components || []);
860
+ state.annotationMeta = annotationMeta;
861
+ state.currentScores = new Map();
862
+ (out.tokens || []).forEach(token => (token.top || []).forEach(pair => {
863
+ const component = Number(pair.component);
864
+ const score = Math.abs(Number(pair.score || 0));
865
+ const previous = state.currentScores.get(component);
866
+ if (!previous || score > Math.abs(Number(previous.score || 0))) {
867
+ state.currentScores.set(component, { component, score: Number(pair.score || 0) });
868
+ }
869
+ }));
870
+
871
+ els.results.innerHTML = out.tokens.map(token => `
872
+ <div class="token-card">
873
+ <div class="token-text" title="${escapeAttr(token.token)}">${escapeHtml(token.token_text || token.token)}</div>
874
+ ${token.top.map(pair => scoreBadge(pair, annotationMeta, tokenTopAbsScore(token))).join("")}
875
+ ${predictionRow(token)}
876
+ </div>
877
+ `).join("");
878
+ els.results.querySelectorAll(".badge").forEach(node => {
879
+ node.addEventListener("click", event => selectComponent(event, Number(node.dataset.component), node.dataset.selectionKey));
880
+ });
881
+ paintHighlights();
882
+ }
883
+
884
+ function predictionRow(token) {
885
+ const pred = token.prediction;
886
+ if (!pred) return "";
887
+ const text = visibleToken(pred.token_text || pred.token || "");
888
+ return `
889
+ <div class="prediction-row" title="${escapeAttr(`next token: ${text}`)}">
890
+ <span class="prediction-label">next</span>
891
+ <span class="prediction-token">${escapeHtml(text)}</span>
892
+ </div>
893
+ `;
894
+ }
895
+
896
+ function scoreBadge(pair, annotationMeta, tokenTopAbs) {
897
+ const component = Number(pair.component);
898
+ const score = Number(pair.score || 0);
899
+ const selectionKey = componentSelectionKey(component, score);
900
+ const active = state.highlights.has(selectionKey);
901
+ const ratio = tokenTopAbs > 0 ? Math.abs(score) / tokenTopAbs : 0;
902
+ const width = 100 * Math.max(0, Math.min(1, ratio));
903
+ const meta = annotationForScore(annotationMeta.get(component), score);
904
+ const dot = meta ? `<span class="annotation-dot ${escapeAttr(meta.confidence)}" aria-hidden="true">${escapeHtml(meta.type_letter)}</span>` : "";
905
+ const label = meta ? meta.label : `C${component}`;
906
+ const title = meta ? `C${component}: ${annotationHint(meta)}` : `C${component}`;
907
+ const cutoff = Math.max(0, Math.min(1, Number(els.weakRatio.value || 0.5)));
908
+ const weak = Number.isFinite(tokenTopAbs) && tokenTopAbs > 0 && Math.abs(score) < tokenTopAbs * cutoff;
909
+ return `
910
+ <div class="score-row">
911
+ <button class="badge ${active ? "hot" : ""} ${weak ? "weak" : ""}" type="button" data-component="${component}" data-selection-key="${escapeAttr(selectionKey)}" data-score="${escapeAttr(score)}" aria-pressed="${active ? "true" : "false"}" title="${escapeAttr(title)}" style="--score-width:${width.toFixed(1)}%;--component-color:${componentColor(component)}">
912
+ <span class="badge-main">${dot}<b class="badge-label">${escapeHtml(label)}</b></span>
913
+ </button>
914
+ <span class="score">${formatScore(pair.score)}</span>
915
+ </div>
916
+ `;
917
+ }
918
+
919
+ function tokenTopAbsScore(token) {
920
+ const scores = (token.top || []).map(pair => Math.abs(Number(pair.score || 0)));
921
+ return scores.length ? Math.max(...scores) : 0;
922
+ }
923
+
924
+ function selectComponent(event, component, selectionKey) {
925
+ state.selectionPanelHidden = false;
926
+ if (event.ctrlKey || event.metaKey) {
927
+ if (state.highlights.has(selectionKey)) state.highlights.delete(selectionKey);
928
+ else state.highlights.add(selectionKey);
929
+ } else {
930
+ state.highlights.clear();
931
+ state.highlights.add(selectionKey);
932
+ }
933
+ persistHighlights();
934
+ paintHighlights();
935
+ }
936
+
937
+
938
+ function highlightStorageKey(model = els.model.value, layer = els.layer.value) {
939
+ return `${model}:${layer}`;
940
+ }
941
+
942
+ function readHighlightStore() {
943
+ try {
944
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.highlights) || "{}");
945
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
946
+ } catch {
947
+ return {};
948
+ }
949
+ }
950
+
951
+ function persistHighlights() {
952
+ const store = readHighlightStore();
953
+ const key = highlightStorageKey();
954
+ const values = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys);
955
+ if (values.length) store[key] = values;
956
+ else delete store[key];
957
+ localStorage.setItem(STORAGE_KEYS.highlights, JSON.stringify(store));
958
+ }
959
+
960
+ function restoreHighlightsForContext(out) {
961
+ const store = readHighlightStore();
962
+ const saved = Array.isArray(store[highlightStorageKey()]) ? store[highlightStorageKey()] : [];
963
+ state.highlights = new Set(saved.map(normalizeSelectionKey).filter(Boolean));
964
+ }
965
+
966
+ function paintHighlights() {
967
+ els.results.querySelectorAll(".badge").forEach(node => {
968
+ const active = state.highlights.has(node.dataset.selectionKey);
969
+ node.classList.toggle("hot", active);
970
+ node.setAttribute("aria-pressed", active ? "true" : "false");
971
+ });
972
+ renderSelectionPanel();
973
+ renderSelectionExport();
974
+ }
975
+
976
+ function renderSelectionPanel() {
977
+ const selections = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys).map(parseSelectionKey);
978
+ els.selectionPanel.classList.toggle("visible", selections.length > 0 && !state.selectionPanelHidden);
979
+ els.selectionList.innerHTML = selections.map(selection => {
980
+ const component = selection.component;
981
+ const score = selection.sign * Math.abs(Number(state.currentScores?.get(component)?.score || 1));
982
+ ensureLayerComponentMeta();
983
+ ensureTokenStats(component);
984
+ ensureComponentNeighbors(component);
985
+ const stats = state.tokenStats.get(selectionStatsKey(component));
986
+ const neighbors = state.componentNeighbors.get(selectionStatsKey(component));
987
+ const sourceLabel = sourceSideLabel(componentMeta(component), score);
988
+ return `
989
+ <div class="selection-entry" style="--component-color:${componentColor(component)}">
990
+ <div class="selection-item">
991
+ <a class="selection-link" href="${escapeAttr(componentAnnotateUrl(component))}">
992
+ <span>${escapeHtml(els.layer.value)} C${component}${selection.sign < 0 ? " -" : " +"}</span>
993
+ <span class="selection-score">${selectionMetrics(component)}</span>
994
+ </a>
995
+ <div class="selection-token-stats">${renderTokenStats(stats)}</div>
996
+ </div>
997
+ ${renderComponentNeighbors(neighbors, score, sourceLabel)}
998
+ </div>
999
+ `;
1000
+ }).join("");
1001
+ }
1002
+
1003
+ function renderSelectionExport() {
1004
+ const selections = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys).map(parseSelectionKey);
1005
+ if (!selections.length) {
1006
+ els.selectionExport.classList.remove("visible");
1007
+ els.selectionExportCopy.classList.remove("visible");
1008
+ els.selectionExportToggle.classList.remove("visible");
1009
+ els.selectionExport.value = "";
1010
+ return;
1011
+ }
1012
+ const selection = selections[0];
1013
+ els.selectionExport.value = componentExportText(selection);
1014
+ els.selectionExportToggle.classList.add("visible");
1015
+ els.selectionExportToggle.textContent = state.selectionExportHidden ? "Show text" : "Hide text";
1016
+ els.selectionExport.classList.toggle("visible", !state.selectionExportHidden);
1017
+ els.selectionExportCopy.classList.toggle("visible", !state.selectionExportHidden);
1018
+ }
1019
+
1020
+ function componentExportText(selection) {
1021
+ const component = selection.component;
1022
+ const meta = componentMeta(component);
1023
+ const erf = Number(meta?.effective_context_mean);
1024
+ const erfText = Number.isFinite(erf) ? Number(erf).toFixed(1).replace(/\.0$/, "") : "?";
1025
+ const activations = componentTokenActivations(component, selection.sign);
1026
+ const activationLines = activations.length
1027
+ ? activations.map(item => `${item.index}: ${item.token} -> ${item.score.toFixed(1)}`).join("\n")
1028
+ : "(component is not present in the current top-k token activations)";
1029
+ return [
1030
+ `[Component C${component}, Effective Receptive Field=${erfText}]`,
1031
+ "",
1032
+ "INPUT TEXT:",
1033
+ els.text.value,
1034
+ "",
1035
+ "TOKEN ACTIVATIONS:",
1036
+ activationLines,
1037
+ "",
1038
+ "",
1039
+ ].join("\n");
1040
+ }
1041
+
1042
+ function componentTokenActivations(component, sign) {
1043
+ const rows = [];
1044
+ (state.lastProbeOutput?.tokens || []).forEach(token => {
1045
+ const item = (token.top || []).find(pair => Number(pair.component) === Number(component));
1046
+ if (!item) return;
1047
+ const score = Number(item.score || 0);
1048
+ if (!Number.isFinite(score) || score === 0) return;
1049
+ if ((score < 0 ? -1 : 1) !== sign) return;
1050
+ rows.push({ index: Number(token.position), token: exportTokenText(token.token_text || token.token), score });
1051
+ });
1052
+ return rows.sort((a, b) => Math.abs(b.score) - Math.abs(a.score));
1053
+ }
1054
+
1055
+ async function copySelectionExport() {
1056
+ const text = els.selectionExport.value;
1057
+ if (!text) return;
1058
+ try {
1059
+ await navigator.clipboard.writeText(text);
1060
+ } catch {
1061
+ els.selectionExport.focus();
1062
+ els.selectionExport.select();
1063
+ document.execCommand("copy");
1064
+ els.selectionExport.setSelectionRange(0, 0);
1065
+ }
1066
+ const previous = els.selectionExportCopy.textContent;
1067
+ els.selectionExportCopy.textContent = "Copied";
1068
+ window.setTimeout(() => { els.selectionExportCopy.textContent = previous; }, 900);
1069
+ }
1070
+
1071
+ function selectionMetrics(component) {
1072
+ const parts = [];
1073
+ const meta = componentMeta(component);
1074
+ const ecl = meta?.effective_context_mean;
1075
+ const kurtosis = meta?.excess_kurtosis;
1076
+ if (Number.isFinite(ecl)) parts.push(`ERF=${Number(ecl).toFixed(1)}`);
1077
+ if (Number.isFinite(kurtosis)) parts.push(`K=${Number(kurtosis).toFixed(1)}`);
1078
+ return escapeHtml(parts.join(" · "));
1079
+ }
1080
+
1081
+ function selectionStatsKey(component) {
1082
+ return `${els.model.value}:${els.layer.value}:${component}`;
1083
+ }
1084
+
1085
+ function layerMetaKey(model = els.model.value, layer = els.layer.value) {
1086
+ return `${model}:${layer}`;
1087
+ }
1088
+
1089
+ function componentMeta(component) {
1090
+ return state.annotationMeta?.get(component) || state.layerComponentMeta.get(layerMetaKey())?.get(Number(component)) || null;
1091
+ }
1092
+
1093
+ function ensureLayerComponentMeta() {
1094
+ const key = layerMetaKey();
1095
+ if (state.layerComponentMeta.has(key) || state.layerComponentMetaLoading.has(key)) return;
1096
+ state.layerComponentMetaLoading.add(key);
1097
+ const params = new URLSearchParams({
1098
+ model: state.meta?.model_name || els.model.value,
1099
+ layer: els.layer.value,
1100
+ });
1101
+ api(`/api/components?${params.toString()}`)
1102
+ .then(data => {
1103
+ state.layerComponentMeta.set(key, componentMetaMap(data.components || []));
1104
+ renderSelectionPanel();
1105
+ renderSelectionExport();
1106
+ })
1107
+ .catch(() => {
1108
+ state.layerComponentMeta.set(key, new Map());
1109
+ })
1110
+ .finally(() => {
1111
+ state.layerComponentMetaLoading.delete(key);
1112
+ });
1113
+ }
1114
+
1115
+ function componentSelectionKey(component, score) {
1116
+ return `${Number(component)}:${Number(score) < 0 ? "-" : "+"}`;
1117
+ }
1118
+
1119
+ function normalizeSelectionKey(value) {
1120
+ if (typeof value === "number" && Number.isFinite(value)) return `${value}:+`;
1121
+ const text = String(value || "");
1122
+ if (/^\d+:[+-]$/.test(text)) return text;
1123
+ const legacy = Number(text);
1124
+ return Number.isFinite(legacy) ? `${legacy}:+` : "";
1125
+ }
1126
+
1127
+ function isValidSelectionKey(value) {
1128
+ return /^\d+:[+-]$/.test(String(value || ""));
1129
+ }
1130
+
1131
+ function parseSelectionKey(value) {
1132
+ const [component, sign] = String(value).split(":");
1133
+ return { component: Number(component), sign: sign === "-" ? -1 : 1 };
1134
+ }
1135
+
1136
+ function compareSelectionKeys(a, b) {
1137
+ const aa = parseSelectionKey(a);
1138
+ const bb = parseSelectionKey(b);
1139
+ return aa.component - bb.component || bb.sign - aa.sign;
1140
+ }
1141
+
1142
+ function hasHighlightedComponent(component) {
1143
+ return state.highlights.has(`${Number(component)}:+`) || state.highlights.has(`${Number(component)}:-`);
1144
+ }
1145
+
1146
+ function ensureTokenStats(component) {
1147
+ const key = selectionStatsKey(component);
1148
+ if (state.tokenStats.has(key)) return;
1149
+ const retries = state.tokenStatsRetries.get(key) || 0;
1150
+ if (retries >= 2) {
1151
+ state.tokenStats.set(key, { error: "token stats unavailable", tokens: [] });
1152
+ return;
1153
+ }
1154
+ state.tokenStatsRetries.set(key, retries + 1);
1155
+ state.tokenStats.set(key, null);
1156
+ const params = new URLSearchParams({
1157
+ model: state.meta?.model_name || els.model.value,
1158
+ layer: els.layer.value,
1159
+ component: String(component),
1160
+ });
1161
+ api(`/api/component-token-stats?${params.toString()}`)
1162
+ .then(data => {
1163
+ state.tokenStats.set(key, data);
1164
+ if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel();
1165
+ })
1166
+ .catch(err => {
1167
+ state.tokenStats.set(key, { error: err.message || "token stats unavailable", tokens: [] });
1168
+ if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel();
1169
+ });
1170
+ }
1171
+
1172
+ function ensureComponentNeighbors(component) {
1173
+ const key = selectionStatsKey(component);
1174
+ if (state.componentNeighbors.has(key)) return;
1175
+ const retries = state.componentNeighborsRetries.get(key) || 0;
1176
+ if (retries >= 2) {
1177
+ state.componentNeighbors.set(key, { error: "neighbors unavailable", neighbors: [] });
1178
+ return;
1179
+ }
1180
+ state.componentNeighborsRetries.set(key, retries + 1);
1181
+ state.componentNeighbors.set(key, null);
1182
+ const params = new URLSearchParams({
1183
+ model: state.meta?.model_name || els.model.value,
1184
+ layer: els.layer.value,
1185
+ component: String(component),
1186
+ });
1187
+ api(`/api/component-neighbors?${params.toString()}`)
1188
+ .then(data => {
1189
+ state.componentNeighbors.set(key, data);
1190
+ if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel();
1191
+ })
1192
+ .catch(err => {
1193
+ state.componentNeighbors.set(key, { error: err.message || "neighbors unavailable", neighbors: [] });
1194
+ if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel();
1195
+ });
1196
+ }
1197
+
1198
+ function renderComponentNeighbors(data, sourceScore, sourceLabel) {
1199
+ if (data === null) {
1200
+ return `
1201
+ <div class="selection-neighbors">
1202
+ ${renderLoadingNeighbor("prev")}
1203
+ ${renderLoadingNeighbor("next")}
1204
+ </div>
1205
+ `;
1206
+ }
1207
+ const byDirection = new Map((data?.neighbors || []).map(item => [item.direction, item]));
1208
+ return `
1209
+ <div class="selection-neighbors">
1210
+ ${renderNeighborCard(byDirection.get("prev"), "prev", sourceScore, sourceLabel)}
1211
+ ${renderNeighborCard(byDirection.get("next"), "next", sourceScore, sourceLabel)}
1212
+ </div>
1213
+ `;
1214
+ }
1215
+
1216
+ function renderNeighborCard(neighbor, direction, sourceScore, sourceLabel) {
1217
+ if (!neighbor) return renderMissingNeighbor(direction);
1218
+ const layer = String(neighbor.neighbor_layer || "");
1219
+ const component = Number(neighbor.neighbor_component);
1220
+ const label = neighborLabel(neighbor, sourceScore, sourceLabel);
1221
+ const cos = Number(neighbor.abs_cosine);
1222
+ const href = componentAnnotateUrl(component, layer);
1223
+ return `
1224
+ <a class="neighbor-card" href="${escapeAttr(href)}" title="${escapeAttr(`${direction}: ${layer} C${component} ${label}`)}">
1225
+ <span class="neighbor-target">${escapeHtml(`${layer} C${component}`)}</span>
1226
+ <span class="neighbor-label">${escapeHtml(label)}</span>
1227
+ <span class="neighbor-metrics">${neighborMetrics(neighbor)}</span>
1228
+ <span class="neighbor-cos" style="--cos-width:${cosWidth(cos)}%">cos=${Number.isFinite(cos) ? cos.toFixed(3) : "?"}</span>
1229
+ </a>
1230
+ `;
1231
+ }
1232
+
1233
+ function renderMissingNeighbor(direction) {
1234
+ return `
1235
+ <div class="neighbor-card missing" aria-hidden="true">
1236
+ <span class="neighbor-target"></span>
1237
+ <span class="neighbor-label"></span>
1238
+ <span class="neighbor-metrics"></span>
1239
+ <span class="neighbor-cos"></span>
1240
+ </div>
1241
+ `;
1242
+ }
1243
+
1244
+ function renderLoadingNeighbor(direction) {
1245
+ return `
1246
+ <div class="neighbor-card loading">
1247
+ <span class="neighbor-target">loading</span>
1248
+ <span class="neighbor-label"></span>
1249
+ <span class="neighbor-metrics"></span>
1250
+ <span class="neighbor-cos"></span>
1251
+ </div>
1252
+ `;
1253
+ }
1254
+
1255
+ function neighborLabel(neighbor, sourceScore, sourceLabel) {
1256
+ const positive = visibleAnnotationLabel(neighbor?.positive_label, neighbor?.positive_confidence);
1257
+ const negative = visibleAnnotationLabel(neighbor?.negative_label, neighbor?.negative_confidence);
1258
+ const positiveMatch = labelSimilarity(sourceLabel, positive);
1259
+ const negativeMatch = labelSimilarity(sourceLabel, negative);
1260
+ if (positiveMatch > negativeMatch && positiveMatch > 0) return positive;
1261
+ if (negativeMatch > positiveMatch && negativeMatch > 0) return negative;
1262
+ const sourceSign = Number(sourceScore) < 0 ? -1 : 1;
1263
+ const neighborSign = Number(neighbor?.neighbor_sign) < 0 ? -1 : 1;
1264
+ if (sourceSign * neighborSign < 0) return negative || "unlabeled";
1265
+ return positive || "unlabeled";
1266
+ }
1267
+
1268
+ function cosWidth(value) {
1269
+ const cos = Math.max(0, Math.min(1, Number(value) || 0));
1270
+ return (100 * cos).toFixed(1);
1271
+ }
1272
+
1273
+ function neighborMetrics(neighbor) {
1274
+ const parts = [];
1275
+ const ecl = Number(neighbor?.effective_context_mean);
1276
+ const kurtosis = Number(neighbor?.excess_kurtosis);
1277
+ if (Number.isFinite(ecl)) parts.push(`ERF=${ecl.toFixed(1)}`);
1278
+ if (Number.isFinite(kurtosis)) parts.push(`K=${kurtosis.toFixed(1)}`);
1279
+ return escapeHtml(parts.join(" · "));
1280
+ }
1281
+
1282
+ function sourceSideLabel(meta, score) {
1283
+ if (!meta) return "";
1284
+ return Number(score) < 0
1285
+ ? visibleAnnotationLabel(meta.negative_label, meta.negative_confidence)
1286
+ : visibleAnnotationLabel(meta.positive_label, meta.positive_confidence);
1287
+ }
1288
+
1289
+ function labelSimilarity(a, b) {
1290
+ const aTokens = labelTokens(a);
1291
+ const bTokens = labelTokens(b);
1292
+ if (!aTokens.size || !bTokens.size) return 0;
1293
+ let overlap = 0;
1294
+ aTokens.forEach(token => { if (bTokens.has(token)) overlap += 1; });
1295
+ return overlap / Math.max(aTokens.size, bTokens.size);
1296
+ }
1297
+
1298
+ function labelTokens(value) {
1299
+ const text = String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1300
+ if (!text || text === "?") return new Set();
1301
+ return new Set(text.split(/\s+/).filter(Boolean));
1302
+ }
1303
+
1304
+ function renderTokenStats(stats) {
1305
+ if (stats === null) return `<span>loading tokens...</span>`;
1306
+ if (stats?.error) return `<span>${escapeHtml(stats.error)}</span>`;
1307
+ const tokens = stats?.tokens || [];
1308
+ if (!tokens.length) return `<span>no example tokens</span>`;
1309
+ const maxCount = Math.max(1, ...tokens.map(item => Number(item.count || 0)));
1310
+ const tokenChips = tokens.map(item => `
1311
+ <span class="selection-token-chip" title="${escapeAttr(`${visibleToken(item.token)}: ${item.count}`)}" style="--token-font-size:${tokenFontSize(item.count, maxCount)}px">
1312
+ <span class="selection-token-text">${escapeHtml(visibleToken(item.token))}</span>
1313
+ </span>
1314
+ `).join("");
1315
+ return tokenChips;
1316
+ }
1317
+
1318
+ function tokenFontSize(count, maxCount) {
1319
+ const value = Math.max(0, Number(count || 0));
1320
+ const ratio = maxCount <= 1 ? 0 : Math.log1p(value) / Math.log1p(maxCount);
1321
+ return (9 + 5 * ratio).toFixed(1);
1322
+ }
1323
+
1324
+ function visibleToken(value) {
1325
+ const text = String(value || "");
1326
+ if (text === " ") return "[space]";
1327
+ if (text === "\n") return "[newline]";
1328
+ return text.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1329
+ }
1330
+
1331
+ function exportTokenText(value) {
1332
+ const text = String(value || "");
1333
+ if (text === ".") return "period";
1334
+ if (text === ",") return "comma";
1335
+ if (text === " ") return "[space]";
1336
+ if (text === "\n") return "[newline]";
1337
+ return visibleToken(text).trim() || visibleToken(text);
1338
+ }
1339
+
1340
+ function componentAnnotateUrl(component, layer = els.layer.value) {
1341
+ const params = new URLSearchParams({
1342
+ model: state.meta?.model_name || els.model.value,
1343
+ layer,
1344
+ component: String(component),
1345
+ });
1346
+ return `/annotate?${params.toString()}`;
1347
+ }
1348
+
1349
+ async function loadModelMeta(options = {}) {
1350
+ state.meta = await api(`/api/meta?model=${encodeURIComponent(els.model.value)}`);
1351
+ els.layer.innerHTML = state.meta.layers.map(layer => `<option value="${escapeAttr(layer)}">${escapeHtml(layer)}</option>`).join("");
1352
+ if (!state.meta.layers.length) throw new Error(`No ICA layers are available for ${state.meta.display_name || state.meta.model_name}.`);
1353
+ const savedLayer = localStorage.getItem(STORAGE_KEYS.layer);
1354
+ if (options.restoreLayer && savedLayer && state.meta.layers.includes(savedLayer)) els.layer.value = savedLayer;
1355
+ }
1356
+
1357
+ function restoreControlValues() {
1358
+ setNumberInputFromStorage(els.topK, STORAGE_KEYS.topK, 1, 32);
1359
+ setNumberInputFromStorage(els.cardWidth, STORAGE_KEYS.cardWidth, 100, 360);
1360
+ setNumberInputFromStorage(els.weakRatio, STORAGE_KEYS.weakRatio, 0, 1);
1361
+ els.keepModels.checked = localStorage.getItem(STORAGE_KEYS.keepModels) !== "0";
1362
+ }
1363
+
1364
+ function setNumberInputFromStorage(input, key, min, max) {
1365
+ const raw = localStorage.getItem(key);
1366
+ if (raw === null) return;
1367
+ const value = Number(raw);
1368
+ if (!Number.isFinite(value)) return;
1369
+ input.value = String(Math.max(min, Math.min(max, value)));
1370
+ }
1371
+
1372
+ function persistControls() {
1373
+ localStorage.setItem(STORAGE_KEYS.model, els.model.value);
1374
+ localStorage.setItem(STORAGE_KEYS.layer, els.layer.value);
1375
+ localStorage.setItem(STORAGE_KEYS.topK, els.topK.value);
1376
+ localStorage.setItem(STORAGE_KEYS.cardWidth, els.cardWidth.value);
1377
+ localStorage.setItem(STORAGE_KEYS.weakRatio, els.weakRatio.value);
1378
+ localStorage.setItem(STORAGE_KEYS.keepModels, els.keepModels.checked ? "1" : "0");
1379
+ }
1380
+
1381
+ function showError(message) {
1382
+ els.message.hidden = false;
1383
+ els.message.className = "error";
1384
+ els.message.textContent = message;
1385
+ }
1386
+
1387
+ function componentColor(componentId) {
1388
+ return `hsl(${(37 * Number(componentId)) % 360} 78% 48%)`;
1389
+ }
1390
+
1391
+ function annotationMetaMap(raw) {
1392
+ const out = new Map();
1393
+ if (!Array.isArray(raw)) return out;
1394
+ raw.forEach(item => {
1395
+ const component = Number(item?.component);
1396
+ if (!Number.isFinite(component)) return;
1397
+ out.set(component, {
1398
+ positive_label: String(item.positive_label || "").trim(),
1399
+ positive_confidence: normalizedConfidence(item.positive_confidence),
1400
+ positive_types: Array.isArray(item.positive_types) ? item.positive_types.map(String) : [],
1401
+ negative_label: String(item.negative_label || "").trim(),
1402
+ negative_confidence: normalizedConfidence(item.negative_confidence),
1403
+ negative_types: Array.isArray(item.negative_types) ? item.negative_types.map(String) : [],
1404
+ excess_kurtosis: Number.isFinite(Number(item.excess_kurtosis)) ? Number(item.excess_kurtosis) : null,
1405
+ effective_context_mean: Number.isFinite(Number(item.effective_context_mean)) ? Number(item.effective_context_mean) : null,
1406
+ });
1407
+ });
1408
+ return out;
1409
+ }
1410
+
1411
+ function componentMetaMap(raw) {
1412
+ const out = new Map();
1413
+ if (!Array.isArray(raw)) return out;
1414
+ raw.forEach(item => {
1415
+ const component = Number(item?.component);
1416
+ if (!Number.isFinite(component)) return;
1417
+ out.set(component, {
1418
+ positive_label: String(item.positive_label || "").trim(),
1419
+ positive_confidence: normalizedConfidence(item.positive_confidence),
1420
+ positive_types: Array.isArray(item.positive_types) ? item.positive_types.map(String) : [],
1421
+ negative_label: String(item.negative_label || "").trim(),
1422
+ negative_confidence: normalizedConfidence(item.negative_confidence),
1423
+ negative_types: Array.isArray(item.negative_types) ? item.negative_types.map(String) : [],
1424
+ excess_kurtosis: Number.isFinite(Number(item.excess_kurtosis)) ? Number(item.excess_kurtosis) : null,
1425
+ effective_context_mean: Number.isFinite(Number(item.effective_context_mean)) ? Number(item.effective_context_mean) : null,
1426
+ });
1427
+ });
1428
+ return out;
1429
+ }
1430
+
1431
+ function annotationForScore(meta, score) {
1432
+ if (!meta) return null;
1433
+ const positive = Number(score || 0) >= 0;
1434
+ const label = positive ? meta.positive_label : meta.negative_label;
1435
+ const confidence = positive ? meta.positive_confidence : meta.negative_confidence;
1436
+ const types = positive ? meta.positive_types : meta.negative_types;
1437
+ if (!visibleAnnotationLabel(label, confidence)) return null;
1438
+ return {
1439
+ label: visibleAnnotationLabel(label, confidence),
1440
+ confidence,
1441
+ type_letter: typeLetter(types),
1442
+ };
1443
+ }
1444
+
1445
+ function annotationHint(meta) {
1446
+ return `${meta.label} (${meta.confidence})`;
1447
+ }
1448
+
1449
+ function visibleAnnotationLabel(value, confidence) {
1450
+ const text = String(value || "").trim();
1451
+ if (!text) return "";
1452
+ if (text === "?" && normalizedConfidence(confidence) === "unclear") return "";
1453
+ return text.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
1454
+ }
1455
+
1456
+ function normalizedConfidence(value) {
1457
+ const confidence = String(value || "unclear").toLowerCase();
1458
+ return ["high", "medium", "low", "unclear"].includes(confidence) ? confidence : "unclear";
1459
+ }
1460
+
1461
+ function typeLetter(types) {
1462
+ const labels = Array.isArray(types) ? types : [];
1463
+ const priority = [
1464
+ ["Form", "F"],
1465
+ ["Word", "W"],
1466
+ ["Phrase", "P"],
1467
+ ["Sentence", "S"],
1468
+ ["Long-Range Context", "L"],
1469
+ ["Global", "G"],
1470
+ ["Position", "O"],
1471
+ ["Sophisticated", "X"],
1472
+ ];
1473
+ for (const [name, letter] of priority) {
1474
+ if (labels.some(label => String(label).toLowerCase() === name.toLowerCase())) return letter;
1475
+ }
1476
+ const first = labels.find(label => String(label).trim());
1477
+ return first ? String(first).trim()[0].toUpperCase() : "";
1478
+ }
1479
+
1480
+ function formatScore(value) {
1481
+ const abs = Math.abs(value);
1482
+ if (abs >= 100) return value.toFixed(0);
1483
+ if (abs >= 10) return value.toFixed(1);
1484
+ return value.toFixed(2);
1485
+ }
1486
+
1487
+ function escapeHtml(value) {
1488
+ return String(value).replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
1489
+ }
1490
+
1491
+ function escapeAttr(value) {
1492
+ return escapeHtml(value);
1493
+ }
1494
+
1495
+ els.model.addEventListener("change", async () => {
1496
+ persistHighlights();
1497
+ state.highlights.clear();
1498
+ state.tokenStats.clear();
1499
+ state.tokenStatsRetries.clear();
1500
+ state.componentNeighbors.clear();
1501
+ state.componentNeighborsRetries.clear();
1502
+ persistControls();
1503
+ try {
1504
+ await loadModelMeta();
1505
+ persistControls();
1506
+ runProbe();
1507
+ } catch (err) {
1508
+ showError(err.message);
1509
+ }
1510
+ });
1511
+ els.layer.addEventListener("change", () => {
1512
+ persistHighlights();
1513
+ state.highlights.clear();
1514
+ state.componentNeighbors.clear();
1515
+ state.componentNeighborsRetries.clear();
1516
+ persistControls();
1517
+ runProbe();
1518
+ });
1519
+ els.topK.addEventListener("change", () => { persistControls(); runProbe(); });
1520
+ els.topK.addEventListener("input", () => { persistControls(); runProbe(); });
1521
+ els.cardWidth.addEventListener("change", updateCardWidth);
1522
+ els.cardWidth.addEventListener("input", updateCardWidth);
1523
+ els.weakRatio.addEventListener("change", () => { persistControls(); rerenderLastProbe(); });
1524
+ els.keepModels.addEventListener("change", () => { persistControls(); if (!els.keepModels.checked) runProbe(); });
1525
+ els.selectionClose.addEventListener("click", () => {
1526
+ state.selectionPanelHidden = true;
1527
+ renderSelectionPanel();
1528
+ });
1529
+ els.selectionExportToggle.addEventListener("click", () => {
1530
+ state.selectionExportHidden = !state.selectionExportHidden;
1531
+ renderSelectionExport();
1532
+ });
1533
+ els.selectionExportCopy.addEventListener("click", copySelectionExport);
1534
+ els.weakRatio.addEventListener("input", () => { persistControls(); rerenderLastProbe(); });
1535
+ els.text.addEventListener("input", scheduleProbe);
1536
+ els.text.addEventListener("keydown", handleTextKeydown);
1537
+ els.runProbe.addEventListener("click", () => {
1538
+ localStorage.setItem(STORAGE_KEYS.probeText, els.text.value);
1539
+ persistControls();
1540
+ runProbe();
1541
+ });
1542
+ updateCardWidth();
1543
+ init();
1544
+ </script>
1545
+ </body>
1546
+ </html>
server/static/random_components.html ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Random ICA Components</title>
7
+ <style>
8
+ :root { --bg:#f8fafc; --panel:#fff; --text:#0f172a; --muted:#64748b; --border:#cbd5e1; --accent:#2563eb; --subtle:#f1f5f9; --shadow:0 1px 2px rgb(15 23 42 / .08),0 8px 24px rgb(15 23 42 / .05); }
9
+ * { box-sizing:border-box; }
10
+ body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
11
+ header { position:sticky; top:0; z-index:5; background:#fff; border-bottom:1px solid var(--border); padding:12px 16px; box-shadow:var(--shadow); }
12
+ .top { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
13
+ h1 { margin:0; font-size:20px; }
14
+ nav { display:flex; gap:6px; }
15
+ nav a { color:var(--muted); text-decoration:none; padding:7px 10px; border-radius:7px; font-weight:650; }
16
+ nav a:hover, nav a.active { color:var(--accent); background:#eff6ff; }
17
+ .toolbar { display:flex; align-items:end; flex-wrap:wrap; gap:10px; margin-top:10px; }
18
+ label { color:#475569; font-size:12px; font-weight:700; }
19
+ select,input,button { font:inherit; border:1px solid var(--border); border-radius:7px; padding:7px 9px; background:#fff; color:var(--text); }
20
+ button { cursor:pointer; background:var(--subtle); font-weight:750; }
21
+ button:hover { color:var(--accent); border-color:var(--accent); background:#eff6ff; }
22
+ main { padding:14px; }
23
+ .panel { background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:var(--shadow); padding:12px; }
24
+ .summary { color:var(--muted); margin-bottom:10px; }
25
+ .run { margin-top:14px; }
26
+ .run h2 { display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin:0 0 8px; font-size:16px; }
27
+ .run-meta { color:var(--muted); font-size:12px; font-weight:500; }
28
+ table { width:100%; border-collapse:collapse; background:#fff; }
29
+ th,td { border-bottom:1px solid #e2e8f0; padding:8px 9px; text-align:left; vertical-align:top; }
30
+ th { position:sticky; top:94px; background:#f8fafc; color:#475569; font-size:12px; text-transform:uppercase; letter-spacing:.02em; z-index:1; }
31
+ tr:hover td { background:#f8fafc; }
32
+ a { color:var(--accent); text-decoration:none; font-weight:800; }
33
+ a:hover { text-decoration:underline; }
34
+ .muted { color:var(--muted); }
35
+ .pill { display:inline-flex; border:1px solid var(--border); border-radius:999px; padding:1px 7px; color:var(--muted); background:#fff; font-size:12px; font-weight:750; white-space:nowrap; }
36
+ .label { max-width:360px; color:#334155; }
37
+ .label-badge { display:inline-flex; align-items:center; border-radius:999px; padding:2px 8px; font-size:12px; font-weight:850; line-height:1.3; }
38
+ .label-badge.high { color:#166534; background:#dcfce7; border:1px solid #86efac; }
39
+ .label-badge.medium { color:#854d0e; background:#fef3c7; border:1px solid #fde68a; }
40
+ .label-badge.low { color:#9f1239; background:#ffe4e6; border:1px solid #f9a8d4; }
41
+ .label-badge.unclear { color:#475569; background:#e5e7eb; border:1px solid #cbd5e1; }
42
+ .status { margin-left:auto; color:var(--muted); }
43
+ .empty,.error { border:1px dashed var(--border); border-radius:8px; padding:18px; color:var(--muted); background:#fff; }
44
+ .error { color:#991b1b; border-color:#fecaca; background:#fef2f2; }
45
+ @media(max-width:760px){ th:nth-child(5),td:nth-child(5),th:nth-child(6),td:nth-child(6){display:none;} }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <header>
50
+ <div class="top">
51
+ <h1>Random ICA Components</h1>
52
+ <nav aria-label="Primary">
53
+ <a href="/">Explorer</a>
54
+ <a href="/sae-explorer">SAE Explorer</a>
55
+ <a href="/stats">Stats</a>
56
+ <a href="/annotate">Annotate</a>
57
+ <a href="/random-components" class="active">Random</a>
58
+ </nav>
59
+ </div>
60
+ <div class="toolbar">
61
+ <label>Model<br><select id="model"><option value="">all models</option></select></label>
62
+ <label style="flex:1;min-width:260px">Selection<br><input id="selection" placeholder="optional: random_components_n50_seed20260524" /></label>
63
+ <button id="load">Load</button>
64
+ <span id="status" class="status"></span>
65
+ </div>
66
+ </header>
67
+ <main>
68
+ <section class="panel">
69
+ <div id="summary" class="summary">Loading random component selections...</div>
70
+ <div id="content"></div>
71
+ </section>
72
+ </main>
73
+ <script>
74
+ const state = { models: [] };
75
+ const el = Object.fromEntries(["model","selection","load","status","summary","content"].map(id=>[id,document.getElementById(id)]));
76
+ async function api(path){ const r=await fetch(path); if(!r.ok){let msg=await r.text(); try{msg=JSON.parse(msg).detail||msg}catch{} throw new Error(msg)} return r.json(); }
77
+ function esc(s){return String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));}
78
+ function setStatus(text){ el.status.textContent = text || ""; }
79
+ async function init(){
80
+ const params = new URLSearchParams(location.search);
81
+ el.selection.value = params.get("selection") || "random_components_n50_seed0";
82
+ const requestedModel = params.get("model") || "qwen3_5_2b_base";
83
+ try {
84
+ const models = await api("/api/models");
85
+ state.models = models.models || [];
86
+ el.model.innerHTML = `<option value="">all models</option>` + state.models.map(m=>`<option value="${esc(m.model_name)}">${esc(m.display_name || m.model_name)}</option>`).join("");
87
+ el.model.value = requestedModel;
88
+ } catch {}
89
+ el.load.onclick = () => loadRuns(true);
90
+ el.model.onchange = () => loadRuns(true);
91
+ el.selection.onkeydown = event => { if(event.key === "Enter") loadRuns(true); };
92
+ await loadRuns(false);
93
+ }
94
+ async function loadRuns(push){
95
+ setStatus("Loading...");
96
+ el.content.innerHTML = "";
97
+ try {
98
+ const params = new URLSearchParams();
99
+ if(el.model.value) params.set("model", el.model.value);
100
+ if(el.selection.value.trim()) params.set("selection", el.selection.value.trim());
101
+ if(push) history.replaceState(null, "", `/random-components${params.toString() ? `?${params}` : ""}`);
102
+ const data = await api(`/api/random-components${params.toString() ? `?${params}` : ""}`);
103
+ render(data);
104
+ setStatus("");
105
+ } catch (error) {
106
+ el.summary.textContent = "";
107
+ el.content.innerHTML = `<div class="error">${esc(error.message)}</div>`;
108
+ setStatus("");
109
+ }
110
+ }
111
+ function render(data){
112
+ const runs = data.runs || [];
113
+ const total = runs.reduce((sum, run) => sum + Number(run.selected_size || 0), 0);
114
+ el.summary.textContent = `${runs.length} selection file${runs.length===1?"":"s"} - ${total} selected components`;
115
+ el.content.innerHTML = runs.map(renderRun).join("") || '<div class="empty">No random component selections found.</div>';
116
+ }
117
+ function renderRun(run){
118
+ const settings = run.settings || {};
119
+ const rows = run.selected_components || [];
120
+ return `<section class="run">
121
+ <h2>
122
+ <span>${esc(run.model)}</span>
123
+ <span class="run-meta">n=${esc(settings.n)} seed=${esc(settings.seed)} inventory=${esc(run.inventory_size)} selection=${esc(run.selection_name || "")}</span>
124
+ </h2>
125
+ <table>
126
+ <thead><tr><th>#</th><th>Component</th><th>Annotate</th><th>K</th><th>Current label</th></tr></thead>
127
+ <tbody>${rows.map((item,index)=>renderRow(item,index)).join("")}</tbody>
128
+ </table>
129
+ </section>`;
130
+ }
131
+ function renderRow(item,index){
132
+ const annotation = item.annotation || {};
133
+ const sign = Number(item.top_abs_sign) < 0 ? -1 : 1;
134
+ const rawLabel = sign < 0 ? annotation.negative_label : annotation.positive_label;
135
+ const confidence = normalizeConfidence(sign < 0 ? annotation.negative_confidence : annotation.positive_confidence);
136
+ const label = visibleLabel(rawLabel);
137
+ const labels = label
138
+ ? `<span class="label-badge ${esc(confidence)}">${label}</span>`
139
+ : '<span class="label-badge unclear">unlabeled</span>';
140
+ const k = Number(item.excess_kurtosis);
141
+ const erf = Number(item.effective_context_mean);
142
+ const metrics = `${Number.isFinite(erf) ? `<span class="pill">ERF=${erf.toFixed(1)}</span>` : ""} ${Number.isFinite(k) ? `<span class="pill">K=${k.toFixed(1)}</span>` : ""}`;
143
+ return `<tr>
144
+ <td class="muted">${index + 1}</td>
145
+ <td><strong>${esc(item.layer)} C${esc(item.component_index)}</strong><br><span class="muted">${esc(item.component_id || "")}</span></td>
146
+ <td><a href="${esc(item.annotate_url)}">Annotate</a></td>
147
+ <td>${metrics || '<span class="muted">?</span>'}</td>
148
+ <td class="label">${labels}</td>
149
+ </tr>`;
150
+ }
151
+ function visibleLabel(value){
152
+ const text = String(value || "").trim();
153
+ if(!text) return "";
154
+ return esc(text);
155
+ }
156
+ function normalizeConfidence(value){
157
+ const text = String(value || "unclear").trim().toLowerCase();
158
+ return ["high","medium","low","unclear"].includes(text) ? text : "unclear";
159
+ }
160
+ init();
161
+ </script>
162
+ </body>
163
+ </html>
server/static/sae_explorer.html ADDED
@@ -0,0 +1,673 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SAE Explorer</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f7f9;
10
+ --panel: #fff;
11
+ --text: #151922;
12
+ --muted: #647084;
13
+ --border: #cbd3df;
14
+ --accent: #1f6feb;
15
+ --shadow: 0 1px 2px rgb(20 25 34 / .08), 0 10px 30px rgb(20 25 34 / .06);
16
+ --token-card-width: 140px;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body {
20
+ margin: 0;
21
+ background: var(--bg);
22
+ color: var(--text);
23
+ font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
+ }
25
+ header {
26
+ position: sticky;
27
+ top: 0;
28
+ z-index: 5;
29
+ background: var(--panel);
30
+ border-bottom: 1px solid var(--border);
31
+ box-shadow: var(--shadow);
32
+ padding: 12px 18px;
33
+ }
34
+ .top {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ gap: 14px;
39
+ }
40
+ h1 { margin: 0; font-size: 19px; letter-spacing: 0; }
41
+ .nav {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 14px;
45
+ }
46
+ .nav a {
47
+ color: var(--accent);
48
+ font-weight: 750;
49
+ text-decoration: none;
50
+ }
51
+ .nav a.active { color: #0f172a; }
52
+ .nav a:hover { text-decoration: underline; }
53
+ main {
54
+ max-width: 1240px;
55
+ margin: 0 auto;
56
+ padding: 18px;
57
+ }
58
+ .panel {
59
+ margin-top: 14px;
60
+ background: var(--panel);
61
+ border: 1px solid var(--border);
62
+ border-radius: 8px;
63
+ box-shadow: var(--shadow);
64
+ padding: 12px;
65
+ }
66
+ .controls {
67
+ display: grid;
68
+ grid-template-columns: minmax(0, 1fr) 450px;
69
+ gap: 12px;
70
+ align-items: stretch;
71
+ }
72
+ .control-stack {
73
+ display: flex;
74
+ flex-wrap: wrap;
75
+ align-content: flex-start;
76
+ align-items: center;
77
+ gap: 10px;
78
+ }
79
+ .inline-control {
80
+ display: grid;
81
+ grid-template-columns: max-content max-content;
82
+ align-items: center;
83
+ column-gap: 8px;
84
+ }
85
+ .inline-control > span { white-space: nowrap; }
86
+ .control-break { flex-basis: 100%; height: 0; }
87
+ label {
88
+ display: grid;
89
+ gap: 5px;
90
+ color: #435066;
91
+ font-size: 12px;
92
+ font-weight: 700;
93
+ }
94
+ textarea, select, input, button {
95
+ width: 100%;
96
+ border: 1px solid var(--border);
97
+ border-radius: 7px;
98
+ background: #fff;
99
+ color: var(--text);
100
+ font: inherit;
101
+ padding: 8px 10px;
102
+ }
103
+ textarea {
104
+ height: 92px;
105
+ min-height: 92px;
106
+ resize: vertical;
107
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
108
+ }
109
+ #topK { width: 58px; min-width: 0; }
110
+ #cardWidth { width: 78px; min-width: 0; }
111
+ #weakRatio { width: 58px; min-width: 0; }
112
+ button {
113
+ cursor: pointer;
114
+ min-height: 39px;
115
+ background: #eef2f7;
116
+ font-weight: 750;
117
+ }
118
+ .run-button {
119
+ width: auto;
120
+ min-height: 32px;
121
+ padding: 6px 12px;
122
+ border-color: #1458c8;
123
+ background: var(--accent);
124
+ color: #fff;
125
+ font-weight: 850;
126
+ }
127
+ .memory-control {
128
+ position: fixed;
129
+ right: 12px;
130
+ bottom: 10px;
131
+ z-index: 4;
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 5px;
135
+ width: auto;
136
+ min-height: 24px;
137
+ padding: 3px 6px;
138
+ border: 1px solid #d7dee9;
139
+ border-radius: 6px;
140
+ background: rgb(255 255 255 / .78);
141
+ color: #94a3b8;
142
+ font-size: 11px;
143
+ font-weight: 650;
144
+ white-space: nowrap;
145
+ }
146
+ .memory-control input { width: auto; margin: 0; padding: 0; accent-color: #94a3b8; }
147
+ .results {
148
+ display: grid;
149
+ grid-template-columns: repeat(auto-fill, minmax(var(--token-card-width), 1fr));
150
+ gap: 10px;
151
+ margin-top: 12px;
152
+ }
153
+ .token-card {
154
+ border: 1px solid var(--border);
155
+ border-radius: 8px;
156
+ padding: 6px;
157
+ background: #fff;
158
+ min-width: 0;
159
+ text-align: center;
160
+ }
161
+ .token-text {
162
+ min-height: 25px;
163
+ font-weight: 850;
164
+ text-align: center;
165
+ overflow-wrap: anywhere;
166
+ margin-bottom: 6px;
167
+ }
168
+ .score-row {
169
+ position: relative;
170
+ min-height: 24px;
171
+ margin-top: 3px;
172
+ }
173
+ .badge {
174
+ position: relative;
175
+ display: flex;
176
+ align-items: center;
177
+ width: 100%;
178
+ min-height: 24px;
179
+ padding: 3px 5px;
180
+ border: 1px solid #d5dce7;
181
+ border-radius: 5px;
182
+ background:
183
+ linear-gradient(
184
+ 90deg,
185
+ var(--score-bg, #edf2f7) 0,
186
+ var(--score-bg, #edf2f7) var(--score-width, 100%),
187
+ transparent var(--score-width, 100%),
188
+ transparent 100%
189
+ );
190
+ color: var(--text);
191
+ font-size: 10px;
192
+ font-weight: 700;
193
+ text-align: left;
194
+ cursor: pointer;
195
+ }
196
+ .badge:hover {
197
+ border-color: var(--feature-color, var(--accent));
198
+ }
199
+ .badge.hot {
200
+ opacity: 1;
201
+ border-color: var(--feature-color, var(--accent));
202
+ --score-bg: color-mix(in srgb, var(--feature-color, var(--accent)) 18%, white);
203
+ box-shadow: inset 3px 0 0 var(--feature-color, var(--accent));
204
+ }
205
+ .badge.weak { opacity: .16; }
206
+ .badge.weak.hot,
207
+ .badge.weak:hover { opacity: 1; }
208
+ .badge-main {
209
+ min-width: 0;
210
+ max-width: calc(100% - 35px);
211
+ overflow: hidden;
212
+ white-space: nowrap;
213
+ text-overflow: ellipsis;
214
+ }
215
+ .score {
216
+ position: absolute;
217
+ right: 5px;
218
+ top: 50%;
219
+ transform: translateY(-50%);
220
+ width: 34px;
221
+ text-align: right;
222
+ color: var(--muted);
223
+ font-size: 9px;
224
+ font-variant-numeric: tabular-nums;
225
+ pointer-events: none;
226
+ }
227
+ .empty, .error {
228
+ margin-top: 12px;
229
+ border: 1px dashed var(--border);
230
+ border-radius: 8px;
231
+ padding: 18px;
232
+ color: var(--muted);
233
+ background: #fff;
234
+ }
235
+ .error { color: #a41414; border-color: #f0b9b9; background: #fff7f7; }
236
+ .request-indicator {
237
+ position: fixed;
238
+ left: 14px;
239
+ top: 62px;
240
+ z-index: 8;
241
+ display: none;
242
+ align-items: center;
243
+ gap: 8px;
244
+ min-height: 34px;
245
+ padding: 8px 10px;
246
+ border: 1px solid var(--border);
247
+ border-radius: 8px;
248
+ background: var(--panel);
249
+ box-shadow: var(--shadow);
250
+ color: #435066;
251
+ font-size: 12px;
252
+ font-weight: 800;
253
+ }
254
+ .request-indicator.visible { display: inline-flex; }
255
+ .request-spinner {
256
+ width: 14px;
257
+ height: 14px;
258
+ border: 2px solid #cbd5e1;
259
+ border-top-color: var(--accent);
260
+ border-radius: 50%;
261
+ animation: request-spin .8s linear infinite;
262
+ }
263
+ @keyframes request-spin { to { transform: rotate(360deg); } }
264
+ .meta-line {
265
+ margin-top: 10px;
266
+ color: var(--muted);
267
+ font-size: 12px;
268
+ font-weight: 650;
269
+ }
270
+ @media (max-width: 760px) {
271
+ main { padding: 12px; }
272
+ .controls { grid-template-columns: 1fr; }
273
+ .top { align-items: flex-start; flex-direction: column; }
274
+ }
275
+ </style>
276
+ </head>
277
+ <body>
278
+ <header>
279
+ <div class="top">
280
+ <h1>SAE Explorer</h1>
281
+ <nav class="nav" aria-label="Primary">
282
+ <a href="/">Explorer</a>
283
+ <a href="/sae-explorer" class="active">SAE Explorer</a>
284
+ <a href="/stats">Stats</a>
285
+ <a href="/annotate">Annotate</a>
286
+ <a href="/random-components">Random</a>
287
+ </nav>
288
+ </div>
289
+ </header>
290
+
291
+ <main>
292
+ <div class="panel">
293
+ <div class="controls">
294
+ <label>
295
+ Text
296
+ <textarea id="probeText" spellcheck="false">Maya stopped at the bank before the trip, waiting in line to deposit a check and withdraw enough cash for the weekend.</textarea>
297
+ </label>
298
+ <div class="control-stack">
299
+ <label class="inline-control">
300
+ <span>Model</span>
301
+ <select id="modelSelect"></select>
302
+ </label>
303
+ <label class="inline-control">
304
+ <span>Layer</span>
305
+ <select id="layerSelect"></select>
306
+ </label>
307
+ <span class="control-break" aria-hidden="true"></span>
308
+ <label class="inline-control">
309
+ <span>Top K</span>
310
+ <input id="topK" type="number" min="1" max="128" value="5" />
311
+ </label>
312
+ <label class="inline-control">
313
+ <span>Card Width</span>
314
+ <input id="cardWidth" type="number" min="100" max="360" step="20" value="140" />
315
+ </label>
316
+ <label class="inline-control">
317
+ <span>Opacity Cutoff</span>
318
+ <input id="weakRatio" type="number" min="0" max="1" step="0.05" value="0.5" />
319
+ </label>
320
+ <button id="runProbe" class="run-button" type="button">Run</button>
321
+ </div>
322
+ <label class="memory-control" title="Keep loaded models in VRAM when switching.">
323
+ <input id="keepModels" type="checkbox" checked />
324
+ <span>Cache LLMs in VRAM</span>
325
+ </label>
326
+ </div>
327
+ <div id="metaLine" class="meta-line"></div>
328
+ </div>
329
+
330
+ <div id="message" class="empty">Choose a layer and run the SAE probe.</div>
331
+ <div id="results" class="results"></div>
332
+ </main>
333
+ <div id="requestIndicator" class="request-indicator" role="status" aria-live="polite">
334
+ <span class="request-spinner" aria-hidden="true"></span>
335
+ <span>Waiting for server...</span>
336
+ </div>
337
+
338
+ <script>
339
+ const STORAGE_KEYS = {
340
+ probeText: "saeExplorer.probeText",
341
+ model: "saeExplorer.model",
342
+ layer: "saeExplorer.layer",
343
+ topK: "saeExplorer.topK",
344
+ cardWidth: "saeExplorer.cardWidth",
345
+ weakRatio: "saeExplorer.opacityCutoff",
346
+ keepModels: "saeExplorer.keepModels",
347
+ highlights: "saeExplorer.selectedFeatures",
348
+ };
349
+
350
+ const state = {
351
+ models: [],
352
+ meta: null,
353
+ requestId: 0,
354
+ pendingRequests: 0,
355
+ lastProbeOutput: null,
356
+ selectedFeatures: new Set(),
357
+ };
358
+
359
+ const els = {
360
+ text: document.getElementById("probeText"),
361
+ model: document.getElementById("modelSelect"),
362
+ layer: document.getElementById("layerSelect"),
363
+ topK: document.getElementById("topK"),
364
+ cardWidth: document.getElementById("cardWidth"),
365
+ weakRatio: document.getElementById("weakRatio"),
366
+ runProbe: document.getElementById("runProbe"),
367
+ keepModels: document.getElementById("keepModels"),
368
+ metaLine: document.getElementById("metaLine"),
369
+ message: document.getElementById("message"),
370
+ results: document.getElementById("results"),
371
+ requestIndicator: document.getElementById("requestIndicator"),
372
+ };
373
+
374
+ async function api(path, options = {}) {
375
+ beginRequest();
376
+ try {
377
+ const res = await fetch(path, {
378
+ headers: { "content-type": "application/json" },
379
+ ...options,
380
+ });
381
+ if (!res.ok) {
382
+ let detail = res.statusText;
383
+ try { detail = (await res.json()).detail || detail; } catch {}
384
+ throw new Error(detail);
385
+ }
386
+ return res.json();
387
+ } finally {
388
+ endRequest();
389
+ }
390
+ }
391
+
392
+ function beginRequest() {
393
+ state.pendingRequests += 1;
394
+ els.requestIndicator.classList.toggle("visible", state.pendingRequests > 0);
395
+ }
396
+
397
+ function endRequest() {
398
+ state.pendingRequests = Math.max(0, state.pendingRequests - 1);
399
+ els.requestIndicator.classList.toggle("visible", state.pendingRequests > 0);
400
+ }
401
+
402
+ async function init() {
403
+ restoreControlValues();
404
+ const savedText = localStorage.getItem(STORAGE_KEYS.probeText);
405
+ if (savedText !== null) els.text.value = savedText;
406
+ try {
407
+ const modelsOut = await api("/api/models");
408
+ state.models = modelsOut.models || [];
409
+ els.model.innerHTML = state.models.map(model => `<option value="${escapeAttr(model.model_name)}">${escapeHtml(model.display_name || model.model_name)}</option>`).join("");
410
+ const savedModel = localStorage.getItem(STORAGE_KEYS.model);
411
+ if (savedModel && state.models.some(model => model.model_name === savedModel)) els.model.value = savedModel;
412
+ else if (state.models.some(model => model.model_name === "gpt2")) els.model.value = "gpt2";
413
+ await loadSaeMeta({ restoreLayer: true });
414
+ runProbe();
415
+ } catch (err) {
416
+ showError(err.message);
417
+ }
418
+ }
419
+
420
+ async function loadSaeMeta(options = {}) {
421
+ state.meta = await api(`/api/sae-meta?model=${encodeURIComponent(els.model.value)}`);
422
+ els.layer.innerHTML = state.meta.layers.map(layer => `<option value="${escapeAttr(layer)}">${escapeHtml(layer)}</option>`).join("");
423
+ const savedLayer = localStorage.getItem(STORAGE_KEYS.layer);
424
+ if (options.restoreLayer && savedLayer && state.meta.layers.includes(savedLayer)) els.layer.value = savedLayer;
425
+ else if (state.meta.layers.includes("layer_05")) els.layer.value = "layer_05";
426
+ restoreFeatureHighlightsForContext();
427
+ renderMetaLine();
428
+ }
429
+
430
+ function renderMetaLine() {
431
+ const sae = state.meta?.sae || {};
432
+ const parts = [
433
+ sae.repo_id ? `SAE: ${sae.repo_id}` : "",
434
+ sae.width ? `width=${sae.width}` : "",
435
+ sae.top_k ? `SAE top-k=${sae.top_k}` : "",
436
+ sae.activation ? `activation=${sae.activation}` : "",
437
+ ].filter(Boolean);
438
+ els.metaLine.textContent = parts.join(" · ");
439
+ }
440
+
441
+ async function runProbe() {
442
+ const requestId = ++state.requestId;
443
+ if (!els.text.value.trim()) {
444
+ els.results.innerHTML = "";
445
+ els.message.hidden = false;
446
+ els.message.className = "empty";
447
+ els.message.textContent = "Enter text to run the SAE probe.";
448
+ return;
449
+ }
450
+ if (!els.results.children.length) {
451
+ els.message.hidden = false;
452
+ els.message.className = "empty";
453
+ els.message.textContent = "Running SAE probe...";
454
+ }
455
+ try {
456
+ const out = await api("/api/sae-probe", {
457
+ method: "POST",
458
+ body: JSON.stringify({
459
+ text: els.text.value,
460
+ model_name: els.model.value,
461
+ layer: els.layer.value,
462
+ top_k: Number(els.topK.value || 5),
463
+ keep_models: els.keepModels.checked,
464
+ }),
465
+ });
466
+ if (requestId !== state.requestId) return;
467
+ renderResults(out);
468
+ } catch (err) {
469
+ if (requestId !== state.requestId) return;
470
+ showError(err.message);
471
+ }
472
+ }
473
+
474
+ function renderResults(out) {
475
+ state.lastProbeOutput = out;
476
+ if (out.truncated) {
477
+ els.message.hidden = false;
478
+ els.message.className = "empty";
479
+ els.message.textContent = `Input was truncated to ${out.max_length} tokens.`;
480
+ } else {
481
+ els.message.hidden = true;
482
+ }
483
+ els.results.innerHTML = (out.tokens || []).map(token => `
484
+ <div class="token-card">
485
+ <div class="token-text" title="${escapeAttr(token.token)}">${escapeHtml(token.token_text || token.token)}</div>
486
+ ${(token.top || []).map(feature => featureBadge(feature, tokenTopActivation(token))).join("")}
487
+ </div>
488
+ `).join("");
489
+ els.results.querySelectorAll(".badge[data-feature]").forEach(node => {
490
+ node.addEventListener("click", event => selectFeature(event, Number(node.dataset.feature)));
491
+ });
492
+ paintFeatureHighlights();
493
+ }
494
+
495
+ function featureBadge(feature, tokenTop) {
496
+ const id = Number(feature.feature);
497
+ const activation = Number(feature.activation || 0);
498
+ const ratio = tokenTop > 0 ? activation / tokenTop : 0;
499
+ const width = 100 * Math.max(0, Math.min(1, ratio));
500
+ const cutoff = Math.max(0, Math.min(1, Number(els.weakRatio.value || 0.5)));
501
+ const weak = Number.isFinite(tokenTop) && tokenTop > 0 && activation < tokenTop * cutoff;
502
+ const active = state.selectedFeatures.has(id);
503
+ return `
504
+ <div class="score-row">
505
+ <button class="badge ${active ? "hot" : ""} ${weak ? "weak" : ""}" type="button" data-feature="${id}" aria-pressed="${active ? "true" : "false"}" title="${escapeAttr(`F${id}: activation ${formatScore(activation)}, preactivation ${formatScore(Number(feature.preactivation || 0))}`)}" style="--score-width:${width.toFixed(1)}%;--score-bg:${featureColor(id)}22;--feature-color:${featureColor(id)}">
506
+ <span class="badge-main">F${id}</span>
507
+ </button>
508
+ <span class="score">${formatScore(activation)}</span>
509
+ </div>
510
+ `;
511
+ }
512
+
513
+ function tokenTopActivation(token) {
514
+ const values = (token.top || []).map(item => Number(item.activation || 0));
515
+ return values.length ? Math.max(...values) : 0;
516
+ }
517
+
518
+ function scheduleProbe() {
519
+ localStorage.setItem(STORAGE_KEYS.probeText, els.text.value);
520
+ persistControls();
521
+ }
522
+
523
+ function handleTextKeydown(event) {
524
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
525
+ event.preventDefault();
526
+ localStorage.setItem(STORAGE_KEYS.probeText, els.text.value);
527
+ persistControls();
528
+ runProbe();
529
+ }
530
+ }
531
+
532
+ function updateCardWidth() {
533
+ const value = Math.max(100, Math.min(360, Number(els.cardWidth.value || 140)));
534
+ document.documentElement.style.setProperty("--token-card-width", `${value}px`);
535
+ localStorage.setItem(STORAGE_KEYS.cardWidth, String(value));
536
+ }
537
+
538
+ function rerenderLastProbe() {
539
+ if (state.lastProbeOutput) renderResults(state.lastProbeOutput);
540
+ }
541
+
542
+ function selectFeature(event, feature) {
543
+ const id = Number(feature);
544
+ if (!Number.isFinite(id)) return;
545
+ if (event.ctrlKey || event.metaKey) {
546
+ if (state.selectedFeatures.has(id)) state.selectedFeatures.delete(id);
547
+ else state.selectedFeatures.add(id);
548
+ } else if (state.selectedFeatures.size === 1 && state.selectedFeatures.has(id)) {
549
+ state.selectedFeatures.clear();
550
+ } else {
551
+ state.selectedFeatures.clear();
552
+ state.selectedFeatures.add(id);
553
+ }
554
+ persistFeatureHighlights();
555
+ paintFeatureHighlights();
556
+ }
557
+
558
+ function paintFeatureHighlights() {
559
+ els.results.querySelectorAll(".badge[data-feature]").forEach(node => {
560
+ const active = state.selectedFeatures.has(Number(node.dataset.feature));
561
+ node.classList.toggle("hot", active);
562
+ node.setAttribute("aria-pressed", active ? "true" : "false");
563
+ });
564
+ }
565
+
566
+ function featureHighlightStorageKey(model = els.model.value, layer = els.layer.value) {
567
+ return `${model}:${layer}`;
568
+ }
569
+
570
+ function readFeatureHighlightStore() {
571
+ try {
572
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.highlights) || "{}");
573
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
574
+ } catch {
575
+ return {};
576
+ }
577
+ }
578
+
579
+ function persistFeatureHighlights() {
580
+ const store = readFeatureHighlightStore();
581
+ const key = featureHighlightStorageKey();
582
+ const values = [...state.selectedFeatures].filter(Number.isFinite).sort((a, b) => a - b);
583
+ if (values.length) store[key] = values;
584
+ else delete store[key];
585
+ localStorage.setItem(STORAGE_KEYS.highlights, JSON.stringify(store));
586
+ }
587
+
588
+ function restoreFeatureHighlightsForContext() {
589
+ const store = readFeatureHighlightStore();
590
+ const saved = Array.isArray(store[featureHighlightStorageKey()]) ? store[featureHighlightStorageKey()] : [];
591
+ state.selectedFeatures = new Set(saved.map(Number).filter(Number.isFinite));
592
+ }
593
+
594
+ function restoreControlValues() {
595
+ setNumberInputFromStorage(els.topK, STORAGE_KEYS.topK, 1, 128);
596
+ setNumberInputFromStorage(els.cardWidth, STORAGE_KEYS.cardWidth, 100, 360);
597
+ setNumberInputFromStorage(els.weakRatio, STORAGE_KEYS.weakRatio, 0, 1);
598
+ els.keepModels.checked = localStorage.getItem(STORAGE_KEYS.keepModels) !== "0";
599
+ }
600
+
601
+ function setNumberInputFromStorage(input, key, min, max) {
602
+ const raw = localStorage.getItem(key);
603
+ if (raw === null) return;
604
+ const value = Number(raw);
605
+ if (!Number.isFinite(value)) return;
606
+ input.value = String(Math.max(min, Math.min(max, value)));
607
+ }
608
+
609
+ function persistControls() {
610
+ localStorage.setItem(STORAGE_KEYS.model, els.model.value);
611
+ localStorage.setItem(STORAGE_KEYS.layer, els.layer.value);
612
+ localStorage.setItem(STORAGE_KEYS.topK, els.topK.value);
613
+ localStorage.setItem(STORAGE_KEYS.cardWidth, els.cardWidth.value);
614
+ localStorage.setItem(STORAGE_KEYS.weakRatio, els.weakRatio.value);
615
+ localStorage.setItem(STORAGE_KEYS.keepModels, els.keepModels.checked ? "1" : "0");
616
+ }
617
+
618
+ function showError(message) {
619
+ els.message.hidden = false;
620
+ els.message.className = "error";
621
+ els.message.textContent = message;
622
+ }
623
+
624
+ function featureColor(featureId) {
625
+ return `hsl(${(37 * Number(featureId)) % 360} 74% 48%)`;
626
+ }
627
+
628
+ function formatScore(value) {
629
+ const number = Number(value || 0);
630
+ const abs = Math.abs(number);
631
+ if (abs >= 100) return number.toFixed(0);
632
+ if (abs >= 10) return number.toFixed(1);
633
+ return number.toFixed(2);
634
+ }
635
+
636
+ function escapeHtml(value) {
637
+ return String(value).replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
638
+ }
639
+
640
+ function escapeAttr(value) {
641
+ return escapeHtml(value);
642
+ }
643
+
644
+ els.model.addEventListener("change", async () => {
645
+ persistControls();
646
+ try {
647
+ await loadSaeMeta();
648
+ persistControls();
649
+ runProbe();
650
+ } catch (err) {
651
+ showError(err.message);
652
+ }
653
+ });
654
+ els.layer.addEventListener("change", () => { persistControls(); restoreFeatureHighlightsForContext(); runProbe(); });
655
+ els.topK.addEventListener("change", () => { persistControls(); runProbe(); });
656
+ els.topK.addEventListener("input", () => { persistControls(); runProbe(); });
657
+ els.cardWidth.addEventListener("change", updateCardWidth);
658
+ els.cardWidth.addEventListener("input", updateCardWidth);
659
+ els.weakRatio.addEventListener("change", () => { persistControls(); rerenderLastProbe(); });
660
+ els.weakRatio.addEventListener("input", () => { persistControls(); rerenderLastProbe(); });
661
+ els.keepModels.addEventListener("change", () => { persistControls(); if (!els.keepModels.checked) runProbe(); });
662
+ els.text.addEventListener("input", scheduleProbe);
663
+ els.text.addEventListener("keydown", handleTextKeydown);
664
+ els.runProbe.addEventListener("click", () => {
665
+ localStorage.setItem(STORAGE_KEYS.probeText, els.text.value);
666
+ persistControls();
667
+ runProbe();
668
+ });
669
+ updateCardWidth();
670
+ init();
671
+ </script>
672
+ </body>
673
+ </html>
server/static/stats.html ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ICA Annotation Stats</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f7f9;
10
+ --panel: #fff;
11
+ --text: #151922;
12
+ --muted: #647084;
13
+ --border: #cbd3df;
14
+ --accent: #1f6feb;
15
+ --shadow: 0 1px 2px rgb(20 25 34 / .08), 0 10px 30px rgb(20 25 34 / .06);
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body {
19
+ margin: 0;
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23
+ }
24
+ header {
25
+ position: sticky;
26
+ top: 0;
27
+ z-index: 5;
28
+ background: var(--panel);
29
+ border-bottom: 1px solid var(--border);
30
+ box-shadow: var(--shadow);
31
+ padding: 12px 18px;
32
+ }
33
+ .top {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: space-between;
37
+ gap: 14px;
38
+ }
39
+ h1 { margin: 0; font-size: 19px; letter-spacing: 0; }
40
+ .nav {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 14px;
44
+ }
45
+ a {
46
+ color: var(--accent);
47
+ font-weight: 750;
48
+ text-decoration: none;
49
+ }
50
+ a:hover { text-decoration: underline; }
51
+ main {
52
+ width: 100%;
53
+ margin: 0;
54
+ padding: 12px;
55
+ }
56
+ .panel {
57
+ background: var(--panel);
58
+ border: 1px solid var(--border);
59
+ border-radius: 8px;
60
+ box-shadow: var(--shadow);
61
+ padding: 12px;
62
+ }
63
+ .toolbar {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ gap: 12px;
68
+ margin-bottom: 12px;
69
+ color: var(--muted);
70
+ font-size: 12px;
71
+ font-weight: 700;
72
+ }
73
+ .model-control {
74
+ display: inline-flex;
75
+ align-items: center;
76
+ gap: 7px;
77
+ color: #435066;
78
+ font-size: 12px;
79
+ font-weight: 800;
80
+ white-space: nowrap;
81
+ }
82
+ .model-control select {
83
+ width: 210px;
84
+ border: 1px solid var(--border);
85
+ border-radius: 7px;
86
+ background: #fff;
87
+ color: var(--text);
88
+ font: inherit;
89
+ padding: 7px 9px;
90
+ }
91
+ .layer-control select {
92
+ width: 160px;
93
+ }
94
+ .toolbar-left {
95
+ display: flex;
96
+ flex-wrap: wrap;
97
+ align-items: center;
98
+ gap: 10px 14px;
99
+ }
100
+ .legend {
101
+ display: flex;
102
+ flex-wrap: wrap;
103
+ gap: 8px 12px;
104
+ align-items: center;
105
+ }
106
+ .legend-item {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ gap: 5px;
110
+ white-space: nowrap;
111
+ }
112
+ .legend-toggle {
113
+ width: auto;
114
+ border: 0;
115
+ background: transparent;
116
+ color: inherit;
117
+ font: inherit;
118
+ font-weight: inherit;
119
+ padding: 0;
120
+ cursor: pointer;
121
+ }
122
+ .legend-toggle:hover { color: var(--accent); }
123
+ .matrix {
124
+ overflow: visible;
125
+ border: 1px solid var(--border);
126
+ border-radius: 7px;
127
+ background: #fff;
128
+ }
129
+ .layer-row {
130
+ display: grid;
131
+ grid-template-columns: 104px 86px minmax(0, 1fr);
132
+ align-items: start;
133
+ gap: 8px;
134
+ width: 100%;
135
+ padding: 6px 8px;
136
+ border-bottom: 1px solid #e5eaf2;
137
+ }
138
+ .layer-row:last-child { border-bottom: 0; }
139
+ .layer-name {
140
+ font-weight: 850;
141
+ white-space: nowrap;
142
+ }
143
+ .layer-count {
144
+ color: var(--muted);
145
+ font-size: 12px;
146
+ font-variant-numeric: tabular-nums;
147
+ text-align: right;
148
+ white-space: nowrap;
149
+ }
150
+ .dots {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ gap: 3px;
154
+ align-items: flex-start;
155
+ min-width: 0;
156
+ }
157
+ .component-pair {
158
+ display: inline-flex;
159
+ gap: 1px;
160
+ margin-right: 2px;
161
+ flex: 0 0 auto;
162
+ }
163
+ .dot {
164
+ display: inline-flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ width: 13px;
168
+ height: 14px;
169
+ border: 1px solid #cbd5e1;
170
+ border-radius: 4px;
171
+ background: #f8fafc;
172
+ color: #64748b;
173
+ font-size: 8px;
174
+ font-weight: 850;
175
+ line-height: 1;
176
+ font-variant-numeric: tabular-nums;
177
+ flex: 0 0 auto;
178
+ text-decoration: none;
179
+ }
180
+ .dot:hover { text-decoration: none; outline: 2px solid rgb(31 111 235 / .25); outline-offset: 1px; }
181
+ .dot.high { color: #fff; background: #16a34a; border-color: #15803d; }
182
+ .dot.medium { color: #166534; background: #fef3c7; border-color: #d9b94e; }
183
+ .dot.low { color: #9f1239; background: #ffe4e6; border-color: #f9a8d4; }
184
+ .dot.unclear { color: #475569; background: #e5e7eb; border-color: #cbd5e1; }
185
+ .dot.auto {
186
+ color: #3b2600;
187
+ background: #f6c343;
188
+ border-color: #b77900;
189
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / .38);
190
+ }
191
+ .dot.erf-value {
192
+ overflow: hidden;
193
+ font-size: 7px;
194
+ }
195
+ .empty, .error {
196
+ border: 1px dashed var(--border);
197
+ border-radius: 8px;
198
+ padding: 18px;
199
+ color: var(--muted);
200
+ background: #fff;
201
+ }
202
+ .error { color: #a41414; border-color: #f0b9b9; background: #fff7f7; }
203
+ @media (max-width: 760px) {
204
+ main { padding: 8px; }
205
+ header { padding: 10px 12px; }
206
+ .top { align-items: flex-start; flex-direction: column; }
207
+ .toolbar { align-items: flex-start; flex-direction: column; }
208
+ .layer-row { grid-template-columns: 74px 58px minmax(0, 1fr); gap: 6px; padding: 6px; }
209
+ .dot { width: 11px; height: 12px; border-radius: 3px; font-size: 7px; }
210
+ }
211
+ </style>
212
+ </head>
213
+ <body>
214
+ <header>
215
+ <div class="top">
216
+ <h1>ICA Annotation Stats</h1>
217
+ <nav class="nav" aria-label="Primary">
218
+ <a href="/">Explorer</a>
219
+ <a href="/sae-explorer">SAE Explorer</a>
220
+ <a href="/stats">Stats</a>
221
+ <a href="/annotate">Annotate</a>
222
+ <a href="/random-components">Random</a>
223
+ </nav>
224
+ </div>
225
+ </header>
226
+
227
+ <main>
228
+ <div class="panel">
229
+ <div class="toolbar">
230
+ <div class="toolbar-left">
231
+ <label class="model-control">
232
+ Model
233
+ <select id="modelSelect"></select>
234
+ </label>
235
+ <label class="model-control layer-control">
236
+ Layer
237
+ <select id="layerSelect" disabled></select>
238
+ </label>
239
+ <div id="summary">Please select a model.</div>
240
+ </div>
241
+ <div class="legend" aria-label="Confidence legend">
242
+ <span class="legend-item"><span class="dot high">W</span> high</span>
243
+ <span class="legend-item"><span class="dot medium">W</span> medium</span>
244
+ <span class="legend-item"><span class="dot low">W</span> low</span>
245
+ <span class="legend-item"><span class="dot unclear">?</span> unclear</span>
246
+ <button id="autoToggle" class="legend-item legend-toggle" type="button" aria-pressed="true" title="Toggle auto-annotation coloring"><span class="dot auto">W</span> auto</button>
247
+ <button id="erfToggle" class="legend-item legend-toggle" type="button" aria-pressed="false" title="Toggle Effective Receptive Field labels"><span class="dot erf-value">11</span> ERF</button>
248
+ <span class="legend-item"><span class="dot"></span> blank</span>
249
+ </div>
250
+ </div>
251
+ <div id="message" class="empty">Loading all component stats...</div>
252
+ <div id="matrix" class="matrix" hidden></div>
253
+ </div>
254
+ </main>
255
+ <script>
256
+ const els = {
257
+ model: document.getElementById("modelSelect"),
258
+ layer: document.getElementById("layerSelect"),
259
+ summary: document.getElementById("summary"),
260
+ message: document.getElementById("message"),
261
+ matrix: document.getElementById("matrix"),
262
+ autoToggle: document.getElementById("autoToggle"),
263
+ erfToggle: document.getElementById("erfToggle"),
264
+ };
265
+
266
+ const STORAGE_KEYS = {
267
+ model: "icaExplorer.model",
268
+ layer: "icaExplorer.layer",
269
+ };
270
+
271
+ const state = {
272
+ model: "",
273
+ layer: "",
274
+ layers: [],
275
+ showAuto: true,
276
+ showErf: false,
277
+ };
278
+
279
+ async function api(path) {
280
+ const res = await fetch(path, { headers: { "content-type": "application/json" } });
281
+ if (!res.ok) {
282
+ let detail = res.statusText;
283
+ try { detail = (await res.json()).detail || detail; } catch {}
284
+ throw new Error(detail);
285
+ }
286
+ return res.json();
287
+ }
288
+
289
+ async function init() {
290
+ try {
291
+ const params = new URLSearchParams(location.search);
292
+ const urlModel = params.get("model") || "";
293
+ const urlLayer = normalizeUrlLayer(params.get("layer") || "");
294
+ const storedModel = localStorage.getItem(STORAGE_KEYS.model) || "";
295
+ const storedLayer = localStorage.getItem(STORAGE_KEYS.layer) || "";
296
+ const requestedModel = urlModel || storedModel;
297
+ const requestedLayer = urlLayer || storedLayer;
298
+ const models = await api("/api/models");
299
+ const names = (models.models || []).map(model => model.model_name);
300
+ fillSelect(els.model, ["", ...names], names.includes(requestedModel) ? requestedModel : "");
301
+ els.model.options[0].textContent = "Please select";
302
+ fillSelect(els.layer, [""], "");
303
+ els.layer.options[0].textContent = "Please select";
304
+ state.model = els.model.value;
305
+ els.model.addEventListener("change", () => {
306
+ state.model = els.model.value;
307
+ state.layer = "";
308
+ persistSelection();
309
+ updateUrl();
310
+ loadLayers();
311
+ });
312
+ els.layer.addEventListener("change", () => {
313
+ state.layer = els.layer.value;
314
+ persistSelection();
315
+ updateUrl();
316
+ loadStats();
317
+ });
318
+ if (state.model) await loadLayers({ requestedLayer });
319
+ else clearStats("Please select a model.");
320
+ } catch (err) {
321
+ showError(err.message);
322
+ }
323
+ }
324
+
325
+ async function loadLayers(options = {}) {
326
+ state.layers = [];
327
+ els.matrix.innerHTML = "";
328
+ els.matrix.hidden = true;
329
+ if (!state.model) {
330
+ els.layer.disabled = true;
331
+ fillSelect(els.layer, [""], "");
332
+ els.layer.options[0].textContent = "Please select";
333
+ clearStats("Please select a model.");
334
+ return;
335
+ }
336
+ els.summary.textContent = "Loading layers...";
337
+ const out = await api(`/api/layers?model=${encodeURIComponent(state.model)}`);
338
+ const layerValues = ["", "__all__", ...(out.layers || [])];
339
+ const requestedLayer = layerValues.includes(options.requestedLayer) ? options.requestedLayer : "";
340
+ fillSelect(els.layer, layerValues, requestedLayer);
341
+ els.layer.options[0].textContent = "Please select";
342
+ els.layer.options[1].textContent = "All layers";
343
+ els.layer.disabled = false;
344
+ state.layer = els.layer.value;
345
+ persistSelection();
346
+ if (state.layer) await loadStats();
347
+ else clearStats("Please select a layer.");
348
+ }
349
+
350
+ async function loadStats() {
351
+ if (!state.model) {
352
+ clearStats("Please select a model.");
353
+ return;
354
+ }
355
+ if (!state.layer) {
356
+ clearStats("Please select a layer.");
357
+ return;
358
+ }
359
+ els.summary.textContent = state.layer === "__all__" ? "Loading all layers..." : `Loading ${state.layer}...`;
360
+ if (state.layer === "__all__") {
361
+ const out = await api(`/api/component-stats?model=${encodeURIComponent(state.model)}`);
362
+ state.layers = out.layers || [];
363
+ } else {
364
+ const out = await api(`/api/components?model=${encodeURIComponent(state.model)}&layer=${encodeURIComponent(state.layer)}`);
365
+ state.layers = [{ layer: state.layer, components: out.components || [] }];
366
+ }
367
+ renderStats(state.layers);
368
+ }
369
+
370
+ function renderStats(layers) {
371
+ const components = layers.flatMap(layer => layer.components || []);
372
+ const annotated = components.reduce((total, component) => total + annotatedDirectionCount(component), 0);
373
+ const totalDirections = components.length * 2;
374
+ const scope = state.layer === "__all__" ? `${layers.length} layers` : state.layer;
375
+ els.summary.textContent = `${state.model} - ${scope} - ${annotated} / ${totalDirections} directions annotated`;
376
+ if (!layers.length) {
377
+ els.message.hidden = false;
378
+ els.message.className = "empty";
379
+ els.message.textContent = "No components found.";
380
+ els.matrix.hidden = true;
381
+ return;
382
+ }
383
+ els.message.hidden = true;
384
+ els.matrix.hidden = false;
385
+ els.matrix.innerHTML = layers.map(layer => layerRow(layer)).join("");
386
+ }
387
+
388
+ function clearStats(message) {
389
+ state.layers = [];
390
+ els.summary.textContent = message;
391
+ els.message.hidden = false;
392
+ els.message.className = "empty";
393
+ els.message.textContent = message;
394
+ els.matrix.hidden = true;
395
+ els.matrix.innerHTML = "";
396
+ }
397
+
398
+ function updateUrl() {
399
+ const params = new URLSearchParams();
400
+ if (state.model) params.set("model", state.model);
401
+ if (state.layer) params.set("layer", state.layer === "__all__" ? "all" : state.layer);
402
+ const query = params.toString();
403
+ history.replaceState(null, "", query ? `${location.pathname}?${query}` : location.pathname);
404
+ }
405
+
406
+ function persistSelection() {
407
+ if (state.model) localStorage.setItem(STORAGE_KEYS.model, state.model);
408
+ else localStorage.removeItem(STORAGE_KEYS.model);
409
+ if (state.layer && state.layer !== "__all__") localStorage.setItem(STORAGE_KEYS.layer, state.layer);
410
+ }
411
+
412
+ function normalizeUrlLayer(value) {
413
+ return value === "all" ? "__all__" : value;
414
+ }
415
+
416
+ els.autoToggle.addEventListener("click", () => {
417
+ state.showAuto = !state.showAuto;
418
+ els.autoToggle.setAttribute("aria-pressed", state.showAuto ? "true" : "false");
419
+ if (state.layers.length) renderStats(state.layers);
420
+ });
421
+
422
+ els.erfToggle.addEventListener("click", () => {
423
+ state.showErf = !state.showErf;
424
+ els.erfToggle.setAttribute("aria-pressed", state.showErf ? "true" : "false");
425
+ if (state.layers.length) renderStats(state.layers);
426
+ });
427
+
428
+ function layerRow(layer) {
429
+ const components = layer.components || [];
430
+ const annotated = components.reduce((total, component) => total + annotatedDirectionCount(component), 0);
431
+ const totalDirections = components.length * 2;
432
+ return `
433
+ <div class="layer-row">
434
+ <div class="layer-name">${escapeHtml(layer.layer)}</div>
435
+ <div class="layer-count">${annotated}/${totalDirections}</div>
436
+ <div class="dots">${components.map(component => componentPair(layer.layer, component)).join("")}</div>
437
+ </div>
438
+ `;
439
+ }
440
+
441
+ function componentPair(layer, component) {
442
+ return `
443
+ <span class="component-pair">
444
+ ${componentDirectionDot(layer, component, "positive")}
445
+ ${componentDirectionDot(layer, component, "negative")}
446
+ </span>
447
+ `;
448
+ }
449
+
450
+ function componentDirectionDot(layer, component, side) {
451
+ const annotation = annotationSide(component, side);
452
+ const title = componentTitle(component, annotation, side);
453
+ const href = componentExamplesUrl(layer, component);
454
+ const text = state.showErf ? erfText(component) : (annotation?.typeLetter || "?");
455
+ const erfClass = state.showErf ? " erf-value" : "";
456
+ if (!annotation) {
457
+ return `<a class="dot${erfClass}" href="${escapeAttr(href)}" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}">${escapeHtml(state.showErf ? text : "")}</a>`;
458
+ }
459
+ const classes = ["dot", annotation.confidence];
460
+ if (state.showErf) classes.push("erf-value");
461
+ if (state.showAuto && component[`${side}_auto_annotated`] && annotation.annotated) classes.push("auto");
462
+ return `<a class="${escapeAttr(classes.join(" "))}" href="${escapeAttr(href)}" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}">${escapeHtml(text)}</a>`;
463
+ }
464
+
465
+ function erfText(component) {
466
+ const value = Number(component.effective_context_mean);
467
+ return Number.isFinite(value) ? String(Math.round(value)) : "";
468
+ }
469
+
470
+ function componentExamplesUrl(layer, component) {
471
+ const params = new URLSearchParams({
472
+ model: state.model,
473
+ layer: String(layer),
474
+ component: String(component.component),
475
+ });
476
+ return `/component?${params.toString()}`;
477
+ }
478
+
479
+ function annotatedDirectionCount(component) {
480
+ return ["positive", "negative"].reduce((total, side) => {
481
+ const annotation = annotationSide(component, side);
482
+ return total + (annotation?.annotated ? 1 : 0);
483
+ }, 0);
484
+ }
485
+
486
+ function annotationSide(component, side) {
487
+ const confidence = normalizedConfidence(component[`${side}_confidence`]);
488
+ const label = visibleAnnotationLabel(component[`${side}_label`], confidence);
489
+ if (!label) return null;
490
+ const types = Array.isArray(component[`${side}_types`]) ? component[`${side}_types`] : [];
491
+ const annotated = !(label === "?" && confidence === "unclear");
492
+ return { side, label, confidence, types, typeLetter: typeLetter(types), annotated };
493
+ }
494
+
495
+ function componentTitle(component, annotation, side) {
496
+ const id = `C${Number(component.component)}`;
497
+ const kurtosis = Number.isFinite(Number(component.excess_kurtosis)) ? ` - kurtosis ${Number(component.excess_kurtosis).toFixed(2)}` : "";
498
+ if (!annotation) return `${id}: ${side} blank${kurtosis}`;
499
+ const type = annotation.types.length ? ` - ${annotation.types.join(", ")}` : "";
500
+ const source = state.showAuto && component[`${side}_auto_annotated`] && annotation.annotated ? " - auto-annotated" : "";
501
+ const erf = Number.isFinite(Number(component.effective_context_mean)) ? ` - mean ERF ${Number(component.effective_context_mean).toFixed(2)}` : "";
502
+ return `${id}: ${annotation.side} ${annotation.label} - ${annotation.confidence}${type}${source}${erf}${kurtosis}`;
503
+ }
504
+
505
+ function visibleAnnotationLabel(value, confidence) {
506
+ const text = String(value || "").trim();
507
+ if (!text) return "";
508
+ return text.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
509
+ }
510
+
511
+ function normalizedConfidence(value) {
512
+ const confidence = String(value || "unclear").toLowerCase();
513
+ return ["high", "medium", "low", "unclear"].includes(confidence) ? confidence : "unclear";
514
+ }
515
+
516
+ function typeLetter(types) {
517
+ const labels = Array.isArray(types) ? types : [];
518
+ const priority = [
519
+ ["Form", "F"],
520
+ ["Word", "W"],
521
+ ["Phrase", "P"],
522
+ ["Sentence", "S"],
523
+ ["Long-Range Context", "L"],
524
+ ["Global", "G"],
525
+ ["Position", "O"],
526
+ ["Sophisticated", "X"],
527
+ ];
528
+ for (const [name, letter] of priority) {
529
+ if (labels.some(label => String(label).toLowerCase() === name.toLowerCase())) return letter;
530
+ }
531
+ const first = labels.find(label => String(label).trim());
532
+ return first ? String(first).trim()[0].toUpperCase() : "";
533
+ }
534
+
535
+ function showError(message) {
536
+ els.message.hidden = false;
537
+ els.message.className = "error";
538
+ els.message.textContent = message;
539
+ els.matrix.hidden = true;
540
+ }
541
+
542
+ function fillSelect(select, values, current) {
543
+ select.innerHTML = values.map(value => `<option value="${escapeAttr(value)}" ${value === current ? "selected" : ""}>${escapeHtml(value)}</option>`).join("");
544
+ }
545
+
546
+ function escapeHtml(value) {
547
+ return String(value).replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
548
+ }
549
+
550
+ function escapeAttr(value) {
551
+ return escapeHtml(value);
552
+ }
553
+
554
+ init();
555
+ </script>
556
+ </body>
557
+ </html>
server/store.py ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any, Iterable
7
+
8
+
9
+ SCHEMA = """
10
+ CREATE TABLE IF NOT EXISTS annotations (
11
+ model_name TEXT NOT NULL,
12
+ layer TEXT NOT NULL,
13
+ component INTEGER NOT NULL,
14
+ label TEXT,
15
+ confidence TEXT,
16
+ positive_label TEXT,
17
+ positive_confidence TEXT,
18
+ negative_label TEXT,
19
+ negative_confidence TEXT,
20
+ positive_interpretation_types_json TEXT,
21
+ negative_interpretation_types_json TEXT,
22
+ interpretation_types_json TEXT,
23
+ summary TEXT,
24
+ notes TEXT,
25
+ include_as_case_study INTEGER NOT NULL DEFAULT 0,
26
+ updated_at TEXT NOT NULL,
27
+ PRIMARY KEY (model_name, layer, component)
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS chosen_random_components (
31
+ selection_name TEXT NOT NULL,
32
+ model_name TEXT NOT NULL,
33
+ selection_index INTEGER NOT NULL,
34
+ layer TEXT NOT NULL,
35
+ component INTEGER NOT NULL,
36
+ component_id TEXT,
37
+ source_json TEXT,
38
+ seed INTEGER,
39
+ requested_n INTEGER,
40
+ inventory_size INTEGER,
41
+ selected_size INTEGER,
42
+ fit_converged INTEGER,
43
+ fit_iterations INTEGER,
44
+ fit_final_lim REAL,
45
+ fit_final_lim_p95 REAL,
46
+ fit_seed INTEGER,
47
+ inserted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
48
+ PRIMARY KEY (selection_name, model_name, selection_index)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_chosen_random_components_component
52
+ ON chosen_random_components(model_name, layer, component);
53
+ """
54
+
55
+
56
+ def connect(db_path: Path) -> sqlite3.Connection:
57
+ db_path.parent.mkdir(parents=True, exist_ok=True)
58
+ conn = sqlite3.connect(str(db_path), check_same_thread=False)
59
+ conn.row_factory = sqlite3.Row
60
+ conn.execute("PRAGMA foreign_keys = ON")
61
+ return conn
62
+
63
+
64
+ def init_db(conn: sqlite3.Connection) -> None:
65
+ conn.executescript(SCHEMA)
66
+ _ensure_annotation_columns(conn)
67
+ conn.commit()
68
+
69
+
70
+ def validate_db(conn: sqlite3.Connection, model_name: str | None = None) -> None:
71
+ tables = {str(row[0]) for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()}
72
+ missing = {"components", "examples", "annotations"} - tables
73
+ if missing:
74
+ raise RuntimeError(f"SQLite DB is missing required table(s): {', '.join(sorted(missing))}")
75
+ if model_name:
76
+ count = conn.execute("SELECT COUNT(*) FROM components WHERE model_name = ?", (model_name,)).fetchone()[0]
77
+ if int(count) == 0:
78
+ raise RuntimeError(f"SQLite DB has no components for model_name={model_name!r}")
79
+
80
+
81
+ def list_models(conn: sqlite3.Connection) -> list[str]:
82
+ rows = conn.execute(
83
+ """
84
+ SELECT model_name FROM components
85
+ UNION
86
+ SELECT model_name FROM annotations
87
+ ORDER BY model_name
88
+ """
89
+ ).fetchall()
90
+ return [str(row[0]) for row in rows]
91
+
92
+
93
+ def list_layers(conn: sqlite3.Connection, model_name: str) -> list[str]:
94
+ rows = conn.execute(
95
+ "SELECT DISTINCT layer FROM components WHERE model_name = ?",
96
+ (model_name,),
97
+ ).fetchall()
98
+ return sorted([str(row[0]) for row in rows], key=layer_sort_key)
99
+
100
+
101
+ def list_components(conn: sqlite3.Connection, model_name: str, layer: str | None = None, component: int | None = None, search: str | None = None) -> list[dict[str, Any]]:
102
+ erf_join, erf_select = _erf_join_sql(conn)
103
+ rows = conn.execute(
104
+ f"""
105
+ SELECT c.layer,
106
+ c.component,
107
+ c.excess_kurtosis,
108
+ {erf_select}
109
+ a.positive_label,
110
+ a.positive_confidence,
111
+ a.negative_label,
112
+ a.negative_confidence,
113
+ a.positive_interpretation_types_json,
114
+ a.negative_interpretation_types_json,
115
+ a.summary,
116
+ a.notes,
117
+ a.include_as_case_study
118
+ FROM components c
119
+ LEFT JOIN annotations a
120
+ ON a.model_name = c.model_name
121
+ AND a.layer = c.layer
122
+ AND a.component = c.component
123
+ {erf_join}
124
+ WHERE c.model_name = ?
125
+ AND (? IS NULL OR c.layer = ?)
126
+ AND (? IS NULL OR c.component = ?)
127
+ """,
128
+ (model_name, layer, layer, component, component),
129
+ ).fetchall()
130
+ query = str(search or "").strip().lower()
131
+ out = []
132
+ for row in rows:
133
+ item = {
134
+ "layer": str(row["layer"]),
135
+ "component": int(row["component"]),
136
+ "excess_kurtosis": float(row["excess_kurtosis"]) if row["excess_kurtosis"] is not None else None,
137
+ "effective_context_mean": float(row["effective_context_mean"]) if row["effective_context_mean"] is not None else None,
138
+ **_annotation_row(row),
139
+ }
140
+ text = " ".join(str(item.get(key, "")) for key in ("component", "positive_label", "negative_label", "summary", "notes")).lower()
141
+ if query and query not in text:
142
+ continue
143
+ out.append(item)
144
+ return sorted(out, key=lambda row: (layer_sort_key(row["layer"]), row["component"]))
145
+
146
+
147
+ def list_component_stats(conn: sqlite3.Connection, model_name: str) -> list[dict[str, Any]]:
148
+ erf_join, erf_select = _erf_join_sql(conn)
149
+ rows = conn.execute(
150
+ f"""
151
+ SELECT c.layer,
152
+ c.component,
153
+ c.excess_kurtosis,
154
+ {erf_select}
155
+ a.positive_label,
156
+ a.positive_confidence,
157
+ a.negative_label,
158
+ a.negative_confidence,
159
+ a.positive_interpretation_types_json,
160
+ a.negative_interpretation_types_json,
161
+ a.notes
162
+ FROM components c
163
+ LEFT JOIN annotations a
164
+ ON a.model_name = c.model_name
165
+ AND a.layer = c.layer
166
+ AND a.component = c.component
167
+ {erf_join}
168
+ WHERE c.model_name = ?
169
+ """,
170
+ (model_name,),
171
+ ).fetchall()
172
+ return sorted(
173
+ [
174
+ {
175
+ "layer": str(row["layer"]),
176
+ "effective_context_mean": float(row["effective_context_mean"]) if row["effective_context_mean"] is not None else None,
177
+ **_annotation_row(row),
178
+ }
179
+ for row in rows
180
+ ],
181
+ key=lambda row: (layer_sort_key(row["layer"]), row["component"]),
182
+ )
183
+
184
+
185
+ def list_annotated_components(conn: sqlite3.Connection, model_name: str, layer: str) -> list[dict[str, Any]]:
186
+ erf_join, erf_select = _erf_join_sql(conn)
187
+ rows = conn.execute(
188
+ f"""
189
+ SELECT a.component,
190
+ {erf_select}
191
+ a.positive_label, a.positive_confidence,
192
+ a.negative_label, a.negative_confidence,
193
+ a.positive_interpretation_types_json,
194
+ a.negative_interpretation_types_json,
195
+ a.notes,
196
+ c.excess_kurtosis
197
+ FROM annotations a
198
+ LEFT JOIN components c
199
+ ON c.model_name = a.model_name
200
+ AND c.layer = a.layer
201
+ AND c.component = a.component
202
+ {erf_join}
203
+ WHERE a.model_name = ? AND a.layer = ?
204
+ AND (
205
+ (a.positive_label IS NOT NULL AND TRIM(a.positive_label) != '')
206
+ OR (a.negative_label IS NOT NULL AND TRIM(a.negative_label) != '')
207
+ )
208
+ ORDER BY a.component
209
+ """,
210
+ (model_name, layer),
211
+ ).fetchall()
212
+ out = []
213
+ for row in rows:
214
+ item = _annotation_row(row)
215
+ item["effective_context_mean"] = float(row["effective_context_mean"]) if row["effective_context_mean"] is not None else None
216
+ out.append(item)
217
+ return out
218
+
219
+
220
+
221
+ def list_component_metadata(conn: sqlite3.Connection, model_name: str, layer: str, components: list[int]) -> list[dict[str, Any]]:
222
+ if not components:
223
+ return []
224
+ erf_join, erf_select = _erf_join_sql(conn)
225
+ unique_components = sorted({int(component) for component in components})
226
+ placeholders = ",".join("?" for _ in unique_components)
227
+ rows = conn.execute(
228
+ f"""
229
+ SELECT c.component,
230
+ c.excess_kurtosis,
231
+ {erf_select}
232
+ a.positive_label,
233
+ a.positive_confidence,
234
+ a.negative_label,
235
+ a.negative_confidence,
236
+ a.positive_interpretation_types_json,
237
+ a.negative_interpretation_types_json,
238
+ a.summary,
239
+ a.notes,
240
+ a.include_as_case_study
241
+ FROM components c
242
+ LEFT JOIN annotations a
243
+ ON a.model_name = c.model_name
244
+ AND a.layer = c.layer
245
+ AND a.component = c.component
246
+ {erf_join}
247
+ WHERE c.model_name = ? AND c.layer = ? AND c.component IN ({placeholders})
248
+ ORDER BY c.component
249
+ """,
250
+ (model_name, layer, *unique_components),
251
+ ).fetchall()
252
+ out = []
253
+ for row in rows:
254
+ item = _annotation_row(row)
255
+ item["effective_context_mean"] = float(row["effective_context_mean"]) if row["effective_context_mean"] is not None else None
256
+ out.append(item)
257
+ return out
258
+
259
+
260
+ def list_component_neighbors(conn: sqlite3.Connection, model_name: str, layer: str, component: int) -> list[dict[str, Any]]:
261
+ if not _table_exists(conn, "component_neighbors"):
262
+ return []
263
+ neighbor_columns = _table_columns(conn, "component_neighbors")
264
+ neighbor_sign_select = "n.neighbor_sign" if "neighbor_sign" in neighbor_columns else "CASE WHEN n.signed_cosine >= 0 THEN 1 ELSE -1 END"
265
+ erf_join, erf_select = _component_neighbor_erf_join_sql(conn)
266
+ rows = conn.execute(
267
+ f"""
268
+ SELECT n.direction,
269
+ n.neighbor_layer,
270
+ n.neighbor_component AS component,
271
+ n.abs_cosine,
272
+ n.signed_cosine,
273
+ {neighbor_sign_select} AS neighbor_sign,
274
+ n.source_n_components,
275
+ n.neighbor_n_components,
276
+ {erf_select}
277
+ a.positive_label,
278
+ a.positive_confidence,
279
+ a.negative_label,
280
+ a.negative_confidence,
281
+ a.positive_interpretation_types_json,
282
+ a.negative_interpretation_types_json,
283
+ a.summary,
284
+ a.notes,
285
+ a.include_as_case_study,
286
+ c.excess_kurtosis
287
+ FROM component_neighbors n
288
+ LEFT JOIN annotations a
289
+ ON a.model_name = n.model_name
290
+ AND a.layer = n.neighbor_layer
291
+ AND a.component = n.neighbor_component
292
+ LEFT JOIN components c
293
+ ON c.model_name = n.model_name
294
+ AND c.layer = n.neighbor_layer
295
+ AND c.component = n.neighbor_component
296
+ {erf_join}
297
+ WHERE n.model_name = ?
298
+ AND n.layer = ?
299
+ AND n.component = ?
300
+ ORDER BY CASE n.direction WHEN 'prev' THEN 0 WHEN 'next' THEN 1 ELSE 2 END
301
+ """,
302
+ (model_name, layer, int(component)),
303
+ ).fetchall()
304
+ out = []
305
+ for row in rows:
306
+ item = _annotation_row(row)
307
+ item.update(
308
+ {
309
+ "direction": str(row["direction"]),
310
+ "neighbor_layer": str(row["neighbor_layer"]),
311
+ "neighbor_component": int(row["component"]),
312
+ "abs_cosine": float(row["abs_cosine"]),
313
+ "signed_cosine": float(row["signed_cosine"]),
314
+ "neighbor_sign": int(row["neighbor_sign"]),
315
+ "source_n_components": int(row["source_n_components"]),
316
+ "neighbor_n_components": int(row["neighbor_n_components"]),
317
+ "effective_context_mean": float(row["effective_context_mean"]) if row["effective_context_mean"] is not None else None,
318
+ }
319
+ )
320
+ out.append(item)
321
+ return out
322
+
323
+
324
+ def list_chosen_random_components(conn: sqlite3.Connection, model_name: str | None = None, selection_name: str | None = None) -> list[dict[str, Any]]:
325
+ if not _table_exists(conn, "chosen_random_components"):
326
+ return []
327
+ erf_join, erf_select = _erf_join_sql(conn)
328
+ rows = conn.execute(
329
+ f"""
330
+ SELECT r.selection_name,
331
+ r.model_name,
332
+ r.selection_index,
333
+ r.layer,
334
+ r.component,
335
+ r.component_id,
336
+ r.source_json,
337
+ r.seed,
338
+ r.requested_n,
339
+ r.inventory_size,
340
+ r.selected_size,
341
+ r.fit_converged,
342
+ r.fit_iterations,
343
+ r.fit_final_lim,
344
+ r.fit_final_lim_p95,
345
+ r.fit_seed,
346
+ c.excess_kurtosis,
347
+ {erf_select}
348
+ a.positive_label,
349
+ a.positive_confidence,
350
+ a.negative_label,
351
+ a.negative_confidence,
352
+ a.positive_interpretation_types_json,
353
+ a.negative_interpretation_types_json,
354
+ a.summary,
355
+ a.notes,
356
+ a.include_as_case_study
357
+ FROM chosen_random_components r
358
+ LEFT JOIN components c
359
+ ON c.model_name = r.model_name
360
+ AND c.layer = r.layer
361
+ AND c.component = r.component
362
+ LEFT JOIN annotations a
363
+ ON a.model_name = r.model_name
364
+ AND a.layer = r.layer
365
+ AND a.component = r.component
366
+ {erf_join}
367
+ WHERE (? IS NULL OR r.model_name = ?)
368
+ AND (? IS NULL OR r.selection_name = ?)
369
+ ORDER BY r.model_name, r.selection_name, r.selection_index
370
+ """,
371
+ (model_name, model_name, selection_name, selection_name),
372
+ ).fetchall()
373
+ out = []
374
+ for row in rows:
375
+ item = _annotation_row(row)
376
+ item.update(
377
+ {
378
+ "selection_name": str(row["selection_name"]),
379
+ "model_name": str(row["model_name"]),
380
+ "selection_index": int(row["selection_index"]),
381
+ "layer": str(row["layer"]),
382
+ "component": int(row["component"]),
383
+ "component_id": str(row["component_id"] or ""),
384
+ "source_json": str(row["source_json"] or ""),
385
+ "seed": int(row["seed"]) if row["seed"] is not None else None,
386
+ "requested_n": int(row["requested_n"]) if row["requested_n"] is not None else None,
387
+ "inventory_size": int(row["inventory_size"]) if row["inventory_size"] is not None else None,
388
+ "selected_size": int(row["selected_size"]) if row["selected_size"] is not None else None,
389
+ "fit_converged": bool(row["fit_converged"]) if row["fit_converged"] is not None else None,
390
+ "fit_iterations": int(row["fit_iterations"]) if row["fit_iterations"] is not None else None,
391
+ "fit_final_lim": float(row["fit_final_lim"]) if row["fit_final_lim"] is not None else None,
392
+ "fit_final_lim_p95": float(row["fit_final_lim_p95"]) if row["fit_final_lim_p95"] is not None else None,
393
+ "fit_seed": int(row["fit_seed"]) if row["fit_seed"] is not None else None,
394
+ "effective_context_mean": float(row["effective_context_mean"]) if row["effective_context_mean"] is not None else None,
395
+ }
396
+ )
397
+ out.append(item)
398
+ return out
399
+
400
+
401
+ def list_component_examples(conn: sqlite3.Connection, model_name: str, layer: str | None = None, component: int | None = None, *, region: str | None = None, limit: int | None = None) -> dict[tuple[str, int], list[dict[str, Any]]]:
402
+ rows = conn.execute(
403
+ """
404
+ SELECT layer,
405
+ component,
406
+ region,
407
+ rank,
408
+ row_index,
409
+ doc_id,
410
+ token_id,
411
+ token,
412
+ source_score,
413
+ direction_cosine,
414
+ position,
415
+ context_to_target,
416
+ context,
417
+ context_score_max_abs
418
+ FROM examples
419
+ WHERE model_name = ?
420
+ AND (? IS NULL OR layer = ?)
421
+ AND (? IS NULL OR component = ?)
422
+ AND (? IS NULL OR region = ?)
423
+ AND (? IS NULL OR rank <= ?)
424
+ ORDER BY layer, component, region, rank
425
+ """,
426
+ (model_name, layer, layer, component, component, region, region, limit, limit),
427
+ ).fetchall()
428
+ examples: dict[tuple[str, int], list[dict[str, Any]]] = {}
429
+ for row in rows:
430
+ key = (str(row["layer"]), int(row["component"]))
431
+ examples.setdefault(key, []).append(_example_row(row))
432
+ return examples
433
+
434
+
435
+ def list_component_example_details(conn: sqlite3.Connection, model_name: str, *, layer: str, component: int) -> list[dict[str, Any]]:
436
+ rows = conn.execute(
437
+ """
438
+ SELECT id,
439
+ region,
440
+ rank,
441
+ row_index,
442
+ doc_id,
443
+ token_id,
444
+ token,
445
+ source_score,
446
+ direction_cosine,
447
+ position,
448
+ context_to_target,
449
+ context,
450
+ context_score_max_abs
451
+ FROM examples
452
+ WHERE model_name = ? AND layer = ? AND component = ?
453
+ ORDER BY region, rank
454
+ """,
455
+ (model_name, layer, component),
456
+ ).fetchall()
457
+ examples = []
458
+ by_id: dict[int, dict[str, Any]] = {}
459
+ for row in rows:
460
+ example = _example_row(row)
461
+ example["context_token_scores"] = []
462
+ examples.append(example)
463
+ by_id[int(row["id"])] = example
464
+ for ids in _chunks(list(by_id), 500):
465
+ if not ids:
466
+ continue
467
+ placeholders = ",".join("?" for _ in ids)
468
+ token_rows = conn.execute(
469
+ f"""
470
+ SELECT example_id, seq, token_position, token, source_score, direction_cosine, is_target
471
+ FROM context_tokens
472
+ WHERE example_id IN ({placeholders})
473
+ ORDER BY example_id, seq
474
+ """,
475
+ tuple(ids),
476
+ ).fetchall()
477
+ for token_row in token_rows:
478
+ example = by_id.get(int(token_row["example_id"]))
479
+ if example is None:
480
+ continue
481
+ example["context_token_scores"].append(
482
+ {
483
+ "position": int(token_row["token_position"]) if token_row["token_position"] is not None else None,
484
+ "token": str(token_row["token"] or ""),
485
+ "source_score": float(token_row["source_score"]) if token_row["source_score"] is not None else None,
486
+ "direction_cosine": float(token_row["direction_cosine"]) if token_row["direction_cosine"] is not None else None,
487
+ "is_target": bool(token_row["is_target"]),
488
+ }
489
+ )
490
+ return examples
491
+
492
+
493
+ def get_component_row(conn: sqlite3.Connection, model_name: str, layer: str, component: int) -> dict[str, Any] | None:
494
+ rows = list_components(conn, model_name, layer=layer, component=component)
495
+ return rows[0] if rows else None
496
+
497
+
498
+ def get_annotation(conn: sqlite3.Connection, model_name: str, layer: str, component: int) -> dict[str, Any] | None:
499
+ row = conn.execute(
500
+ """
501
+ SELECT * FROM annotations
502
+ WHERE model_name = ? AND layer = ? AND component = ?
503
+ """,
504
+ (model_name, layer, component),
505
+ ).fetchone()
506
+ return dict(row) if row else None
507
+
508
+
509
+ def update_annotation(conn: sqlite3.Connection, *, model_name: str, layer: str, component: int, positive_label: str, positive_confidence: str, positive_interpretation_types: list[str], negative_label: str, negative_confidence: str, negative_interpretation_types: list[str], summary: str, notes: str, include_as_case_study: bool) -> None:
510
+ conn.execute(
511
+ """
512
+ INSERT INTO annotations(
513
+ model_name, layer, component,
514
+ positive_label, positive_confidence, positive_interpretation_types_json,
515
+ negative_label, negative_confidence, negative_interpretation_types_json,
516
+ summary, notes, include_as_case_study, updated_at
517
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
518
+ ON CONFLICT(model_name, layer, component) DO UPDATE SET
519
+ positive_label = excluded.positive_label,
520
+ positive_confidence = excluded.positive_confidence,
521
+ positive_interpretation_types_json = excluded.positive_interpretation_types_json,
522
+ negative_label = excluded.negative_label,
523
+ negative_confidence = excluded.negative_confidence,
524
+ negative_interpretation_types_json = excluded.negative_interpretation_types_json,
525
+ summary = excluded.summary,
526
+ notes = excluded.notes,
527
+ include_as_case_study = excluded.include_as_case_study,
528
+ updated_at = datetime('now')
529
+ """,
530
+ (
531
+ model_name,
532
+ layer,
533
+ int(component),
534
+ positive_label,
535
+ _normalize_confidence(positive_confidence),
536
+ json.dumps([str(item) for item in positive_interpretation_types if str(item)]),
537
+ negative_label,
538
+ _normalize_confidence(negative_confidence),
539
+ json.dumps([str(item) for item in negative_interpretation_types if str(item)]),
540
+ summary,
541
+ notes,
542
+ 1 if include_as_case_study else 0,
543
+ ),
544
+ )
545
+ conn.commit()
546
+
547
+
548
+ def infer_default_annotation_sign(conn: sqlite3.Connection, model_name: str, layer: str, component: int) -> int:
549
+ row = conn.execute(
550
+ """
551
+ SELECT source_score
552
+ FROM examples
553
+ WHERE model_name = ? AND layer = ? AND component = ?
554
+ AND region IN ('top_abs', 'top_abs_sample_500', 'top_abs_sample_5000')
555
+ ORDER BY ABS(source_score) DESC, rank ASC
556
+ LIMIT 1
557
+ """,
558
+ (model_name, layer, component),
559
+ ).fetchone()
560
+ if row is None or row["source_score"] is None:
561
+ return 1
562
+ return 1 if float(row["source_score"]) >= 0 else -1
563
+
564
+
565
+ def get_examples_by_region(conn: sqlite3.Connection, model_name: str, layer: str, component: int) -> tuple[list[str], dict[str, list[dict[str, Any]]]]:
566
+ examples = list_component_example_details(conn, model_name, layer=layer, component=component)
567
+ by_region: dict[str, list[dict[str, Any]]] = {}
568
+ for example in examples:
569
+ by_region.setdefault(str(example["region"] or "examples"), []).append(example)
570
+ regions = sorted(by_region, key=_example_band_sort_key)
571
+ return regions, by_region
572
+
573
+
574
+ def pick_default_region(regions: list[str], examples_by_region: dict[str, list[dict[str, Any]]]) -> str:
575
+ for candidate in ("top_abs", "top_abs_sample_500", "top_abs_sample_5000"):
576
+ if candidate in examples_by_region:
577
+ return candidate
578
+ return regions[0] if regions else ""
579
+
580
+
581
+ def search_components(conn: sqlite3.Connection, *, model_name: str | None, query: str, confidence: str, annotation_type: str, include_examples: bool, limit: int) -> list[dict[str, Any]]:
582
+ models = [model_name] if model_name else list_models(conn)
583
+ results = []
584
+ query_l = query.strip().lower()
585
+ for model in models:
586
+ for item in list_components(conn, model):
587
+ labels = [item.get("positive_label", ""), item.get("negative_label", ""), item.get("summary", ""), item.get("notes", "")]
588
+ haystack = " ".join(str(x) for x in labels).lower()
589
+ if query_l and query_l not in haystack:
590
+ continue
591
+ if confidence and confidence not in {item.get("positive_confidence"), item.get("negative_confidence")}:
592
+ continue
593
+ if annotation_type and annotation_type not in set(item.get("positive_types", []) + item.get("negative_types", [])):
594
+ continue
595
+ row = {"model_name": model, **item}
596
+ if include_examples:
597
+ ex = list_component_examples(conn, model, layer=item["layer"], component=item["component"], limit=3).get((item["layer"], item["component"]), [])
598
+ row["examples"] = ex
599
+ results.append(row)
600
+ if len(results) >= limit:
601
+ return results
602
+ return results
603
+
604
+
605
+ def layer_sort_key(layer: str) -> tuple[int, int | str]:
606
+ if layer == "embedding":
607
+ return (0, 0)
608
+ if layer.startswith("layer_"):
609
+ return (1, int(layer.removeprefix("layer_")))
610
+ return (2, layer)
611
+
612
+
613
+ def _erf_join_sql(conn: sqlite3.Connection) -> tuple[str, str]:
614
+ table = _first_existing_table(conn, ["effective_receptive_fields", "effective_context_lengths"])
615
+ if table is None:
616
+ return "", "NULL AS effective_context_mean,"
617
+ columns = _table_columns(conn, table)
618
+ value_column = _first_existing_name(columns, ["mean_erf", "erf_mean", "mean_length", "effective_receptive_field_mean", "effective_context_mean"])
619
+ if value_column is None:
620
+ return "", "NULL AS effective_context_mean,"
621
+ quoted_table = _quote_identifier(table)
622
+ quoted_value = _quote_identifier(value_column)
623
+ return (
624
+ f"""
625
+ LEFT JOIN {quoted_table} erf
626
+ ON erf.model_name = c.model_name
627
+ AND erf.layer = c.layer
628
+ AND erf.component = c.component
629
+ """,
630
+ f"erf.{quoted_value} AS effective_context_mean,",
631
+ )
632
+
633
+
634
+ def _component_neighbor_erf_join_sql(conn: sqlite3.Connection) -> tuple[str, str]:
635
+ table = _first_existing_table(conn, ["effective_receptive_fields", "effective_context_lengths"])
636
+ if table is None:
637
+ return "", "NULL AS effective_context_mean,"
638
+ columns = _table_columns(conn, table)
639
+ value_column = _first_existing_name(columns, ["mean_erf", "erf_mean", "mean_length", "effective_receptive_field_mean", "effective_context_mean"])
640
+ if value_column is None:
641
+ return "", "NULL AS effective_context_mean,"
642
+ quoted_table = _quote_identifier(table)
643
+ quoted_value = _quote_identifier(value_column)
644
+ return (
645
+ f"""
646
+ LEFT JOIN {quoted_table} erf
647
+ ON erf.model_name = n.model_name
648
+ AND erf.layer = n.neighbor_layer
649
+ AND erf.component = n.neighbor_component
650
+ """,
651
+ f"erf.{quoted_value} AS effective_context_mean,",
652
+ )
653
+
654
+
655
+ def _first_existing_table(conn: sqlite3.Connection, names: list[str]) -> str | None:
656
+ for name in names:
657
+ if _table_exists(conn, name):
658
+ return name
659
+ return None
660
+
661
+
662
+ def _table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
663
+ return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({_quote_identifier(table_name)})").fetchall()}
664
+
665
+
666
+ def _first_existing_name(names: set[str], candidates: list[str]) -> str | None:
667
+ for candidate in candidates:
668
+ if candidate in names:
669
+ return candidate
670
+ return None
671
+
672
+
673
+ def _quote_identifier(name: str) -> str:
674
+ return '"' + str(name).replace('"', '""') + '"'
675
+
676
+
677
+ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
678
+ return conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table_name,)).fetchone() is not None
679
+
680
+
681
+ def _ensure_annotation_columns(conn: sqlite3.Connection) -> None:
682
+ columns = {str(row["name"]) for row in conn.execute("PRAGMA table_info(annotations)").fetchall()}
683
+ required = {
684
+ "label": "TEXT",
685
+ "confidence": "TEXT",
686
+ "positive_label": "TEXT",
687
+ "positive_confidence": "TEXT",
688
+ "negative_label": "TEXT",
689
+ "negative_confidence": "TEXT",
690
+ "positive_interpretation_types_json": "TEXT",
691
+ "negative_interpretation_types_json": "TEXT",
692
+ "interpretation_types_json": "TEXT",
693
+ "summary": "TEXT",
694
+ "notes": "TEXT",
695
+ "include_as_case_study": "INTEGER NOT NULL DEFAULT 0",
696
+ "updated_at": "TEXT",
697
+ }
698
+ for column, ddl in required.items():
699
+ if column not in columns:
700
+ conn.execute(f"ALTER TABLE annotations ADD COLUMN {column} {ddl}")
701
+
702
+
703
+ def _annotation_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
704
+ keys = row.keys() if hasattr(row, "keys") else row
705
+ def get(key: str, default: Any = None) -> Any:
706
+ return row[key] if key in keys else default
707
+ positive_label = str(get("positive_label") or "")
708
+ negative_label = str(get("negative_label") or "")
709
+ auto_annotated = "auto-annotation" in str(get("notes") or "").lower()
710
+ return {
711
+ "component": int(get("component")),
712
+ "positive_label": positive_label,
713
+ "positive_confidence": _normalize_confidence(get("positive_confidence")),
714
+ "negative_label": negative_label,
715
+ "negative_confidence": _normalize_confidence(get("negative_confidence")),
716
+ "positive_types": _json_list(get("positive_interpretation_types_json")),
717
+ "negative_types": _json_list(get("negative_interpretation_types_json")),
718
+ "summary": str(get("summary") or ""),
719
+ "notes": str(get("notes") or ""),
720
+ "include_as_case_study": bool(get("include_as_case_study") or 0),
721
+ "excess_kurtosis": float(get("excess_kurtosis")) if get("excess_kurtosis") is not None else None,
722
+ "auto_annotated": auto_annotated,
723
+ }
724
+
725
+
726
+ def _example_row(row: sqlite3.Row) -> dict[str, Any]:
727
+ return {
728
+ "region": str(row["region"] or ""),
729
+ "rank": int(row["rank"]),
730
+ "row_index": int(row["row_index"]) if "row_index" in row.keys() and row["row_index"] is not None else None,
731
+ "doc_id": int(row["doc_id"]) if "doc_id" in row.keys() and row["doc_id"] is not None else None,
732
+ "token_id": int(row["token_id"]) if "token_id" in row.keys() and row["token_id"] is not None else None,
733
+ "token": str(row["token"] or ""),
734
+ "source_score": float(row["source_score"]) if row["source_score"] is not None else None,
735
+ "direction_cosine": float(row["direction_cosine"]) if row["direction_cosine"] is not None else None,
736
+ "position": int(row["position"]) if row["position"] is not None else None,
737
+ "context_to_target": str(row["context_to_target"] or ""),
738
+ "context": str(row["context"] or ""),
739
+ "context_score_max_abs": float(row["context_score_max_abs"]) if row["context_score_max_abs"] is not None else None,
740
+ }
741
+
742
+
743
+ def _json_list(value: Any) -> list[str]:
744
+ try:
745
+ parsed = json.loads(value or "[]")
746
+ except json.JSONDecodeError:
747
+ return []
748
+ return [str(item) for item in parsed] if isinstance(parsed, list) else []
749
+
750
+
751
+ def _normalize_confidence(value: Any) -> str:
752
+ text = str(value or "unclear").strip().lower()
753
+ return text if text in {"high", "medium", "low", "unclear"} else "unclear"
754
+
755
+
756
+ def _chunks(items: list[int], size: int) -> Iterable[list[int]]:
757
+ for index in range(0, len(items), size):
758
+ yield items[index : index + size]
759
+
760
+
761
+ def _example_band_sort_key(region: str) -> tuple[int, str]:
762
+ name = str(region or "").lower().replace("-", "_")
763
+ compact = name.replace("_", "")
764
+ if name == "top_abs" or compact == "topabs":
765
+ return (0, name)
766
+ if "sample" in name:
767
+ return (1, name)
768
+ if "near_zero" in name or "nearzero" in compact:
769
+ return (2, name)
770
+ if "opposite" in name:
771
+ return (3, name)
772
+ return (4, name)