Spaces:
Sleeping
Sleeping
| """ | |
| NeuroScope-Web API Server | |
| FastAPI endpoints that expose Moon's research functions to the React frontend. | |
| This replaces the browser-based transformers.js worker with server-side | |
| TransformerLens inference. | |
| Run with: uvicorn main:app --reload --port 8000 | |
| API docs: http://localhost:8000/docs | |
| """ | |
| import os | |
| from typing import List, Optional | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| import model | |
| import research | |
| import refusal_pairs as refusal_pairs_module | |
| from refusal_bench.harmfulness_probe import ( | |
| evaluate_probe, | |
| extract_last_token_residuals, | |
| extract_with_ablation, | |
| train_probe, | |
| ) | |
| from refusal_bench.runner import run_bench, serialize as serialize_bench | |
| from refusal_bench.techniques import TECHNIQUES | |
| # ----------------------------------------------------------------------------- | |
| # App Setup | |
| # ----------------------------------------------------------------------------- | |
| app = FastAPI( | |
| title="NeuroScope-Web API", | |
| description="Moon's interpretability research powered by TransformerLens", | |
| version="0.1.0", | |
| ) | |
| # CORS origins are configurable so the same image runs locally and on HF Spaces. | |
| # Set ALLOWED_ORIGINS to your deployed frontend URL(s), e.g. | |
| # ALLOWED_ORIGINS=https://neuroscope.vercel.app,https://preview.neuroscope.vercel.app | |
| _default_origins = "http://localhost:3000,http://localhost:3001" | |
| ALLOWED_ORIGINS = [ | |
| origin.strip() | |
| for origin in os.environ.get("ALLOWED_ORIGINS", _default_origins).split(",") | |
| if origin.strip() | |
| ] | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| # Request/Response Models | |
| # ----------------------------------------------------------------------------- | |
| # Layer/head upper bounds cover GPT-2-small (12L, 12H), Llama-3.2-1B | |
| # (16L, 32H), and Llama-3.2-3B (28L, 24H). Static pydantic caps validate | |
| # request shape; per-model bounds are enforced at runtime via _validate_layer. | |
| class LoadRequest(BaseModel): | |
| model_name: str = Field(default="gpt2-small", description="Model to load") | |
| class PromptRequest(BaseModel): | |
| prompt: str = Field(..., description="Input text to analyze") | |
| class GradientRequest(BaseModel): | |
| prompt: str | |
| target_token: str = Field(..., description="Token to compute gradients toward") | |
| class AttentionRequest(BaseModel): | |
| prompt: str | |
| layer: int = Field(ge=0, le=27, description="Layer index (model-dependent)") | |
| head: int = Field(ge=0, le=31, description="Head index (model-dependent)") | |
| class SteeringRequest(BaseModel): | |
| positive_prompts: List[str] = Field(..., min_length=1) | |
| negative_prompts: List[str] = Field(..., min_length=1) | |
| layer: int = Field(default=6, ge=0, le=27, description="Layer for extraction") | |
| class SteeredGenerationRequest(BaseModel): | |
| prompt: str | |
| steering_vector: List[float] | |
| alpha: float = Field(default=1.0, description="Steering strength") | |
| layer: int = Field(default=6, ge=0, le=27) | |
| max_new_tokens: int = Field(default=30, ge=1, le=100) | |
| class AblationRequest(BaseModel): | |
| prompt: str | |
| direction: List[float] = Field( | |
| ..., description="Direction to project out of the residual stream" | |
| ) | |
| layer: int = Field(default=6, ge=0, le=27, description="Layer for ablation") | |
| max_new_tokens: int = Field(default=30, ge=1, le=100) | |
| class RefusalBenchRequest(BaseModel): | |
| """ | |
| Run the Refusal Bench: a head-to-head comparison of refusal-ablation | |
| techniques on the loaded model, scored by refusal rate + harmfulness- | |
| probe AUC. | |
| techniques: subset of refusal_bench.techniques.TECHNIQUES keys | |
| (e.g. ["arditi", "cosmic", "cheng", "wollschlager"]). | |
| layer: residual-stream layer for extraction + ablation. | |
| harmful_prompts/harmless_prompts: contrastive pairs. The runner | |
| splits 80/20 (configurable) into extraction + eval folds. | |
| """ | |
| technique_names: List[str] = Field(..., min_length=1) | |
| layer: int = Field(ge=0, le=27) | |
| harmful_prompts: List[str] = Field(..., min_length=5) | |
| harmless_prompts: List[str] = Field(..., min_length=5) | |
| test_fraction: float = Field(default=0.2, gt=0.0, lt=1.0) | |
| max_new_tokens: int = Field(default=32, ge=1, le=128) | |
| temperature: float = Field(default=0.7, ge=0.0, le=2.0) | |
| seed: int = Field(default=42, ge=0) | |
| class HarmfulnessProbeRequest(BaseModel): | |
| """ | |
| Train (and optionally re-evaluate) a Zhao-style harmfulness probe. | |
| - layer: residual-stream layer used for both probe training and (if a | |
| direction is provided) ablation. | |
| - ablation_direction: optional. If set, the probe trained on baseline | |
| residuals is re-evaluated on residuals collected while this direction | |
| is projected out at `layer`. Mean p_harm staying high == residual | |
| stream still encodes harmfulness even after surface refusal collapses. | |
| """ | |
| harmful_prompts: List[str] = Field(..., min_length=2) | |
| harmless_prompts: List[str] = Field(..., min_length=2) | |
| layer: int = Field( | |
| ge=0, | |
| le=27, | |
| description="Layer for residual extraction (and ablation if direction given)", | |
| ) | |
| ablation_direction: Optional[List[float]] = None | |
| # ----------------------------------------------------------------------------- | |
| # Runtime validation | |
| # ----------------------------------------------------------------------------- | |
| def _validate_layer(layer: int) -> None: | |
| """Reject layer indices that exceed the loaded model's depth.""" | |
| m = model.get_model() | |
| if layer >= m.cfg.n_layers: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"layer {layer} >= n_layers {m.cfg.n_layers}", | |
| ) | |
| def _validate_head(head: int) -> None: | |
| """Reject head indices that exceed the loaded model's n_heads.""" | |
| m = model.get_model() | |
| if head >= m.cfg.n_heads: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"head {head} >= n_heads {m.cfg.n_heads}", | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| # Endpoints | |
| # ----------------------------------------------------------------------------- | |
| async def health(): | |
| """Simple health check - useful for verifying the server is running.""" | |
| return {"status": "ok", "service": "neuroscope-api"} | |
| async def load_model(req: LoadRequest): | |
| """ | |
| Load a model into memory. Must be called before other endpoints. | |
| GPT-2 small (~500MB) takes a few seconds to load on first call. Llama | |
| models are gated and require HF_TOKEN in the environment. | |
| Subsequent calls with the same model return immediately. | |
| """ | |
| try: | |
| return model.load_model(req.model_name) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def logit_lens(req: PromptRequest): | |
| """ | |
| Run logit lens analysis on a prompt. | |
| Shows what the model would predict if we stopped at each layer. | |
| """ | |
| try: | |
| return research.logit_lens(req.prompt) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def attention_pattern(req: AttentionRequest): | |
| """Get attention weights for a specific layer and head.""" | |
| try: | |
| _validate_layer(req.layer) | |
| _validate_head(req.head) | |
| return research.get_attention_pattern(req.prompt, req.layer, req.head) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def token_gradients(req: GradientRequest): | |
| """Compute gradient-based token importance.""" | |
| try: | |
| return research.compute_token_gradients(req.prompt, req.target_token) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def steering_vector(req: SteeringRequest): | |
| """Compute a steering vector from contrastive prompts (difference of means).""" | |
| try: | |
| _validate_layer(req.layer) | |
| return research.extract_steering_vector( | |
| req.positive_prompts, | |
| req.negative_prompts, | |
| req.layer, | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def contrastive_pairs(): | |
| """Get Moon's curated sentiment pairs (validated to tokenize to same length).""" | |
| pairs = research.get_contrastive_pairs() | |
| return { | |
| "pairs": [{"positive": p, "negative": n} for p, n in pairs], | |
| "count": len(pairs), | |
| } | |
| async def refusal_pairs(): | |
| """ | |
| Get curated refusal-direction contrastive pairs (harmful + harmless). | |
| Populate via `python backend/scripts/build_refusal_pairs.py` — pulls | |
| JailbreakBench + Alpaca, length-matches on Llama-3.2-1B tokenizer. | |
| Returns count=0 until populated. | |
| """ | |
| pairs = refusal_pairs_module.get_refusal_pairs() | |
| return { | |
| "pairs": [{"harmful": h, "harmless": s} for h, s in pairs], | |
| "count": len(pairs), | |
| } | |
| async def generate_steered(req: SteeredGenerationRequest): | |
| """Generate text with a steering vector injected at a specific layer.""" | |
| try: | |
| _validate_layer(req.layer) | |
| return research.generate_steered( | |
| req.prompt, | |
| req.steering_vector, | |
| req.alpha, | |
| req.layer, | |
| req.max_new_tokens, | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def ablate_direction(req: AblationRequest): | |
| """ | |
| Generate text with a direction projected out of the residual stream. | |
| Implements h' = h - (h · d̂) d̂ at the chosen layer. Standard primitive | |
| for testing claims like "direction d mediates behavior X" (Arditi 2024). | |
| Returns ablated and baseline generations for side-by-side comparison. | |
| """ | |
| try: | |
| _validate_layer(req.layer) | |
| return research.ablate_along_direction( | |
| req.prompt, | |
| req.direction, | |
| req.layer, | |
| req.max_new_tokens, | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def harmfulness_probe(req: HarmfulnessProbeRequest): | |
| """ | |
| Train a linear harmfulness probe on residuals at `layer` (Zhao 2507.11878). | |
| Always returns the baseline (no-ablation) train/test AUCs and per-prompt | |
| P(harmful). If `ablation_direction` is supplied, also re-evaluates the | |
| probe on residuals collected while that direction is ablated at `layer`, | |
| returning the post-ablation P(harmful) on the harmful prompt set. | |
| The probe DOES NOT generalize across layers — call per layer for a sweep. | |
| """ | |
| try: | |
| _validate_layer(req.layer) | |
| harmful_resid = extract_last_token_residuals(req.harmful_prompts, req.layer) | |
| harmless_resid = extract_last_token_residuals(req.harmless_prompts, req.layer) | |
| train_result = train_probe(harmful_resid, harmless_resid) | |
| probe = train_result["model"] | |
| pre_eval = evaluate_probe( | |
| probe, | |
| harmful_resid, | |
| labels=[1] * harmful_resid.shape[0], | |
| ) | |
| response = { | |
| "layer": req.layer, | |
| "n_harmful": len(req.harmful_prompts), | |
| "n_harmless": len(req.harmless_prompts), | |
| "train_auc": train_result["train_auc"], | |
| "test_auc": train_result["test_auc"], | |
| "n_train": train_result["n_train"], | |
| "n_test": train_result["n_test"], | |
| "pre_ablation_p_harm": pre_eval["p_harm"], | |
| "pre_ablation_mean_p_harm": pre_eval["mean_p_harm"], | |
| "post_ablation_p_harm": None, | |
| "post_ablation_mean_p_harm": None, | |
| } | |
| if req.ablation_direction is not None: | |
| ablated_resid = extract_with_ablation( | |
| req.harmful_prompts, | |
| layer_extract=req.layer, | |
| ablation_direction=req.ablation_direction, | |
| ablation_layer=req.layer, | |
| ) | |
| post_eval = evaluate_probe( | |
| probe, | |
| ablated_resid, | |
| labels=[1] * ablated_resid.shape[0], | |
| ) | |
| response["post_ablation_p_harm"] = post_eval["p_harm"] | |
| response["post_ablation_mean_p_harm"] = post_eval["mean_p_harm"] | |
| return response | |
| except (RuntimeError, ValueError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def list_bench_techniques(): | |
| """List the refusal-ablation techniques registered in the bench.""" | |
| return { | |
| "techniques": [ | |
| { | |
| "key": key, | |
| "name": cls.name, | |
| "paper_url": cls.paper_url, | |
| } | |
| for key, cls in sorted(TECHNIQUES.items()) | |
| ], | |
| "count": len(TECHNIQUES), | |
| } | |
| async def refusal_bench(req: RefusalBenchRequest): | |
| """ | |
| Run the Refusal Bench end-to-end. | |
| Trains a shared harmfulness probe on the extraction split, then loops | |
| over every requested technique, fits it on the same extraction data, | |
| and scores it with refusal-rate (keyword-based) + probe AUC on the | |
| held-out eval split. Returns one row per technique. | |
| Two-axis story: a technique with high |Δ refusal rate| but low |Δ AUC| | |
| has suppressed verbal refusal without removing the model's internal | |
| harmfulness representation — the Zhao 2507.11878 dissociation, here | |
| measured across multiple ablation methods. | |
| """ | |
| try: | |
| _validate_layer(req.layer) | |
| result = run_bench( | |
| technique_names=req.technique_names, | |
| layer=req.layer, | |
| harmful_prompts=req.harmful_prompts, | |
| harmless_prompts=req.harmless_prompts, | |
| test_fraction=req.test_fraction, | |
| max_new_tokens=req.max_new_tokens, | |
| temperature=req.temperature, | |
| seed=req.seed, | |
| ) | |
| return serialize_bench(result) | |
| except (RuntimeError, ValueError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def pca_trajectories(req: PromptRequest): | |
| """3D PCA coordinates for all tokens across all layers.""" | |
| try: | |
| return research.compute_pca_trajectories(req.prompt) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |