# Project export: GlassBox

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Interpretable and Cognitive Observability platform for LLMs
- Devpost: https://devpost.com/software/glassbox-rhxod4
- GitHub: https://github.com/aniruddh-alt/glassbox
- Video: https://www.youtube.com/embed/tYCY8UQ0erg?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — aniruddh-alt (70 commits), Cursor (1 commits)

## Devpost submission (written by the team)

### Inspiration

AI will happily give you a confident medical answer. We have no clear way of making it interpretable because it is inherenly polysemantic and features are held in superposition (one neuron activating to multiple features). We built GlassBox to close this gap, a window into what a model is actually doing while it answers, so the nurse, doctor, or patient on the other end can tell whether to trust the answer or question it. A model can write a fluent, self-assured paragraph while, underneath, its internal signals are anything but sure. And it turns out you can read that wiring. Recent interpretability work shows that simple probes can catch internal states a model's text is hiding, from uncertainty to deception (Apollo Research's linear-probe work was a big influence here). We picked Google's Gemma 3 4B as our subject because it's open, it's small enough to actually instrument, and its middle layers are where these signals live cleanly. We also use Gemma Scope Sparse Auto Encoders to do unsupervisedb exploration of features in the model. GlassBox is what happens when you stop treating the model as a black box and start watching it think.

### What it does

GlassBox sits next to a medical chatbot. You ask a clinical question, the model answers like it always would, and GlassBox shows you what was going on inside while it did. Two views, side by side with the answer: The feature map: which concepts the model actually engaged with for your question, surfaced from its internal activations. The trackers: live readings for things like uncertainty and harmfulness. An answer that reads confident with nothing firing underneath is very different from one that reads confident while its uncertainty signal is lit. Instead of a lone answer, the person in the loop gets context, enough to catch the confidently-wrong cases before they reach a patient.

### How we built it

GlassBox runs as two pieces. There's a lightweight application layer that handles the chat, the views, and everything a user touches, and a separate GPU service that holds the model and does the heavy lifting. The app layer never loads the model itself; it asks the GPU service for what it needs over a simple API. Keeping those two apart means the interface stays fast and responsive while the expensive, GPU-bound work scales on its own, and either side can be worked on or restarted without dragging the other down. The feature map comes from a pretrained sparse autoencoder that turns raw activations into interpretable features. The trackers work differently: for each concept we precompute a "direction" from labeled examples ahead of time, and at answer-time we just measure the model's live activation against it. That's one cheap comparison per tracker, because the hard part already happened offline. We used Arize Phoenix* to trace every turn, so when something looks off we can see exactly what happened inside a request, and Sentry to catch failures before they quietly corrupt the signal. An interpretability tool you can't debug is just another black box.

### Challenges we ran into

Our first probes scored no better than a coin flip (AUROC = 0.5) The cause was a single oversized signal in the model's internals that drowned out everything else. Normalizing for it fixed the probes, and taught us that this correction isn't optional. It has to ship with every direction. Our first probes scored no better than a coin flip (AUROC = 0.5) The cause was a single oversized signal in the model's internals that drowned out everything else. Normalizing for it fixed the probes, and taught us that this correction isn't optional. It has to ship with every direction. This project required heavy GPU compute and on-demand pods which was not easily accessible. We have to then figure out a way to use runpod's GPU and created a micro-service that handled the heavy lifting. This project required heavy GPU compute and on-demand pods which was not easily accessible. We have to then figure out a way to use runpod's GPU and created a micro-service that handled the heavy lifting. We first trained probes in a setup that wasn't identical to how we serve them, and the mismatch quietly degraded the signal. We had to route the training path through the exact same machinery as the live one. We first trained probes in a setup that wasn't identical to how we serve them, and the mismatch quietly degraded the signal. We had to route the training path through the exact same machinery as the live one. Enabling ad-hoc probe monitors was very ambitious. We essentially production-alized a frontier research paper (Persona Vectors) by Anthropic to make this feasible. Enabling ad-hoc probe monitors was very ambitious. We essentially production-alized a frontier research paper (Persona Vectors) by Anthropic to make this feasible.

### Accomplishments we're proud of

It works, end to end. A question goes in, an answer comes out, and the feature map and tracker readings light up beside it in real time. We're proud the whole loop holds together as one system, fast enough to actually use, instead of a pile of disconnected scripts. And we stayed honest about the numbers: every tracker is measured against a baseline, so the signal we put on screen is one we'd actually stand behind. We are also really proud of converting a intensive mechanistic interpretability project into a 24 hour hackathon submission

### What we learned

We learned that the messiness inside these models is real and has to be handled, not ignored, and that an interpretability number means nothing without a baseline next to it. Above all, we confirmed our starting hunch which was that the gap between what a model says and what it's signaling inside is real, measurable, and worth showing to the people who depend on it. Making models more interpretable is the first and most crucial step in safe use of AI.

### What's next

Extend this to agents and agentic harnesses. The most natural next step would be to scale up this system to be able to detect hallucinated tool calls, and detecting misaligned behavior. Building an end to end interpretability agent that can go beyond feature discovery and monitoring. Real time steering of models and reliably setting up guardrails is very powerful.

## README (from the GitHub repository)

<div align="center">

<img src="docs/assets/logo.svg" alt="GlassBox" width="72" height="72" />

# GlassBox

**Interpretability-grade observability for open-weight LLMs.**

*Surface uncertainty, never suppress it.*

[![License: MIT](https://img.shields.io/badge/License-MIT-000.svg)](LICENSE)

</div>

---

GlassBox lets you watch an open-weight model's internal state while it answers. It runs the model with a single forward hook on one layer and turns that activation into two signals per turn:

- **Feature cloud** — the top SAE features firing in the residual stream, i.e. which concepts are active. Exploratory: labels are auto-interp and always shown with a caveat.
- **Probes** — calibrated linear probes (diff-of-means + logistic regression) that score concepts such as over-confidence or harmful intent, and flag when the model is *internally uncertain but verbally confident*.

You can train your own probe from a plain-language description on the **Build** tab. Every turn emits one structured event (`CognitionEvent`, see `backend/schema.py`) that the UI renders and Sentry alerts on when a probe trips.

GlassBox is general-purpose. A medical clinical-decision-support setup ships as one labeled, opt-in example profile in `config.example.yaml`.

## Architecture

Two FastAPI processes with a hard split:

- **Orchestration backend** (`backend/app.py`) runs on CPU and never imports torch. It assembles the per-turn event and fans it out.
- **GPU pod service** (`backend/gpu_service.py`) owns torch. It generates with the model while one hook on the configured layer captures the residual stream; that single activation feeds both the SAE feature cloud and the probes.

```
UI ──POST /api/chat──▶  GPU pod: generate + layer hook
                              │ (one residual activation)
                   ┌──────────┴──────────┐
              SAE feature cloud     persona-vector probes
                   └──────────┬──────────┘
                   backend: build one CognitionEvent
                              │ fanout
                   ┌──────────┼───────────┐
                  UI        Sentry     Claude judge
                          (when flagged)   (async, when flagged)
```

`POST /api/chat` returns `application/x-ndjson`: zero or more `{"type":"token",...}` lines, then exactly one `{"type":"event", ...CognitionEvent}`. The default model is `unsloth/gemma-3-4b-it` with Gemma Scope SAEs at layer 17, which is ungated and loads without a HuggingFace token.

## Quickstart

```bash
# 1. Install
uv sync                 # base install — runs in synthetic fallback mode, no GPU
uv sync --extra ml      # full install — torch + sae_lens, for real activations

# 2. Configure
cp config.example.yaml config.yaml   # edit as needed; config.yaml is gitignored
cp .env.example .env                  # ANTHROPIC_API_KEY enables Build + labels; SENTRY_DSN optional

# 3. Backend (port 8000)
uv run uvicorn backend.app:app --port 8000

# 4. Frontend (Vite on :5173, proxies /api -> :8000)
cd frontend && npm install && npm run dev
```

Open http://localhost:5173. Check the backend with `curl -s localhost:8000/api/health`:

```json
{"mode":"fallback","model":"unsloth/gemma-3-4b-it","layer":17,"trackers":[]}
```

Without the `ml` extra (or with no GPU and no weights), the backend reports `"mode":"fallback"` and serves synthetic features, so the whole UI works on any machine. With the `ml` extra, weights present, and a running GPU pod, it reports `"mode":"real"` and the activations, live probes, and Build pipeline are real.

Secrets (`ANTHROPIC_API_KEY`, `SENTRY_DSN`, `POD_TOKEN`, `HF_TOKEN`) live only in `.env`, never in `config.yaml`. Sentry receives anomaly flags and scalar metrics only; raw prompts and responses stay local unless you set `observability.sentry.send_io: true`.

## Documentation

- [`docs/probe-training.md`](docs/probe-training.md) — training and calibrating probe vectors.
- [`docs/tailscale-pod-runbook.md`](docs/tailscale-pod-runbook.md) — running the GPU pod over Tailscale when campus or office WiFi blocks public SSH.

## License

[MIT](LICENSE).


## Detected evidence (automated analysis)

Indexed codebase: 118 recognized source files, 1193 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- Hugging Face (technology) — detected in the code
- Python (language) — detected in the code
- PyTorch (technology) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 137)

```
.env.example
.gitignore
.python-version
backend/__init__.py
backend/agent/__init__.py
backend/agent/interp_agent.py
backend/agent/prompts.py
backend/agent/tools.py
backend/analyze.py
backend/app.py
backend/batch_medqa_observability.py
backend/coherence_eval.py
backend/config.py
backend/engine.py
backend/events.py
backend/fallback.py
backend/fanout.py
backend/gpu_service.py
backend/labels.py
backend/mock_labels.py
backend/observability.py
backend/phoenix_eval_features.py
backend/pod_client.py
backend/runtime.py
backend/schema.py
backend/science/__init__.py
backend/science/artifacts/hallucination.json
backend/science/artifacts/harmful_prompt.json
backend/science/artifacts/harmful.json
backend/science/artifacts/over_confidence.json
backend/science/artifacts/risk_awareness.json
backend/science/artifacts/uncertainty.json
backend/science/concept_synth.py
backend/science/feature_provider.py
backend/science/persona.py
backend/science/sae.py
backend/sentry_api.py
backend/smoke_fanout.py
backend/smoke_test_agent.py
backend/smoke_test_gs2.py
backend/tests/__init__.py
backend/tests/test_agent_persist.py
backend/tests/test_analyze.py
backend/tests/test_api_track.py
backend/tests/test_api.py
backend/tests/test_appconfig.py
backend/tests/test_attribution.py
backend/tests/test_coherence_eval.py
backend/tests/test_events.py
backend/tests/test_fallback.py
backend/tests/test_fanout.py
backend/tests/test_feature_provider.py
backend/tests/test_fit.py
backend/tests/test_generate.py
backend/tests/test_gpu_service.py
backend/tests/test_harmfulness_pipeline.py
backend/tests/test_jobs.py
backend/tests/test_judge.py
backend/tests/test_labels_autointerp.py
backend/tests/test_loop.py
backend/tests/test_observability_endpoint.py
backend/tests/test_observability.py
backend/tests/test_persona.py
backend/tests/test_phoenix_eval_features.py
backend/tests/test_pod_client.py
backend/tests/test_prompt_tracker.py
backend/tests/test_provider_masking.py
backend/tests/test_rank.py
backend/tests/test_runtime.py
backend/tests/test_sentry_alarm.py
backend/tests/test_sentry_api.py
backend/tests/test_smoke.py
backend/validation/build_medqa_dataset.py
backend/validation/csv_probe_sweep.py
backend/validation/eval_auroc.py
backend/validation/harmfulness_pipeline.py
config.example.yaml
docs/probe-training.md
docs/superpowers/plans/2026-06-20-chatbot-sae-integration.md
docs/superpowers/plans/2026-06-21-cognition-observability.md
docs/superpowers/plans/2026-06-22-appconfig-contract.md
docs/superpowers/plans/2026-06-22-README.md
docs/superpowers/plans/2026-06-22-ws0-config-hygiene-rebrand.md
docs/superpowers/plans/2026-06-22-ws1-observability.md
docs/superpowers/plans/2026-06-22-ws2-byo-model-sae.md
docs/superpowers/plans/2026-06-22-ws3-probe-pipeline-gpu.md
docs/superpowers/plans/2026-06-22-ws4-oss-infra.md
docs/superpowers/specs/2026-06-20-chatbot-sae-integration-design.md
docs/superpowers/specs/2026-06-20-gpu-microservice-design.md
docs/superpowers/specs/2026-06-21-cognition-observability-design.md
docs/superpowers/specs/2026-06-21-interp-agent-platform-integration-design.md
docs/superpowers/specs/2026-06-22-open-source-readiness-design.md
docs/tailscale-pod-runbook.md
fixtures/cognition_event.sample.json
fixtures/medqa_prompts.json
fixtures/probe_prompts/benign.csv
fixtures/probe_prompts/harmful.csv
frontend/index.html
frontend/package.json
frontend/src/api.ts
frontend/src/App.tsx
frontend/src/components/.gitkeep
frontend/src/components/AdjudicationBanner.tsx
frontend/src/components/ChatPanel.tsx
frontend/src/components/FeatureField.tsx
frontend/src/components/ProbePanel.tsx
frontend/src/components/ProbeVisibilityPanel.tsx
frontend/src/health.ts
frontend/src/instrument.ts
frontend/src/main.tsx
frontend/src/mock.ts
frontend/src/ObservabilityPage.tsx
frontend/src/ProbeBuilderPage.tsx
frontend/src/probePipeline.ts
frontend/src/probes.ts
frontend/src/styles.css
frontend/src/types.ts
frontend/src/useCognitionStream.ts
frontend/src/useObservability.ts
frontend/src/useProbeBuild.ts
[17 more files omitted for size]
```

### Dependencies

- frontend/package.json: @fontsource/hanken-grotesk@^5.2.8, @sentry/react@^10.59.0, @sentry/vite-plugin@^5.3.0, @types/react@^18.3.0, @types/react-dom@^18.3.0, @vitejs/plugin-react@^4.3.0, react@^18.3.1, react-dom@^18.3.1, react-force-graph-2d@^1.29.1, typescript@^5.5.0, vite@^5.4.0
- pyproject.toml: accelerate@>=0.34, anthropic@>=0.40, fastapi@>=0.138.0, httpx@>=0.27.0, pydantic@>=2.9, pydantic-settings@>=2.0, pyyaml@>=6.0, sae-lens@>=6.0, scikit-learn@>=1.5, sentry-sdk[fastapi]@>=2.63.0, torch@>=2.4, transformers@>=4.50, uvicorn[standard]@>=0.30

### Recent commits (newest first)

- Merge pull request #4 from aniruddh-alt/docs/readme-branding-cleanup
- docs: rewrite README for OSS; add cube logo and favicon
- Merge pull request #3 from aniruddh-alt/chatbot-sae-integration
- chore: remove AI-generated slop from WS0 (trim verbose param-doc docstrings, redundant comments, unused import)
- fix(deps): add uvicorn[standard] to base deps (required to serve the app; lost when requirements.txt was retired)
- docs: sync WS0 plan with preflight fixes (call-site update, targeted staging, real assertions)
- fix(analyze): require cfg on _rank_features; fix masked attribution tests
- chore(license): set concrete copyright holder; finalize WS0 rebrand foundation
- chore(frontend): standardize on npm package-lock.json; drop bun.lock
- chore(deps): retire backend/requirements.txt; pyproject is canonical; neutralize package description
- chore: gitignore config.yaml, probe_jobs/, artifacts/watch-*.json, .pytest_cache/
- chore: remove tracked cruft (main.py stub, empty err.txt, batch_medqa_results.json)
- refactor(config): remove dead flat globals; migrate straggler consumers to AppConfig
- refactor(gpu_service): build app.state.config; thread builder/model configs into agent; neutralize agent prompts
- refactor(app): build app.state.config and thread AppConfig through all endpoints
- refactor(science): thread Model/SAE/FeatureCloud/Probe configs into torch modules
- refactor(analyze): thread AppConfig through analyze_turn/_real_turn/_rank_features; neutral test samples
- refactor(labels): thread SAEConfig/FeatureCloudConfig/key; neutralize auto-interp prompt
- refactor(fallback): neutralize synthetic templates/labels; take SAEConfig + np_source
- refactor(fanout): thread ObsConfig/ProbeConfig/sentry_dsn; Phoenix branch left for WS1

## Key source files (fetched from GitHub, selected and truncated for size)

### LANES.md

```markdown
# Lanes & build order

Three people, parallel from hour 1. The unlock: **freeze `schema.py` + `types.ts` + `fixtures/cognition_event.sample.json` together first**, then everyone builds against the fixture.

## Ownership

| Lane | Owner | Files | Owns |
|------|-------|-------|------|
| **A — Backend** | | `app.py`, `engine.py`, `events.py`, `fanout.py`, `labels.py`, `config.py`, `schema.py` | Model load + the layer-12 hook + KV-cache decode, the `cognition_event` builder, FastAPI NDJSON streaming, and the single `fanout()` sponsor seam. |
| **B — Science** | | `science/sae.py`, `science/persona.py`, `science/concept_synth.py`, `validation/*` | Gemma Scope SAE top-k (Family A), persona-vector + calibrated probes (Family B), on-demand user concepts, and the offline AUROC validation vs a logistic-regression baseline. |
| **C — Frontend** | | `frontend/src/*` | The clinician chat view, the live feature cloud (`react-force-graph-2d`), the uncertainty meter (green→red zone), tracked-concept chips, and the Observability tab (Sentry + Phoenix linkouts). |

## Build order (dependency-ordered)

| When | Lane | Deliverable |
|------|------|-------------|
| **H0–1** | shared | **Freeze the contract.** Write `schema.py` + `types.ts` + `fixtures/cognition_event.sample.json` together. Agree the NDJSON line protocol. |
| **H0–2** | all | Scaffold in parallel. A: FastAPI streams the fixture from `/api/chat`. B: load model + SAE on GPU, confirm the layer-12 hook fires + reconstruction error sane. C: render the full UI from the fixture. |
| **H2–5** | B→A | Real signals: persona vectors for the 3 built-ins (diff-of-means + calibrated LogReg), wire `score_all_trackers` + `sae_topk` into `/api/chat`. |
| **H5–8** | A | `fanout()` → Sentry `capture_event` (fingerprinted Issue) + Phoenix OpenInference span. |
| **H8–11** | A+B | Claude layer: offline auto-interp labels for the demo feature set; async `claude_honesty_judge` on flagged events → `adjudication`. |
| **H11–15** | B+C | User-defined concepts: `concept_synth` behind `POST /api/track`; `TrackedConcepts` chips with spinner→live meter. |
| **H15–19** | B | Honest validation (offline): build MedMCQA/PubMedQA confident-wrong labels with a fixed confidence gate; AUROC probe vs baseline + reliability diagram. |
| **H19–22** | C+A | Polish: feature-cloud unverified badges + Neuronpedia embed modal + k-slider; Observability tab; severity tuning. |
| **H22–24** | all | Demo hardening: scripted prompt that reliably trips a flag (meter red → adjudication → click into Sentry/Arize); default to posthoc if live is jittery; pre-record a fallback. |

## Sponsor wiring (all in `fanout.py`)

- **Sentry** — `capture_event(fingerprint=['glassbox', concept, severity], contexts={'cognition': event})` → grouped Issue.
- **Arize Phoenix** — OpenInference LLM span with `cognition.*` attributes + one eval over traces.
- **Anthropic/Claude** — (a) offline auto-interp feature labels; (b) async adjudication of flagged events → `adjudicati
[truncated — 137 more characters]
```

### PLAN.md

```markdown
# GlassBox — Build Plan

**Live "fMRI for a medical LLM."** Watch SAE features fire token-by-token as an open model answers medical questions, surface *confident-wrongness* from the model's own internal uncertainty, and keep the whole patient-data path on-prem. UC Berkeley AI Hackathon · 24h · 3 people.

---

## 0. One-line thesis
Teams deploying medical LLMs are flying blind: a confident answer and a hallucination look identical from the outside. GlassBox instruments the model's **internal** state — surfacing uncertainty/hallucination features in real time — so the deploying team gets an alert *before* a confident-wrong answer reaches a patient. **Surface, never suppress.**

---

## 1. Current state (validated ✅)
- Repo scaffolded; `.venv` (uv, py3.12, torch 2.12, transformers 5.12, sae-lens 6, scikit-learn).
- **`config.py`** — locked to Gemma Scope 2 + Gemma-3-4b (see §2).
- **`engine.py`** — model load (multimodal-aware loader + nested-layer auto-detect + tensor/tuple hook), `generate_and_capture()`. ✅
- **`science/sae.py`** — `load_sae` / `sae_topk` / `reconstruction_error`. ✅
- **`science/persona.py`** — `persona_vector` (diff-of-means) / `train_probe` (calibrated LogReg) / `project` / `score_all_trackers`. ✅
- **`science/feature_provider.py`** — Local (primary) + Neuronpedia (fallback) + `get_provider`. ✅
- **`labels.py`** — Neuronpedia keyless label cache. ✅
- **`smoke_test.py`** — Family-A end-to-end: **reconstruction cosine 0.999**, correct medical answer, labels resolve. ✅
- **`smoke_test_probe.py`** — Family-B layer sweep: diff-of-means **cleanly separates** hedging vs confident (mechanism proven; toy set saturated AUROC=1.0 → real validation still owed).
- **`bench_latency.py`** — interp overhead **<100 ms**; generation ~3 s (MPS) dominates; cold label fetch 503 ms → pre-warm.

**Translation:** the hard, risky parts (SAE on Gemma-3-4b, layer wiring, live capture, probe mechanism, latency) are *proven*. What remains is product polish, validation, integration, and sponsor wiring.

---

## 2. Locked architecture

**Stack:** `unsloth/gemma-3-4b-it` (ungated; gated `google/` mirror as alt) · Gemma Scope 2 `gemma-scope-2-4b-it-res` `layer_17_width_16k_l0_medium` (d_in 2560) · Neuronpedia labels (~100% on residual SAEs) · single GPU (local for demo) · FastAPI POST + `fetch()/ReadableStream` NDJSON (never EventSource).

**Two families, one layer-17 hook:**
- **A — SAE feature cloud** (exploratory): top-k features/token → labels (caveated). The "wow" viz.
- **B — persona-vector probes** (reliable): diff-of-means + calibrated LogReg for `uncertainty` / `harmful` / `hallucination` + user-defined trackers. The safety signal.

**One `cognition_event` per message** → `fanout()` (CPU, no torch) → Sentry · Arize/Phoenix · UI · async Claude judge.

### 🔒 Trust boundary (the privacy architecture — say this out loud to judges)
> **PHI never leaves the box.** The local model does *all* patient-data work — generation, SAE features, probes, detection. **
[truncated — 6899 more characters]
```

### pyproject.toml

```
[project]
name = "glassbox"
version = "0.1.0"
description = "Open-source interpretability & observability for open-weight LLMs"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.138.0",
    "sentry-sdk[fastapi]>=2.63.0",
    "httpx>=0.27.0",
    "pydantic>=2.9",
    "pydantic-settings>=2.0",
    "pyyaml>=6.0",
    "uvicorn[standard]>=0.30",
]

[project.optional-dependencies]
# Real inference path. Installed only where weights/GPU exist:  uv sync --extra ml
# Validated against torch 2.4+ / transformers 4.50+ / sae-lens 6 on Gemma + Gemma Scope.
ml = [
    "torch>=2.4",
    "transformers>=4.50",
    "sae-lens>=6.0",
    "scikit-learn>=1.5",
    "accelerate>=0.34",
    "anthropic>=0.40",
]

[dependency-groups]
dev = [
    "pytest>=8.0",
]

[tool.pytest.ini_options]
testpaths = ["backend/tests"]
addopts = "-q"

```

### frontend/package.json

```
{
  "name": "glassbox-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@fontsource/hanken-grotesk": "^5.2.8",
    "@sentry/react": "^10.59.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-force-graph-2d": "^1.29.1"
  },
  "devDependencies": {
    "@sentry/vite-plugin": "^5.3.0",
    "@types/react": "^18.3.0",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.5.0",
    "vite": "^5.4.0"
  }
}

```

### backend/app.py

```python
"""FastAPI backend — the A↔C seam. OWNER: Lane A."""

from __future__ import annotations

import asyncio
import json
import re
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.concurrency import run_in_threadpool

from . import labels, observability, runtime, sentry_api
from .analyze import analyze_turn
from .fanout import fanout, init_sponsors, capture_cognition_alarm, sentry_enabled, _flag_reason

_TOKEN_CADENCE_S = 0.012  # replay the (already-generated) answer at a readable typing pace


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Bring up per-request readiness loading + the sponsor observability surfaces at startup.

    runtime.start_loading() kicks off the background model/SAE load (per-request readiness;
    requests use the synthetic fallback until it's ready). init_sponsors() is the SINGLE
    sponsor seam (contract #4): Sentry (incident view) + Phoenix (analytics view), each
    independently optional so a missing DSN or Phoenix sidecar logs a warning and the app
    still serves.
    """
    from .config import load_config

    app.state.config = load_config()
    cfg = app.state.config
    runtime.start_loading(cfg)
    init_sponsors(cfg.observability, cfg.sentry_dsn)
    yield


app = FastAPI(title="GlassBox", lifespan=lifespan)
app.add_middleware(
    CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)


@app.get("/api/health")
def health(request: Request) -> dict:
    return runtime.health_payload(request.app.state.config)


@app.get("/api/observability")
async def observability_endpoint(request: Request):
    """Return the in-process store snapshot merged with health, Sentry, and Phoenix UI URL.
    Never contains prompt or response text (store holds only redacted views)."""
    cfg = request.app.state.config
    snap = observability.STORE.snapshot()
    snap["health"] = runtime.health_payload(cfg)
    snap["sentry"] = {
        "emit_configured": bool(cfg.sentry_dsn),
        "configured": bool(cfg.sentry_auth_token),
        "deep_link": sentry_api.deep_link(cfg.observability.sentry, cfg.sentry_auth_token),
        "issues": await sentry_api.list_recent_issues(cfg.observability.sentry, cfg.sentry_auth_token),
    }
    snap["phoenix_ui_url"] = None  # WS1: remove this key
    return snap


@app.post("/api/observability/eval")
async def observability_eval():
    """Run a Phoenix batch coherence eval (labels-only, no prompt/response).
    coherence_eval is imported lazily so this task ships before that module exists."""
    from starlette.concurrency import run_in_threadpool
    from . import coherence_eval  # noqa: PLC0415 — intentionally lazy
    return await run_in_threadpool(coherence_eval.run_eval)


@app.post("/api/observability/test-sentry")
async def observability_test_sentry(request: Request):
    """Fire a synthetic flagged-turn alarm through Sentry (no GPU, no PHI).

    Use this to verify SENTRY_DSN wiring. Real chat turns alarm automatically via fanout()
    whenever any probe sets event.flag=true."""
    import time
    import uuid

    cfg = request.app.state.config
    if not sentry_enabled(cfg.sentry_dsn):
        return JSONResponse(
            {"ok": False, "reason": "SENTRY_DSN not configured — set it in .env and restart the backend."},
            status_code=503,
        )
    message_id = f"test-{uuid.uuid4().hex[:8]}"
    event = {
        "message_id": message_id,
        "ts": time.time(),
        "model": "glassbox-test",
        "layer": 17,
        "flag": True,
        "severity": "warning",
        "uncertainty": 0.91,
        "uncertainty_proj": 1.2,
        "trackers": {
            "over_confidence": {"score": 0.91, "proj": 1.2, "flag": True, "reliable": True},
            "harmful": {"score": 0.12, "flag": False, "reliable": True},
        },
        "features": [{"index": 0, "label": "synthetic test alarm (no PHI)", "act": 1.0}],
        "io": {"user_msg": "[synthetic test — not a real patient]", "response": "[synthetic test]"},
    }
    sent = capture_cognition_alarm(event, cfg.observability, flush=True)
    return {
        "ok": sent,
        "message_id": message_id,
        "flag_reason": _flag_reason(event),
        "hint": "Check Sentry Issues for 'Confident-wrong answer'. Chat turns alarm the same way when a probe flags.",
    }


@app.post("/api/observability/replay-sentry")
async def observability_replay_sentry(request: Request, body: dict | None = None):
    """Re-emit Sentry for a flagged turn already in the in-process store (e.g. if an alarm was missed)."""
    cfg = request.app.state.config
    if not sentry_enabled(cfg.sentry_dsn):
        return JSONResponse({"ok": False, "reason": "SENTRY_DSN not configured"}, status_code=503)
    message_id = (body or {}).get("message_id")
    if message_id:
        view = observability.STORE.get_turn(message_id)
    else:
        flagged = observability.STORE.snapshot()["confident_wrong"]
        view = observability.STORE.get_turn(flagged[-1]["message_id"]) if flagged else None
    if view is None or not view.get("flag"):
        return JSONResponse({"ok": False, "reason": "turn not found or not flagged"}, status_code=404)
    sent = capture_cognition_alarm({**view, "io": {}}, cfg.observability, flush=True)
    return {"ok": sent, "message_id": view["message_id"], "flag_reason": _flag_reason(view)}


def _chunks(text: str) -> list[str]:
    """Split into word-with-trailing-space chunks for the streamed typing effect."""
    return re.findall(r"\S+\s*", text) or [text]


@app.post("/api/chat")
async def chat(request: Request, body: dict):
    """Generate a turn, stream status + token replay, then one CognitionEvent line."""
    cfg = request.app.state.config
    messages = body.get("messages") or []
    turn_start_ns = time.time_ns()

    async def gen():
        yiel
[truncated — 3375 more characters]
```

### frontend/src/main.tsx

```typescript
import "./instrument"; // MUST be first — Sentry initializes before any app code runs

import React from "react";
import { createRoot } from "react-dom/client";
import * as Sentry from "@sentry/react";

// Self-hosted type (offline, no runtime requests). One grotesque throughout.
import "@fontsource/hanken-grotesk/400.css";
import "@fontsource/hanken-grotesk/500.css";
import "@fontsource/hanken-grotesk/600.css";

import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Sentry.ErrorBoundary fallback={<p>Something went wrong</p>} showDialog>
      <App />
    </Sentry.ErrorBoundary>
  </React.StrictMode>,
);

```

### frontend/src/App.tsx

```typescript
// Root view. Left: multi-turn clinician chat. Right: cognition stage (feature field + probes +
// Claude verdict) reflecting the LATEST message's CognitionEvent. Real /api/chat (no mock).
import { useEffect, useMemo, useState } from "react";

import "./styles.css";
import type { CognitionEvent } from "./types";
import { useCognitionStream } from "./useCognitionStream";
import { useHealth, MODE_BADGE } from "./health";
import { ChatPanel, type Msg } from "./components/ChatPanel";
import { FeatureField } from "./components/FeatureField";
import { ProbePanel } from "./components/ProbePanel";
import { AdjudicationBanner } from "./components/AdjudicationBanner";
import { ObservabilityPage } from "./ObservabilityPage";
import { ProbeBuilderPage } from "./ProbeBuilderPage";
import { ProbeVisibilityPanel } from "./components/ProbeVisibilityPanel";
import { collectProbeIds } from "./useProbeVisibility";

type View = "chat" | "observe" | "build";

function parseView(raw: string | null): View {
  if (raw === "observe" || raw === "build") return raw;
  return "chat";
}

export function App() {
  const { answer, event, status, send } = useCognitionStream();
  const [thread, setThread] = useState<Msg[]>([]);
  const [latest, setLatest] = useState<CognitionEvent | null>(null);
  const health = useHealth();
  const [view, setView] = useState<View>(
    () => parseView(localStorage.getItem("glassbox.view")),
  );
  const [observeMounted, setObserveMounted] = useState(view === "observe");
  const [buildMounted, setBuildMounted] = useState(view === "build");

  function onSend(content: string) {
    const history: Msg[] = [...thread, { role: "user", content }];
    setThread(history);
    send(history);
  }

  useEffect(() => {
    if (status === "done") {
      setThread((t) => (t.length && t[t.length - 1].role === "user"
        ? [...t, { role: "assistant", content: answer || "" }] : t));
      if (event) setLatest(event);
    } else if (status === "error") {
      setThread((t) => (t.length && t[t.length - 1].role === "user"
        ? [...t, { role: "assistant", content: "[generation failed]" }] : t));
    }
  }, [status, answer, event]);

  useEffect(() => {
    localStorage.setItem("glassbox.view", view);
    if (view === "observe") setObserveMounted(true);
    if (view === "build") setBuildMounted(true);
  }, [view]);

  const features = useMemo(() => latest?.features ?? [], [latest]);
  const trackers = useMemo(() => latest?.trackers ?? {}, [latest]);
  const probeIds = useMemo(
    () => collectProbeIds(health?.trackers, trackers),
    [health?.trackers, trackers],
  );

  return (
    <div className="app">
      <header className="glass">
        <div className="mark">
          <svg className="mark-cube" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <g stroke="var(--ink)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
              <path d="M4 9 H15 V20 H4 Z" />
              <path d="M9 4 H20 V15" />
              <path d="M4 9 L9 4" />
              <path d="M15 9 L20 4" />
              <path d="M15 20 L20 15" />
              <path d="M9 4 V15 H20" />
              <path d="M9 15 L4 20" />
            </g>
          </svg>
          Glassbox
        </div>
        <div className="meta">
          {health && <span className={`badge ${health.mode}`}>{MODE_BADGE[health.mode]}</span>}
        </div>
        <nav className="app-nav">
          <button
            className={`app-nav-btn${view === "chat" ? " active" : ""}`}
            onClick={() => setView("chat")}
          >Chat</button>
          <button
            className={`app-nav-btn${view === "build" ? " active" : ""}`}
            onClick={() => setView("build")}
          >Build</button>
          <button
            className={`app-nav-btn${view === "observe" ? " active" : ""}`}
            onClick={() => setView("observe")}
          >Observe</button>
        </nav>
        <div className="live"><span className="d" />Live</div>
      </header>

      <main style={{ display: view === "chat" ? "" : "none" }}>
        <ChatPanel
          thread={thread}
          pending={status === "streaming" ? answer : null}
          status={status}
          onSend={onSend}
        />
        <section className="stage">
          <FeatureField features={features} latents={health?.d_sae} />
          <ProbePanel
            trackers={trackers}
            registered={health?.trackers}
            onOpenBuilder={() => setView("build")}
          />
          <ProbeVisibilityPanel probeIds={probeIds} compact />
          <AdjudicationBanner adjudication={latest?.adjudication ?? null} />
          <p className="ethos">
            <b>Read-only instrument.</b> It flags low-confidence turns; it never edits the assistant's output.
          </p>
        </section>
      </main>

      {buildMounted && (
        <div className="obs-shell" style={{ display: view === "build" ? "flex" : "none" }}>
          <ProbeBuilderPage />
        </div>
      )}

      {observeMounted && (
        <div className="obs-shell" style={{ display: view === "observe" ? "flex" : "none" }}>
          <ObservabilityPage />
        </div>
      )}
    </div>
  );
}

```

### config.example.yaml

```yaml
# config.example.yaml — copy to config.yaml and edit. config.yaml is gitignored.
# Secrets (ANTHROPIC_API_KEY, SENTRY_DSN, SENTRY_AUTH_TOKEN, POD_TOKEN, HF_TOKEN) go in .env, never here.

runtime:
  mode: posthoc
  product_name: GlassBox

model:
  model_id: unsloth/gemma-3-4b-it
  layer: 17
  device: cuda
  preamble_skip: 12
  max_new_tokens: 512
  # Neutral default assistant prompt. See the "medical" profile below for the labeled example.
  system_prompt: |
    You are a helpful, honest, and harmless AI assistant. Answer clearly and accurately.
    When you are uncertain, say so plainly rather than guessing.

sae:
  release: gemma-scope-2-4b-it-res
  sae_id_pattern: "layer_{layer}_width_16k_l0_medium"
  d_in: 2560
  d_sae: 16384
  np_model: gemma-3-4b-it
  np_source_pattern: "{layer}-gemmascope-2-res-16k"

feature_cloud:
  rank_method: attribution
  autointerp: true

probes:
  enabled: [harmful, harmful_prompt, over_confidence]
  disabled: [uncertainty, hallucination, risk_awareness]
  builder:
    agent_model: claude-opus-4-8
    judge_model: claude-opus-4-8
    auroc_threshold: 0.75

observability:
  sentry:
    environment: production
    send_io: false   # PHI gate; keep false to never send raw I/O to Sentry

pod:
  url: http://localhost:8001
  timeout: 120

# --- Labeled example profile: medical clinical-decision-support (opt-in) ---
# GlassBox is a general-purpose interpretability/observability tool. Medical is ONE example.
# To use, copy the prompt below into model.system_prompt above:
#   system_prompt: |
#     You are a clinical decision-support assistant for licensed healthcare professionals.
#     You provide accurate, evidence-based medical information grounded in current clinical
#     guidelines and the peer-reviewed literature. Accuracy first; calibrated confidence;
#     safety first (surface contraindications, interactions, special populations); typical
#     adult dosing with caveats; you support, not replace, the clinician's judgment. Direct
#     emergencies to emergency services. Define abbreviations on first use.
# A matching example recon_probe and contrast_prompt for the medical profile:
#   sae:
#     recon_probe: "Is ibuprofen safe during the third trimester of pregnancy?"

```

### backend/__init__.py

```python
"""GlassBox backend package."""

```

### scripts/run_web.sh

```shell
#!/usr/bin/env bash
# Frontend dev server on :5173 (proxies /api -> :8000).
set -euo pipefail
cd "$(dirname "$0")/../frontend"
npm run dev

```

[100 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]