From ec5fa330a22136bfb4367448a3fb210f15be7d97 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Tue, 9 Jun 2026 19:59:57 +0000 Subject: [PATCH 01/17] Add /forward_pass route + bundle transformer-explainer in preview Wires up the new nnsightful forward_pass tool as a workbench API route and serves the transformer-explainer Svelte SPA from the same Next.js host so PR preview deploys exercise the whole flow end to end. Backend: - New routes/forward_pass.py: /start, /results/{job_id}, /status/{job_id}. The status endpoint proxies NDIF so the browser never sees NDIF_API_KEY. - state.py: ModelMetadata gains n_heads, n_kv_heads, d_model, d_head, vocab_size, positional_kind, arch_kind. Auto-derived from AutoConfig.from_pretrained() the same way n_layers is. Surfaces on the existing /models response without further changes. - main.py: register the new route, allow http://localhost:5173 / :4173 origins in dev mode (vite default + preview), add GZipMiddleware so the larger forward_pass payloads compress before egress. Frontend: - next.config.js: rewrite /transformer-explainer{,/} to /transformer-explainer/index.html so the bundled SvelteKit SPA's entrypoint resolves via Next.js public/ serving. - workbench/_web/Dockerfile: new te-builder stage clones ndif-team/transformer-explainer at TE_REF, runs `npm run build` with VITE_WORKBENCH_API set to the preview API host, copies the static output into public/transformer-explainer/. CI: - preview-deploy.yml: pass VITE_WORKBENCH_API, VITE_USER_EMAIL, TE_REPO, and TE_REF as build args to the web image. TE_REF defaults to forward-pass; overridable via the repo variable TRANSFORMER_EXPLAINER_REF for future bumps. Deps: - pyproject.toml + uv.lock: pin nnsightful at jon-bell/nnsightful@ea17d7b3 (forward-pass branch) to pick up the new forward_pass tool. Will switch to upstream once merged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/preview-deploy.yml | 4 + pyproject.toml | 2 +- uv.lock | 4 +- workbench/_api/main.py | 13 +++- workbench/_api/routes/__init__.py | 3 +- workbench/_api/routes/forward_pass.py | 101 ++++++++++++++++++++++++++ workbench/_api/state.py | 38 ++++++++++ workbench/_web/Dockerfile | 25 +++++++ workbench/_web/next.config.js | 9 +++ 9 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 workbench/_api/routes/forward_pass.py diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 69c3d714..9eb1615f 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -157,6 +157,10 @@ jobs: ${{ env.IMAGE_NAME }}:pr-${{ steps.meta.outputs.preview_id }} build-args: | NEXT_PUBLIC_BACKEND_URL=https://${{ steps.meta.outputs.api_host }} + VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} + VITE_USER_EMAIL=dev@localhost + TE_REPO=https://github.com/ndif-team/transformer-explainer.git + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || 'forward-pass' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/pyproject.toml b/pyproject.toml index b443a208..ffc68457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "influxdb-client>=1.49.0", "uvicorn>=0.33.0", "torch>=2.9.0", - "nnsightful @ git+https://github.com/AdamBelfki3/nnsightful.git", + "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 5b959fdc..46e6b252 100644 --- a/uv.lock +++ b/uv.lock @@ -864,7 +864,7 @@ wheels = [ [[package]] name = "nnsightful" version = "0.1.0" -source = { git = "https://github.com/AdamBelfki3/nnsightful.git#f94eeb7b23de60702d6ca806be67275a3d23c45c" } +source = { git = "https://github.com/jon-bell/nnsightful.git?rev=ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644#ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644" } dependencies = [ { name = "ipython" }, { name = "nnsight" }, @@ -1737,7 +1737,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.16" }, { name = "modal", specifier = ">=0.65.66" }, { name = "nnsight", specifier = ">=0.7.0" }, - { name = "nnsightful", git = "https://github.com/AdamBelfki3/nnsightful.git" }, + { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "toml", specifier = ">=0.10.2" }, { name = "torch", specifier = ">=2.9.0", index = "https://download.pytorch.org/whl/cpu" }, diff --git a/workbench/_api/main.py b/workbench/_api/main.py index 08755b02..6f48847c 100644 --- a/workbench/_api/main.py +++ b/workbench/_api/main.py @@ -1,10 +1,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware import logging import os import anyio -from .routes import lens, patch, models, logit_lens, activation_patching +from .routes import lens, patch, models, logit_lens, activation_patching, forward_pass from .state import AppState from dotenv import load_dotenv; load_dotenv() @@ -22,7 +23,11 @@ ] if os.environ.get('CONFIG') != "prod": - ALLOWED_ORIGINS.append("http://localhost:3000") + ALLOWED_ORIGINS.extend([ + "http://localhost:3000", + "http://localhost:5173", # vite dev (transformer-explainer) + "http://localhost:4173", # vite preview + ]) ALLOWED_ORIGIN_REGEX = ( # Vercel dev/staging previews + ripley-cluster PR previews. @@ -56,9 +61,13 @@ def fastapi_app(): max_age=3600, ) + # Compress large forward_pass / logit_lens / activation_patching payloads. + app.add_middleware(GZipMiddleware, minimum_size=1024) + app.include_router(lens, prefix="/lens") app.include_router(logit_lens, prefix="/logit_lens") app.include_router(activation_patching, prefix="/activation_patching") + app.include_router(forward_pass, prefix="/forward_pass") app.include_router(patch, prefix="/patch") app.include_router(models, prefix="/models") diff --git a/workbench/_api/routes/__init__.py b/workbench/_api/routes/__init__.py index a1b6e8a6..a05514a6 100644 --- a/workbench/_api/routes/__init__.py +++ b/workbench/_api/routes/__init__.py @@ -3,9 +3,10 @@ from .models import router as models from .logit_lens import router as logit_lens from .activation_patching import router as activation_patching +from .forward_pass import router as forward_pass from nnsight import ndif import nnsightful ndif.register(nnsightful) -__all__ = ["lens", "patch", "models", "logit_lens", "activation_patching"] \ No newline at end of file +__all__ = ["lens", "patch", "models", "logit_lens", "activation_patching", "forward_pass"] \ No newline at end of file diff --git a/workbench/_api/routes/forward_pass.py b/workbench/_api/routes/forward_pass.py new file mode 100644 index 00000000..c93a5fe0 --- /dev/null +++ b/workbench/_api/routes/forward_pass.py @@ -0,0 +1,101 @@ +import logging +from typing import Literal + +import requests +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from nnsightful.tools.forward_pass import forward_pass +from nnsightful.types import ForwardPassData + +from ..auth import require_user_email +from ..data_models import NDIFResponse +from ..state import AppState, get_state + + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class ForwardPassRequest(BaseModel): + model: str + prompt: str + positions: list[int] | Literal["all"] = [-1] + top_k: int = 10 + + +class ForwardPassResponse(NDIFResponse): + data: ForwardPassData | None = None + + +@router.post("/start", response_model=ForwardPassResponse) +async def start_forward_pass( + req: ForwardPassRequest, + state: AppState = Depends(get_state), + user_email: str = Depends(require_user_email), +): + model = state[req.model] + backend = state.make_backend(model=model) + + output = forward_pass._run( + model, + req.prompt, + positions=req.positions, + top_k=req.top_k, + remote=state.remote, + backend=backend, + non_blocking=state.remote, + raw=False, + ) + + if state.remote and backend is not None and not getattr(backend, "blocking", False): + return {"job_id": output} + + return {"data": forward_pass.to_data_obj(**output)} + + +@router.post("/results/{job_id}", response_model=ForwardPassResponse) +async def collect_forward_pass( + job_id: str, + req: ForwardPassRequest, + state: AppState = Depends(get_state), + user_email: str = Depends(require_user_email), +): + backend = state.make_backend(job_id=job_id) + results = backend()["results"] + data = forward_pass.to_data_obj(**results) + return {"data": data} + + +class JobStatusResponse(BaseModel): + status: str + description: str | None = None + + +@router.get("/status/{job_id}", response_model=JobStatusResponse) +async def job_status( + job_id: str, + state: AppState = Depends(get_state), + user_email: str = Depends(require_user_email), +): + """Proxy NDIF job status so the browser never sees the NDIF API key.""" + if not state.remote: + # Local mode runs synchronously; if a client polls in local mode, just + # report COMPLETED — by the time the start endpoint returned, the work + # was already done. + return {"status": "COMPLETED", "description": "local mode is synchronous"} + try: + resp = requests.get( + f"{state.ndif_backend_url}/response/{job_id}", + timeout=10, + ) + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"NDIF status returned {resp.status_code}") + payload = resp.json() + status_value = payload.get("status", "UNKNOWN") + description = payload.get("description") or payload.get("msg") + return {"status": status_value, "description": description} + except requests.RequestException as exc: + logger.warning(f"forward_pass status proxy failed for {job_id}: {exc}") + raise HTTPException(status_code=502, detail="NDIF status unreachable") diff --git a/workbench/_api/state.py b/workbench/_api/state.py index 3b22ba32..5d4a462a 100644 --- a/workbench/_api/state.py +++ b/workbench/_api/state.py @@ -2,6 +2,7 @@ import os import torch import toml +from typing import Literal from fastapi import Request from huggingface_hub import model_info, get_safetensors_metadata @@ -54,9 +55,32 @@ def fetch_model_metadata(model_name: str) -> "ModelMetadata": # caveat: if the HF token doesn't have license-acceptance for this # model, AutoConfig.from_pretrained raises 403 — surface the model in # /models with unknown layer count rather than 500ing the whole list. + n_heads = 0 + n_kv_heads = 0 + d_model = 0 + d_head = 0 + vocab_size = 0 + positional_kind: Literal["absolute", "rope"] = "absolute" + arch_kind: Literal["gpt2", "llama", "other"] = "other" try: config = AutoConfig.from_pretrained(model_name) n_layers = getattr(config, "num_hidden_layers", None) or getattr(config, "n_layer", 0) + n_heads = int(getattr(config, "num_attention_heads", 0) or getattr(config, "n_head", 0)) + n_kv_heads = int(getattr(config, "num_key_value_heads", n_heads) or n_heads) + d_model = int(getattr(config, "hidden_size", 0) or getattr(config, "n_embd", 0)) + d_head = int(getattr(config, "head_dim", 0) or (d_model // n_heads if n_heads else 0)) + vocab_size = int(getattr(config, "vocab_size", 0)) + positional_kind = "rope" if ( + getattr(config, "rope_theta", None) is not None + or getattr(config, "rope_parameters", None) is not None + ) else "absolute" + model_type = (getattr(config, "model_type", "") or "").lower() + if "gpt2" in model_type: + arch_kind = "gpt2" + elif model_type in ("llama", "mistral", "qwen2"): + arch_kind = "llama" + else: + arch_kind = "other" except Exception as e: logger.warning(f"Could not load config for {model_name} ({e.__class__.__name__}); n_layers will show as 0") n_layers = 0 @@ -82,6 +106,13 @@ def fetch_model_metadata(model_name: str) -> "ModelMetadata": n_layers=n_layers, params=_format_params(num_params) if num_params > 0 else "unknown", gated=gated, + n_heads=n_heads, + n_kv_heads=n_kv_heads, + d_model=d_model, + d_head=d_head, + vocab_size=vocab_size, + positional_kind=positional_kind, + arch_kind=arch_kind, ) @@ -93,6 +124,13 @@ class ModelMetadata(BaseModel): n_layers: int params: str gated: bool + n_heads: int = 0 + n_kv_heads: int = 0 + d_model: int = 0 + d_head: int = 0 + vocab_size: int = 0 + positional_kind: Literal["absolute", "rope"] = "absolute" + arch_kind: Literal["gpt2", "llama", "other"] = "other" class ModelsConfig(BaseModel): diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 1a53452b..262b6048 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -3,6 +3,27 @@ # Build context must be the repo root (the Dockerfile path is workbench/_web/Dockerfile) # so the build can reach the sibling nnsightful symlink target if/when needed. +# Build the transformer-explainer Svelte SPA in a separate stage so its +# build output can be copied into the Next.js public/ tree. Served at +# /transformer-explainer/ on the same host as the Next.js app (no CORS +# in production; the API call goes to api. per ALLOWED_ORIGIN_REGEX). +FROM node:20-slim AS te-builder +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git +ARG TE_REF=forward-pass +RUN git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te +WORKDIR /app/te +ARG VITE_WORKBENCH_API=http://localhost:8000 +ARG VITE_USER_EMAIL=dev@localhost +ENV NODE_ENV=production \ + VITE_WORKBENCH_API=${VITE_WORKBENCH_API} \ + VITE_USER_EMAIL=${VITE_USER_EMAIL} +RUN npm install --legacy-peer-deps \ + && npm run build + FROM oven/bun:1.2 AS deps WORKDIR /app/workbench/_web COPY workbench/_web/package.json workbench/_web/bun.lock* ./ @@ -21,6 +42,10 @@ RUN apt-get update \ && mkdir -p /data COPY --from=deps /app/workbench/_web/node_modules ./node_modules COPY workbench/_web ./ +# Bring transformer-explainer's static build into public/ so Next.js +# serves it at /transformer-explainer/* (see next.config.js rewrites for +# the trailing-slash entrypoint). +COPY --from=te-builder /app/te/build ./public/transformer-explainer # NEXT_PUBLIC_BACKEND_URL is inlined into the JS bundle by Next.js — must # be set at build time. Defaults to the dev placeholder so a vanilla # `docker build` still produces a working (but offline-backend) image. diff --git a/workbench/_web/next.config.js b/workbench/_web/next.config.js index ed00fba2..f941dbd1 100644 --- a/workbench/_web/next.config.js +++ b/workbench/_web/next.config.js @@ -45,6 +45,15 @@ const nextConfig = { "./node_modules/nnsightful/src/nnsightful/viz/charts.js", ], }, + // Transformer-explainer is built as a static SPA into public/transformer-explainer/ + // by the Dockerfile's te-builder stage. Next.js doesn't auto-serve directory + // index files from public/, so rewrite the bare path to the SvelteKit HTML. + async rewrites() { + return [ + { source: "/transformer-explainer", destination: "/transformer-explainer/index.html" }, + { source: "/transformer-explainer/", destination: "/transformer-explainer/index.html" }, + ]; + }, webpack: (config) => { // Fallbacks for @huggingface/transformers package config.resolve.fallback = { From 7692968adcb5c05aa01b757d8ec4afe675a64a94 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Tue, 9 Jun 2026 20:10:55 +0000 Subject: [PATCH 02/17] fix(ci): install TE devDependencies before flipping NODE_ENV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The te-builder stage set NODE_ENV=production before `npm install`, which makes npm skip devDependencies — including vite, sveltekit, and svelte-check. `npm run build` then ran `vite build` → command not found → exit 127 → preview build failure. Split into install (NODE_ENV unset) then build (NODE_ENV=production) so the production svelte.config.js base-path branch still kicks in for the bundle without starving the install of build tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- workbench/_web/Dockerfile | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 262b6048..81b5e909 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,11 +18,16 @@ RUN git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te WORKDIR /app/te ARG VITE_WORKBENCH_API=http://localhost:8000 ARG VITE_USER_EMAIL=dev@localhost -ENV NODE_ENV=production \ - VITE_WORKBENCH_API=${VITE_WORKBENCH_API} \ +ENV VITE_WORKBENCH_API=${VITE_WORKBENCH_API} \ VITE_USER_EMAIL=${VITE_USER_EMAIL} -RUN npm install --legacy-peer-deps \ - && npm run build +# `npm install` first without NODE_ENV=production so devDependencies +# (vite, sveltekit, svelte-check, etc.) actually get installed. Only +# after the install is complete do we flip NODE_ENV=production so that +# `vite build` + svelte.config.js's base-path branch produce the +# /transformer-explainer/ prefixed bundle. +RUN npm install --legacy-peer-deps +ENV NODE_ENV=production +RUN npm run build FROM oven/bun:1.2 AS deps WORKDIR /app/workbench/_web From 67cb13b743ea961e3b85892e46babb67c8372598 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Wed, 10 Jun 2026 00:18:33 +0000 Subject: [PATCH 03/17] fix(ci): pass BASE_PATH to TE build so assets resolve under /transformer-explainer/ The previous build emitted /_app/... asset URLs instead of /transformer-explainer/_app/..., so every CSS/JS chunk 404'd when the SPA was served from the workbench /transformer-explainer/ subpath. Pair with the TE fix that switches the base resolution to an explicit BASE_PATH env var (d009601 on ndif-team/transformer-explainer); set it to /transformer-explainer in the te-builder stage as both a build arg and a runtime env so svelte.config.js sees it. Co-Authored-By: Claude Opus 4.7 (1M context) --- workbench/_web/Dockerfile | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 81b5e909..6607414a 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,13 +18,16 @@ RUN git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te WORKDIR /app/te ARG VITE_WORKBENCH_API=http://localhost:8000 ARG VITE_USER_EMAIL=dev@localhost +ARG BASE_PATH=/transformer-explainer ENV VITE_WORKBENCH_API=${VITE_WORKBENCH_API} \ - VITE_USER_EMAIL=${VITE_USER_EMAIL} + VITE_USER_EMAIL=${VITE_USER_EMAIL} \ + BASE_PATH=${BASE_PATH} # `npm install` first without NODE_ENV=production so devDependencies # (vite, sveltekit, svelte-check, etc.) actually get installed. Only -# after the install is complete do we flip NODE_ENV=production so that -# `vite build` + svelte.config.js's base-path branch produce the -# /transformer-explainer/ prefixed bundle. +# after the install is complete do we flip NODE_ENV=production. The +# BASE_PATH env var (read by svelte.config.js) is the source of truth +# for the deploy prefix — relying on NODE_ENV alone was racy with vite's +# own mode handling. RUN npm install --legacy-peer-deps ENV NODE_ENV=production RUN npm run build From b17f934bfe40664aceb00ed7d549c3943336ea97 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Wed, 10 Jun 2026 01:16:42 +0000 Subject: [PATCH 04/17] fix(ci): bust te-builder cache when transformer-explainer HEAD moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git clone of ndif-team/transformer-explainer was layer-cached based solely on the ARG values, so a TE branch update never re-ran the clone and the bundle kept the pre-fix base-path bug. ADD the per-ref commit JSON from GitHub's API before the clone — Docker invalidates the layer whenever the URL content changes, which forces a fresh clone (and a fresh `npm install && vite build`) on every TE HEAD move while preserving the cache for unrelated CI runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- workbench/_web/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 6607414a..1e2a9127 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -14,6 +14,12 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git ARG TE_REF=forward-pass +# Docker caches RUN layers by their literal command + previous layer +# hash, so `git clone` is reused across builds even after the branch HEAD +# moves. ADD a per-ref JSON from the GitHub API so the layer cache busts +# whenever TE's HEAD changes; the resulting file is referenced only for +# its checksum effect on the cache key, not its contents. +ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json RUN git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te WORKDIR /app/te ARG VITE_WORKBENCH_API=http://localhost:8000 From 2e5f457e31ea7c0c2494dfedf9734c2d4ba94f28 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Thu, 18 Jun 2026 19:19:23 +0000 Subject: [PATCH 05/17] fix(web): redirect /transformer-explainer to trailing-slash form SvelteKit adapter-static emits relative asset URLs (./_app/immutable/...) which only resolve correctly when the document URL ends in a slash. Previously we *rewrote* the slashless path to serve index.html under the slashless URL, so the browser treated the last segment as a file and hoisted every CSS/JS reference up to host root (/_app/...), 404ing everything. Switch to a 307 redirect that adds the trailing slash; the rewrite is kept for the canonical /transformer-explainer/ form and serves the prerendered HTML. Co-Authored-By: Claude Opus 4.7 (1M context) --- workbench/_web/next.config.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/workbench/_web/next.config.js b/workbench/_web/next.config.js index f941dbd1..f1608c30 100644 --- a/workbench/_web/next.config.js +++ b/workbench/_web/next.config.js @@ -46,11 +46,21 @@ const nextConfig = { ], }, // Transformer-explainer is built as a static SPA into public/transformer-explainer/ - // by the Dockerfile's te-builder stage. Next.js doesn't auto-serve directory - // index files from public/, so rewrite the bare path to the SvelteKit HTML. + // by the Dockerfile's te-builder stage. SvelteKit's prerendered HTML uses + // relative asset URLs (./_app/immutable/...), so the browser MUST see a + // trailing slash on the document URL — otherwise the relative refs resolve + // one level above /transformer-explainer/ and land at host root. + async redirects() { + return [ + { + source: "/transformer-explainer", + destination: "/transformer-explainer/", + permanent: false, + }, + ]; + }, async rewrites() { return [ - { source: "/transformer-explainer", destination: "/transformer-explainer/index.html" }, { source: "/transformer-explainer/", destination: "/transformer-explainer/index.html" }, ]; }, From 1c3d8e24a18446f72fbb047738ede54cb2dd9d85 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Thu, 18 Jun 2026 20:02:00 +0000 Subject: [PATCH 06/17] fix(web): drop trailing-slash redirect (caused 308 loop with Next default) The TE bundle now emits absolute /transformer-explainer/_app/... URLs (9756deb on transformer-explainer@forward-pass), so we no longer need the browser document URL to end in a slash. The previous redirect fought with Next.js's built-in trailingSlash:false handling and produced ERR_TOO_MANY_REDIRECTS. Keep the rewrite for both slash and slashless forms. Co-Authored-By: Claude Opus 4.7 (1M context) --- workbench/_web/next.config.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/workbench/_web/next.config.js b/workbench/_web/next.config.js index f1608c30..f4046f87 100644 --- a/workbench/_web/next.config.js +++ b/workbench/_web/next.config.js @@ -46,21 +46,16 @@ const nextConfig = { ], }, // Transformer-explainer is built as a static SPA into public/transformer-explainer/ - // by the Dockerfile's te-builder stage. SvelteKit's prerendered HTML uses - // relative asset URLs (./_app/immutable/...), so the browser MUST see a - // trailing slash on the document URL — otherwise the relative refs resolve - // one level above /transformer-explainer/ and land at host root. - async redirects() { - return [ - { - source: "/transformer-explainer", - destination: "/transformer-explainer/", - permanent: false, - }, - ]; - }, + // by the Dockerfile's te-builder stage. The SvelteKit build emits absolute + // asset URLs (/transformer-explainer/_app/immutable/...) thanks to its + // paths.base + paths.relative=false config, so we can serve the same + // index.html for both /transformer-explainer and /transformer-explainer/ + // without worrying about how the document URL affects asset resolution. + // An earlier attempt to *redirect* the slashless form to the slash form + // hit a loop with Next.js's default `trailingSlash: false` behavior. async rewrites() { return [ + { source: "/transformer-explainer", destination: "/transformer-explainer/index.html" }, { source: "/transformer-explainer/", destination: "/transformer-explainer/index.html" }, ]; }, From 99c60bbbb25cfa8c46559a4be89364858b0c4daf Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Thu, 18 Jun 2026 20:54:59 +0000 Subject: [PATCH 07/17] chore(ci): retrigger preview to pick up TE 60714c0 Pulls in the ModelSelector dropdown z-index + click-outside fix from ndif-team/transformer-explainer@60714c0. The TE_REF cache-bust ADD will re-clone the branch, no other changes required. Co-Authored-By: Claude Opus 4.7 (1M context) From 948d19a32ca30c31cefce22ac2acd5affc5c6464 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 00:29:04 +0000 Subject: [PATCH 08/17] fix(ci): pin TE_REF to a SHA for reproducible preview bundles Switching from a branch name to a 40-char commit SHA so each preview build is tied to a known TE commit. Bumping the value busts the docker layer cache (the SHA shows up in both the cache-bust ADD URL and the git clone command), which removes any ambiguity about whether the deployed bundle has the latest fixes. Initial pin: a4f3b3e029a8a0c36e47c16094908e7dddbdf508 (60714c0 ModelSelector z-index/click-outside fix + a4f3b3e new Playwright dropdown spec) The git clone path branches on regex: bare SHAs need fetch+checkout because `git clone --branch` doesn't accept SHAs, but human-readable refs (branches/tags) still use the simple `--branch` form for speed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/preview-deploy.yml | 2 +- workbench/_web/Dockerfile | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 9eb1615f..06c8da01 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || 'forward-pass' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || 'a4f3b3e029a8a0c36e47c16094908e7dddbdf508' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 1e2a9127..a8ea06f6 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -13,14 +13,23 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git -ARG TE_REF=forward-pass -# Docker caches RUN layers by their literal command + previous layer -# hash, so `git clone` is reused across builds even after the branch HEAD -# moves. ADD a per-ref JSON from the GitHub API so the layer cache busts -# whenever TE's HEAD changes; the resulting file is referenced only for -# its checksum effect on the cache key, not its contents. +# TE_REF: pin to a specific commit SHA for reproducible builds. Updating +# this value also busts the docker layer cache below it (the ADD line +# embeds it in a URL, and the RUN line uses it as a clone target — both +# layer hashes change when TE_REF changes). Bump this when there's a new +# TE commit to ship into the preview. +ARG TE_REF=a4f3b3e029a8a0c36e47c16094908e7dddbdf508 ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json -RUN git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te +# `git clone --branch` works with tag or branch names but not bare SHAs, +# so for a SHA we shallow clone the default branch then fetch+checkout. +# Falls back to --branch for human-readable refs (branches/tags). +RUN if echo "${TE_REF}" | grep -Eq '^[0-9a-f]{40}$'; then \ + git clone --filter=blob:none ${TE_REPO} te \ + && git -C te fetch --depth 1 origin ${TE_REF} \ + && git -C te checkout ${TE_REF}; \ + else \ + git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te; \ + fi WORKDIR /app/te ARG VITE_WORKBENCH_API=http://localhost:8000 ARG VITE_USER_EMAIL=dev@localhost From df929a6fc355ba9714b9efc26f564ee8e63a6d7d Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 00:56:08 +0000 Subject: [PATCH 09/17] fix(ci): require VITE_WORKBENCH_API + write it to .env.production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed bundle was hitting http://localhost:8000 because something in the te-builder cache chain produced a build where the env var hadn't been applied. Two-pronged hardening: 1. Drop the localhost:8000 default on the ARG. Missing build-args now fail the build with a clear message instead of silently producing a broken bundle. 2. Write the URL into .env.production in the cloned TE source before the npm install layer. .env files are loaded by vite at build time AND become part of the source-layer cache key — so changing them does invalidate downstream layers regardless of how the cache is scoped. Belt-and-braces with the existing ENV directives. Also bumps TE_REF to 0d0c3a8 (diagnostic logging of the API base), which gives users a console line that confirms what the bundle is configured with — and a loud warning if the fallback ever fires again. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/preview-deploy.yml | 2 +- workbench/_web/Dockerfile | 32 ++++++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 06c8da01..a5c216c2 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || 'a4f3b3e029a8a0c36e47c16094908e7dddbdf508' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '0d0c3a81e3e08255024b3c6c6e71d1ab08d5f5f0' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index a8ea06f6..b3e7a9e8 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,7 +18,7 @@ ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git # embeds it in a URL, and the RUN line uses it as a clone target — both # layer hashes change when TE_REF changes). Bump this when there's a new # TE commit to ship into the preview. -ARG TE_REF=a4f3b3e029a8a0c36e47c16094908e7dddbdf508 +ARG TE_REF=0d0c3a81e3e08255024b3c6c6e71d1ab08d5f5f0 ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json # `git clone --branch` works with tag or branch names but not bare SHAs, # so for a SHA we shallow clone the default branch then fetch+checkout. @@ -31,20 +31,30 @@ RUN if echo "${TE_REF}" | grep -Eq '^[0-9a-f]{40}$'; then \ git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te; \ fi WORKDIR /app/te -ARG VITE_WORKBENCH_API=http://localhost:8000 +# These ARGs MUST be supplied via build-args from the workflow. There is +# no safe default for the API URL: a fallback like http://localhost:8000 +# silently produces a broken bundle that 404s in production, which is the +# bug that prompted this hardening. Leaving the ARG default empty makes +# the build fail loudly inside `npm run build` if the wiring is broken. +ARG VITE_WORKBENCH_API ARG VITE_USER_EMAIL=dev@localhost ARG BASE_PATH=/transformer-explainer -ENV VITE_WORKBENCH_API=${VITE_WORKBENCH_API} \ - VITE_USER_EMAIL=${VITE_USER_EMAIL} \ - BASE_PATH=${BASE_PATH} +RUN test -n "${VITE_WORKBENCH_API}" || (echo "VITE_WORKBENCH_API build-arg is required" && exit 1) +# Persist the env vars into the TE source tree as a .env.production file +# rather than only setting them via ENV, so vite picks them up even if a +# cached upstream layer somehow shadows the process env. .env files are +# loaded by vite at build time and become part of the source layer hash, +# which means changing them invalidates the npm run build layer cleanly. +RUN printf 'VITE_WORKBENCH_API=%s\nVITE_USER_EMAIL=%s\nBASE_PATH=%s\n' \ + "${VITE_WORKBENCH_API}" "${VITE_USER_EMAIL}" "${BASE_PATH}" > .env.production \ + && cat .env.production # `npm install` first without NODE_ENV=production so devDependencies -# (vite, sveltekit, svelte-check, etc.) actually get installed. Only -# after the install is complete do we flip NODE_ENV=production. The -# BASE_PATH env var (read by svelte.config.js) is the source of truth -# for the deploy prefix — relying on NODE_ENV alone was racy with vite's -# own mode handling. +# (vite, sveltekit, svelte-check, etc.) actually get installed. RUN npm install --legacy-peer-deps -ENV NODE_ENV=production +# BASE_PATH is read by svelte.config.js for the deploy prefix; relying on +# NODE_ENV alone was racy with vite's own mode handling, so we set it +# explicitly here as well. +ENV NODE_ENV=production BASE_PATH=${BASE_PATH} RUN npm run build FROM oven/bun:1.2 AS deps From e3088a641cda714036aeaf30e7d469f6eaecb020 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 01:06:14 +0000 Subject: [PATCH 10/17] fix(ci): derive VITE_WORKBENCH_API from existing NEXT_PUBLIC_BACKEND_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt required a new build-arg (VITE_WORKBENCH_API), but pull_request_target runs the workflow YAML from main — not from the PR HEAD — so my workflow updates on this branch never took effect. The build then failed at the new guard because main's workflow never passed the arg. Reuse NEXT_PUBLIC_BACKEND_URL (which main's workflow already passes for the Next.js build) to derive VITE_WORKBENCH_API. The te-builder still fails loudly if neither is set, and the explicit arg still wins if/when the workflow is updated. Same .env.production / no-default-fallback hardening from df929a6 carries over. This also explains the original mixed-content bug: the deployed previews were running main's workflow YAML, which never passed VITE_WORKBENCH_API, so the Dockerfile's old default `http://localhost:8000` shipped in every bundle. With this change, the same API host the Next.js app already talks to gets baked into the TE bundle as well. Co-Authored-By: Claude Opus 4.7 (1M context) --- workbench/_web/Dockerfile | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index b3e7a9e8..759ae395 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -31,21 +31,23 @@ RUN if echo "${TE_REF}" | grep -Eq '^[0-9a-f]{40}$'; then \ git clone --depth 1 --branch ${TE_REF} ${TE_REPO} te; \ fi WORKDIR /app/te -# These ARGs MUST be supplied via build-args from the workflow. There is -# no safe default for the API URL: a fallback like http://localhost:8000 -# silently produces a broken bundle that 404s in production, which is the -# bug that prompted this hardening. Leaving the ARG default empty makes -# the build fail loudly inside `npm run build` if the wiring is broken. +# preview-deploy.yml already passes NEXT_PUBLIC_BACKEND_URL (Next.js +# needs it for the same workbench API host). We reuse it here so the +# te-builder doesn't need a separate workflow change to land first +# (pull_request_target uses the workflow from main, not from the PR +# HEAD — see https://docs.github.com/en/actions/security-guides/automatic-token-authentication). +ARG NEXT_PUBLIC_BACKEND_URL ARG VITE_WORKBENCH_API ARG VITE_USER_EMAIL=dev@localhost ARG BASE_PATH=/transformer-explainer -RUN test -n "${VITE_WORKBENCH_API}" || (echo "VITE_WORKBENCH_API build-arg is required" && exit 1) -# Persist the env vars into the TE source tree as a .env.production file -# rather than only setting them via ENV, so vite picks them up even if a -# cached upstream layer somehow shadows the process env. .env files are -# loaded by vite at build time and become part of the source layer hash, -# which means changing them invalidates the npm run build layer cleanly. -RUN printf 'VITE_WORKBENCH_API=%s\nVITE_USER_EMAIL=%s\nBASE_PATH=%s\n' \ +# Resolve VITE_WORKBENCH_API: prefer the explicit arg, fall back to the +# Next.js backend URL. Fail the build if neither is set so a future +# misconfiguration produces a clear error instead of a silently broken +# bundle (the localhost:8000 fallback that triggered this hardening). +RUN export VITE_WORKBENCH_API="${VITE_WORKBENCH_API:-${NEXT_PUBLIC_BACKEND_URL}}" \ + && test -n "${VITE_WORKBENCH_API}" \ + || (echo "Either VITE_WORKBENCH_API or NEXT_PUBLIC_BACKEND_URL build-arg is required" && exit 1) \ + && printf 'VITE_WORKBENCH_API=%s\nVITE_USER_EMAIL=%s\nBASE_PATH=%s\n' \ "${VITE_WORKBENCH_API}" "${VITE_USER_EMAIL}" "${BASE_PATH}" > .env.production \ && cat .env.production # `npm install` first without NODE_ENV=production so devDependencies From b47095b42dd308903764f932b4bd3def12f9f4a9 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 01:24:23 +0000 Subject: [PATCH 11/17] chore(ci): bump TE_REF to 93067a3 (credentials: include fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up ndif-team/transformer-explainer@93067a3 — TE's fetch now sends the oauth2_proxy session cookie cross-origin so the API stops 302'ing to the auth flow on /models/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/preview-deploy.yml | 2 +- workbench/_web/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index a5c216c2..e34dbf9a 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '0d0c3a81e3e08255024b3c6c6e71d1ab08d5f5f0' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '93067a377e6195243a12e26e1f8c27196ae13f52' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 759ae395..02f5813a 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,7 +18,7 @@ ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git # embeds it in a URL, and the RUN line uses it as a clone target — both # layer hashes change when TE_REF changes). Bump this when there's a new # TE commit to ship into the preview. -ARG TE_REF=0d0c3a81e3e08255024b3c6c6e71d1ab08d5f5f0 +ARG TE_REF=93067a377e6195243a12e26e1f8c27196ae13f52 ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json # `git clone --branch` works with tag or branch names but not bare SHAs, # so for a SHA we shallow clone the default branch then fetch+checkout. From 04164c22862e6b8fdfcf3b0fd1d09b0005cf86b1 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 01:39:31 +0000 Subject: [PATCH 12/17] chore(deps): bump nnsightful to 75b4b8c (pydantic-out-of-trace fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up jon-bell/nnsightful@75b4b8c — forward_pass tool no longer records a pydantic_core call into the NDIF trace graph, fixing remote runs that were returning "Module pydantic_core._pydantic_core is not whitelisted". Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffc68457..8a00f034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "influxdb-client>=1.49.0", "uvicorn>=0.33.0", "torch>=2.9.0", - "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644", + "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@75b4b8c101617e63ba01b39a0957a934d798aab9", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 46e6b252..75b63d85 100644 --- a/uv.lock +++ b/uv.lock @@ -864,7 +864,7 @@ wheels = [ [[package]] name = "nnsightful" version = "0.1.0" -source = { git = "https://github.com/jon-bell/nnsightful.git?rev=ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644#ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644" } +source = { git = "https://github.com/jon-bell/nnsightful.git?rev=75b4b8c101617e63ba01b39a0957a934d798aab9#75b4b8c101617e63ba01b39a0957a934d798aab9" } dependencies = [ { name = "ipython" }, { name = "nnsight" }, @@ -1737,7 +1737,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.16" }, { name = "modal", specifier = ">=0.65.66" }, { name = "nnsight", specifier = ">=0.7.0" }, - { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=ea17d7b36b17afbe5a0b877ecb373ee1ad3d0644" }, + { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=75b4b8c101617e63ba01b39a0957a934d798aab9" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "toml", specifier = ">=0.10.2" }, { name = "torch", specifier = ">=2.9.0", index = "https://download.pytorch.org/whl/cpu" }, From 6064d0c8ffa0e029c48a93bb01a6f31857c1a16f Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 01:49:36 +0000 Subject: [PATCH 13/17] chore(deps): bump nnsightful to 5a2b3e7 (all-Pydantic-out-of-trace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up jon-bell/nnsightful@5a2b3e7 — _detect_arch now returns a plain dict, so the trace context's closure never captures any Pydantic instances. NDIF's dill-based serializer can pickle the frame cleanly without dragging in pydantic_core. The previous bump (75b4b8c) only fixed the model_dump call but left the ForwardPassArch instance in scope, where it was still being captured silently. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8a00f034..c9efbe24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "influxdb-client>=1.49.0", "uvicorn>=0.33.0", "torch>=2.9.0", - "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@75b4b8c101617e63ba01b39a0957a934d798aab9", + "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@5a2b3e7e7a5a60841411bf3a639a890081d4319f", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 75b63d85..9401714c 100644 --- a/uv.lock +++ b/uv.lock @@ -864,7 +864,7 @@ wheels = [ [[package]] name = "nnsightful" version = "0.1.0" -source = { git = "https://github.com/jon-bell/nnsightful.git?rev=75b4b8c101617e63ba01b39a0957a934d798aab9#75b4b8c101617e63ba01b39a0957a934d798aab9" } +source = { git = "https://github.com/jon-bell/nnsightful.git?rev=5a2b3e7e7a5a60841411bf3a639a890081d4319f#5a2b3e7e7a5a60841411bf3a639a890081d4319f" } dependencies = [ { name = "ipython" }, { name = "nnsight" }, @@ -1737,7 +1737,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.16" }, { name = "modal", specifier = ">=0.65.66" }, { name = "nnsight", specifier = ">=0.7.0" }, - { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=75b4b8c101617e63ba01b39a0957a934d798aab9" }, + { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=5a2b3e7e7a5a60841411bf3a639a890081d4319f" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "toml", specifier = ">=0.10.2" }, { name = "torch", specifier = ">=2.9.0", index = "https://download.pytorch.org/whl/cpu" }, From 6e1d5986d66d93b545efc6d5126010c2a3725e95 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 02:18:03 +0000 Subject: [PATCH 14/17] feat: GPT-J end-to-end (state.py detect, nnsightful 075d074, TE 22b1402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated bumps: - workbench _api/state.py: detect model_type=="gptj" → arch_kind="gptj", treat presence of cfg.rotary_dim as RoPE (GPT-J's flavor of partial RoPE doesn't set rope_theta/rope_parameters). The /models response now correctly tags EleutherAI/gpt-j-6b as {arch_kind:"gptj", positional_kind:"rope"}. - nnsightful → 075d074: forward_pass tool gains a GPT-J branch (out_proj / single ln_1 / partial RoPE via rotate_every_two) and a multi-GPU device-mismatch fix on the RoPE cos/sin. Pytest now has 17 cases covering GPT-2, Llama (full RoPE), and GPT-J (partial RoPE). - TE → 22b1402: ModelSelector defaults to GPT-J when available, falls through to GPT-2 then any allowed model. New purple gptj badge. Local verification before push: - nnsightful pytest 17/17 green - TE playwright 7/7 + 1 skip green (Llama spec skips in local mode, same as before) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/preview-deploy.yml | 2 +- pyproject.toml | 2 +- uv.lock | 4 ++-- workbench/_api/state.py | 9 +++++++-- workbench/_web/Dockerfile | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index e34dbf9a..9133ece8 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '93067a377e6195243a12e26e1f8c27196ae13f52' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '22b1402c608f417989bcbae4d9980312179562de' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/pyproject.toml b/pyproject.toml index c9efbe24..73fde1bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "influxdb-client>=1.49.0", "uvicorn>=0.33.0", "torch>=2.9.0", - "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@5a2b3e7e7a5a60841411bf3a639a890081d4319f", + "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@075d074d6422cfeb0f923528aa5ac3539fdae370", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 9401714c..86256975 100644 --- a/uv.lock +++ b/uv.lock @@ -864,7 +864,7 @@ wheels = [ [[package]] name = "nnsightful" version = "0.1.0" -source = { git = "https://github.com/jon-bell/nnsightful.git?rev=5a2b3e7e7a5a60841411bf3a639a890081d4319f#5a2b3e7e7a5a60841411bf3a639a890081d4319f" } +source = { git = "https://github.com/jon-bell/nnsightful.git?rev=075d074d6422cfeb0f923528aa5ac3539fdae370#075d074d6422cfeb0f923528aa5ac3539fdae370" } dependencies = [ { name = "ipython" }, { name = "nnsight" }, @@ -1737,7 +1737,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.16" }, { name = "modal", specifier = ">=0.65.66" }, { name = "nnsight", specifier = ">=0.7.0" }, - { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=5a2b3e7e7a5a60841411bf3a639a890081d4319f" }, + { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=075d074d6422cfeb0f923528aa5ac3539fdae370" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "toml", specifier = ">=0.10.2" }, { name = "torch", specifier = ">=2.9.0", index = "https://download.pytorch.org/whl/cpu" }, diff --git a/workbench/_api/state.py b/workbench/_api/state.py index 5d4a462a..4cab9805 100644 --- a/workbench/_api/state.py +++ b/workbench/_api/state.py @@ -61,7 +61,7 @@ def fetch_model_metadata(model_name: str) -> "ModelMetadata": d_head = 0 vocab_size = 0 positional_kind: Literal["absolute", "rope"] = "absolute" - arch_kind: Literal["gpt2", "llama", "other"] = "other" + arch_kind: Literal["gpt2", "llama", "gptj", "other"] = "other" try: config = AutoConfig.from_pretrained(model_name) n_layers = getattr(config, "num_hidden_layers", None) or getattr(config, "n_layer", 0) @@ -70,13 +70,18 @@ def fetch_model_metadata(model_name: str) -> "ModelMetadata": d_model = int(getattr(config, "hidden_size", 0) or getattr(config, "n_embd", 0)) d_head = int(getattr(config, "head_dim", 0) or (d_model // n_heads if n_heads else 0)) vocab_size = int(getattr(config, "vocab_size", 0)) + # GPT-J uses partial RoPE keyed off `rotary_dim` rather than + # rope_theta / rope_parameters (which are Llama-era config knobs). positional_kind = "rope" if ( getattr(config, "rope_theta", None) is not None or getattr(config, "rope_parameters", None) is not None + or getattr(config, "rotary_dim", None) is not None ) else "absolute" model_type = (getattr(config, "model_type", "") or "").lower() if "gpt2" in model_type: arch_kind = "gpt2" + elif model_type == "gptj": + arch_kind = "gptj" elif model_type in ("llama", "mistral", "qwen2"): arch_kind = "llama" else: @@ -130,7 +135,7 @@ class ModelMetadata(BaseModel): d_head: int = 0 vocab_size: int = 0 positional_kind: Literal["absolute", "rope"] = "absolute" - arch_kind: Literal["gpt2", "llama", "other"] = "other" + arch_kind: Literal["gpt2", "llama", "gptj", "other"] = "other" class ModelsConfig(BaseModel): diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 02f5813a..41b33090 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,7 +18,7 @@ ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git # embeds it in a URL, and the RUN line uses it as a clone target — both # layer hashes change when TE_REF changes). Bump this when there's a new # TE commit to ship into the preview. -ARG TE_REF=93067a377e6195243a12e26e1f8c27196ae13f52 +ARG TE_REF=22b1402c608f417989bcbae4d9980312179562de ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json # `git clone --branch` works with tag or branch names but not bare SHAs, # so for a SHA we shallow clone the default branch then fetch+checkout. From 178339cdf17cb06288c5299b3786f40d5d696dfd Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 12:58:59 +0000 Subject: [PATCH 15/17] chore(ci): bump TE_REF to runtime-probe page-load default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up ndif-team/transformer-explainer 166bf6d which: * replaces the hardcoded gpt-j > gpt-2 > first-allowed preference in +page.svelte's onMount with a runtime probe loop. The page now iterates /models smallest-first and adopts the first model whose forward pass actually completes. Fixes the case where the previous default (gpt-j-6b) is HOT on NDIF but failing with "Module nnsight.intervention.batching is not whitelisted" — instead of dead-ending the page, the probe advances and lands on a working model (typically Llama-3.1-8B in current NDIF state). * adds a comprehensive Llama-3.1-8B forward-pass E2E spec (24-token prompt, GQA shape, RoPE row-sum invariant, causal-mask zero pattern, K/V storage at n_kv_heads) that runs against real NDIF in ~15s. * generalizes the page-load smoke (renamed gpt-2 → bootstrap) and the whitespace error-states test so neither pins openai-community/gpt2 as a required HOT deployment. Full TE Playwright suite green locally (8 tests, 1.4 min) against the live workbench backend + real NDIF. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/preview-deploy.yml | 2 +- workbench/_web/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 9133ece8..c218cb9a 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '22b1402c608f417989bcbae4d9980312179562de' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '166bf6dde0f258cb2031061ec9430d52a5b0ce7f' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 41b33090..864414c0 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,7 +18,7 @@ ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git # embeds it in a URL, and the RUN line uses it as a clone target — both # layer hashes change when TE_REF changes). Bump this when there's a new # TE commit to ship into the preview. -ARG TE_REF=22b1402c608f417989bcbae4d9980312179562de +ARG TE_REF=166bf6dde0f258cb2031061ec9430d52a5b0ce7f ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json # `git clone --branch` works with tag or branch names but not bare SHAs, # so for a SHA we shallow clone the default branch then fetch+checkout. From 6205141a90957308b7bbc825433f76b15b1c4095 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 14:02:51 +0000 Subject: [PATCH 16/17] chore(ci): bump TE_REF to 128-token Llama E2E test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up ndif-team/transformer-explainer eec90b6 which adds a long- prompt test case (≥128 tokens, observed ~147) to the Llama-3.1-8B spec. Uses a Buffer-based extraction path to work around V8's max string size (the decoded payload is ~600+ MB at this length). Co-Authored-By: Claude Opus 4.7 --- .github/workflows/preview-deploy.yml | 2 +- workbench/_web/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index c218cb9a..3367c1f6 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '166bf6dde0f258cb2031061ec9430d52a5b0ce7f' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || 'eec90b69fe642ca96bd23a3adaa0c52c4ff888c8' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 864414c0..91107ded 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,7 +18,7 @@ ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git # embeds it in a URL, and the RUN line uses it as a clone target — both # layer hashes change when TE_REF changes). Bump this when there's a new # TE commit to ship into the preview. -ARG TE_REF=166bf6dde0f258cb2031061ec9430d52a5b0ce7f +ARG TE_REF=eec90b69fe642ca96bd23a3adaa0c52c4ff888c8 ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json # `git clone --branch` works with tag or branch names but not bare SHAs, # so for a SHA we shallow clone the default branch then fetch+checkout. From 7629b63099940ae1cf932904938054869a694532 Mon Sep 17 00:00:00 2001 From: Jon Bell Date: Fri, 19 Jun 2026 20:02:56 +0000 Subject: [PATCH 17/17] chore: bump nnsightful + TE refs to trim-payload commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * nnsightful 075d074 → 3204cba: forward_pass now ships only `scores` per layer. The masked + softmax views are derived client-side. Halves the wire payload (S=103: 387 MB → 176 MB decoded). * transformer-explainer 22b1402 → 7599b64: adds deriveAttention.ts with the matching client-side derivation, Llama E2E spec updated to drop the Buffer-extraction hack and use plain .json() again. Adds retry-on-transient-NDIF-error to both the page-load bootstrap loop and the test helper, since NDIF's "is not whitelisted" race is firing for ~80% of jobs against pinned Llama deployments today. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/preview-deploy.yml | 2 +- pyproject.toml | 2 +- uv.lock | 4 ++-- workbench/_web/Dockerfile | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 3367c1f6..992e33fb 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -160,7 +160,7 @@ jobs: VITE_WORKBENCH_API=https://${{ steps.meta.outputs.api_host }} VITE_USER_EMAIL=dev@localhost TE_REPO=https://github.com/ndif-team/transformer-explainer.git - TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || 'eec90b69fe642ca96bd23a3adaa0c52c4ff888c8' }} + TE_REF=${{ vars.TRANSFORMER_EXPLAINER_REF || '7599b64dae7615039b6b1854af65cf3e4b73971b' }} cache-from: type=gha,scope=preview-web cache-to: type=gha,mode=max,scope=preview-web diff --git a/pyproject.toml b/pyproject.toml index 73fde1bd..8e24dac0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "influxdb-client>=1.49.0", "uvicorn>=0.33.0", "torch>=2.9.0", - "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@075d074d6422cfeb0f923528aa5ac3539fdae370", + "nnsightful @ git+https://github.com/jon-bell/nnsightful.git@3204cba6a91763f03e9e9bd363f84dc69b04d7ed", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 86256975..730a8e47 100644 --- a/uv.lock +++ b/uv.lock @@ -864,7 +864,7 @@ wheels = [ [[package]] name = "nnsightful" version = "0.1.0" -source = { git = "https://github.com/jon-bell/nnsightful.git?rev=075d074d6422cfeb0f923528aa5ac3539fdae370#075d074d6422cfeb0f923528aa5ac3539fdae370" } +source = { git = "https://github.com/jon-bell/nnsightful.git?rev=3204cba6a91763f03e9e9bd363f84dc69b04d7ed#3204cba6a91763f03e9e9bd363f84dc69b04d7ed" } dependencies = [ { name = "ipython" }, { name = "nnsight" }, @@ -1737,7 +1737,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.16" }, { name = "modal", specifier = ">=0.65.66" }, { name = "nnsight", specifier = ">=0.7.0" }, - { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=075d074d6422cfeb0f923528aa5ac3539fdae370" }, + { name = "nnsightful", git = "https://github.com/jon-bell/nnsightful.git?rev=3204cba6a91763f03e9e9bd363f84dc69b04d7ed" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "toml", specifier = ">=0.10.2" }, { name = "torch", specifier = ">=2.9.0", index = "https://download.pytorch.org/whl/cpu" }, diff --git a/workbench/_web/Dockerfile b/workbench/_web/Dockerfile index 91107ded..8d351d6f 100644 --- a/workbench/_web/Dockerfile +++ b/workbench/_web/Dockerfile @@ -18,7 +18,7 @@ ARG TE_REPO=https://github.com/ndif-team/transformer-explainer.git # embeds it in a URL, and the RUN line uses it as a clone target — both # layer hashes change when TE_REF changes). Bump this when there's a new # TE commit to ship into the preview. -ARG TE_REF=eec90b69fe642ca96bd23a3adaa0c52c4ff888c8 +ARG TE_REF=7599b64dae7615039b6b1854af65cf3e4b73971b ADD https://api.github.com/repos/ndif-team/transformer-explainer/commits/${TE_REF} /tmp/te-ref.json # `git clone --branch` works with tag or branch names but not bare SHAs, # so for a SHA we shallow clone the default branch then fetch+checkout.