Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Qwen-Scope · Live SAE Feature Steering</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #08080d; | |
| --bg-panel: rgba(18, 18, 26, 0.78); | |
| --bg-panel-strong: rgba(24, 24, 34, 0.92); | |
| --border: rgba(255,255,255,0.08); | |
| --border-strong: rgba(255,255,255,0.16); | |
| --fg: #ececf1; | |
| --fg-dim: #9b9ba8; | |
| --fg-faint: #5e5e6e; | |
| --accent: #7df9ff; | |
| --accent-2: #ff7df9; | |
| --warn: #ffb84d; | |
| --good: #74e2a3; | |
| --bad: #ff7d7d; | |
| --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; | |
| --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--fg); font-family:var(--sans); overflow:hidden; } | |
| #scene { position:fixed; inset:0; z-index:0; } | |
| #ui { position:fixed; inset:0; z-index:10; pointer-events:none; display:grid; | |
| grid-template-columns: 380px 1fr 420px; | |
| grid-template-rows: 1fr auto; | |
| gap:14px; padding:14px; } | |
| #ui > section { pointer-events:auto; } | |
| .panel { background:var(--bg-panel); border:1px solid var(--border); | |
| border-radius:14px; padding:14px; backdrop-filter: blur(14px) saturate(140%); | |
| -webkit-backdrop-filter: blur(14px) saturate(140%); overflow:hidden; | |
| transition: padding .15s ease, max-height .25s ease; } | |
| .panel h2 { margin:0 0 10px; font-size:11px; font-weight:600; | |
| text-transform:uppercase; letter-spacing:0.14em; color:var(--fg-dim); | |
| display:flex; justify-content:space-between; align-items:center; gap:8px; } | |
| .panel h3 { margin:0 0 6px; font-size:13px; font-weight:600; color:var(--fg); } | |
| /* Minimize button on each panel header */ | |
| .min-btn { background:transparent; border:1px solid var(--border-strong); | |
| color:var(--fg-dim); border-radius:5px; padding:1px 8px; font-size:14px; | |
| font-family:var(--mono); cursor:pointer; line-height:1; min-width:0; | |
| font-weight:400; letter-spacing:0; } | |
| .min-btn:hover { background:var(--bg-panel-strong); color:var(--fg); | |
| border-color:var(--accent); transform:none; box-shadow:none; } | |
| .panel.collapsed { padding:8px 14px; } | |
| .panel.collapsed > h2 { margin-bottom:0; } | |
| .panel.collapsed > :not(h2) { display:none; } | |
| /* Header strip */ | |
| #header { position:fixed; top:14px; left:50%; transform:translateX(-50%); | |
| z-index:20; pointer-events:auto; | |
| background:var(--bg-panel-strong); border:1px solid var(--border-strong); | |
| border-radius:999px; padding:8px 18px; display:flex; gap:14px; | |
| align-items:center; font-family:var(--mono); font-size:12px; color:var(--fg-dim); | |
| backdrop-filter: blur(20px); } | |
| #header .dot { width:8px; height:8px; border-radius:50%; background:var(--bad); transition:background .3s; } | |
| #header.live .dot { background:var(--good); box-shadow:0 0 12px var(--good); } | |
| #header.loading .dot { background:var(--warn); box-shadow:0 0 12px var(--warn); animation:pulse 1.5s ease-in-out infinite; } | |
| @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } } | |
| #header b { color:var(--fg); font-weight:600; } | |
| #model-select { | |
| background:transparent; color:var(--fg); border:1px solid var(--border-strong); | |
| border-radius:6px; padding:3px 8px; font-family:var(--mono); font-size:11px; | |
| outline:none; cursor:pointer; max-width:280px; | |
| } | |
| #model-select:focus { border-color:var(--accent); } | |
| #model-select option { background:#16161e; color:var(--fg); } | |
| /* Tab strip */ | |
| #tabs { position:fixed; top:62px; left:50%; transform:translateX(-50%); z-index:18; | |
| display:flex; gap:6px; pointer-events:auto; background:var(--bg-panel-strong); | |
| border:1px solid var(--border-strong); border-radius:999px; | |
| padding:4px; backdrop-filter: blur(20px); } | |
| .tab { background:transparent; border:none; color:var(--fg-dim); | |
| padding:6px 16px; font-size:11px; font-weight:600; | |
| text-transform:uppercase; letter-spacing:0.08em; border-radius:999px; | |
| cursor:pointer; transition: all 0.15s ease; } | |
| .tab:hover { color:var(--fg); background:rgba(255,255,255,0.04); } | |
| .tab.active { background: linear-gradient(135deg, rgba(125,249,255,0.18), rgba(255,125,249,0.12)); | |
| color:var(--fg); border:1px solid var(--border-strong); } | |
| /* Tab visibility — hide panels not matching the current tab */ | |
| body[data-tab="steering"] [data-show-tab]:not([data-show-tab~="steering"]) { display:none ; } | |
| body[data-tab="evaluation"] [data-show-tab]:not([data-show-tab~="evaluation"]) { display:none ; } | |
| body[data-tab="datacentric"] [data-show-tab]:not([data-show-tab~="datacentric"]) { display:none ; } | |
| /* Loading overlay shown during model swap */ | |
| #loading-overlay { | |
| position:fixed; inset:0; z-index:50; display:none; | |
| background:rgba(8,8,13,0.78); backdrop-filter: blur(6px); | |
| align-items:center; justify-content:center; | |
| } | |
| #loading-overlay.visible { display:flex; } | |
| #loading-overlay .lo-card { | |
| background:var(--bg-panel-strong); border:1px solid var(--border-strong); | |
| border-radius:14px; padding:24px 32px; text-align:center; min-width:300px; | |
| } | |
| /* Left: prompt + controls (top-left of screen, below the header) */ | |
| #left { display:flex; flex-direction:column; gap:14px; min-height:0; | |
| grid-column: 1; grid-row: 1; align-self: start; | |
| padding-top: 110px; } | |
| textarea, input[type="text"], input[type="number"] { | |
| width:100%; background:rgba(0,0,0,0.35); border:1px solid var(--border-strong); | |
| color:var(--fg); border-radius:8px; padding:10px 12px; font-family:var(--mono); | |
| font-size:13px; outline:none; resize:vertical; | |
| } | |
| textarea:focus, input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(125,249,255,0.12); } | |
| textarea { min-height:72px; } | |
| .row { display:flex; gap:8px; align-items:center; } | |
| .row > * { flex:1; } | |
| .row > .grow0 { flex:0; } | |
| button { background: linear-gradient(135deg, rgba(125,249,255,0.18), rgba(255,125,249,0.12)); | |
| border:1px solid var(--border-strong); color:var(--fg); border-radius:8px; | |
| padding:9px 14px; font-family:var(--sans); font-size:13px; font-weight:600; | |
| cursor:pointer; transition:all .15s; letter-spacing:0.02em; } | |
| button:hover:not(:disabled) { border-color:var(--accent); transform:translateY(-1px); box-shadow:0 4px 14px rgba(125,249,255,0.18); } | |
| button:disabled { opacity:0.4; cursor:not-allowed; } | |
| button.primary { background: linear-gradient(135deg, #7df9ff, #ff7df9); color:#0a0a14; border-color:transparent; } | |
| button.primary:hover:not(:disabled) { box-shadow:0 4px 18px rgba(255,125,249,0.32); } | |
| button.ghost { background:transparent; } | |
| /* Middle: nothing (3D scene shows through) */ | |
| #middle { grid-column: 2; grid-row: 1; pointer-events:none; } | |
| /* Bottom: output band spans all columns; sits below cloud area */ | |
| #bottom { grid-column: 1 / -1; grid-row: 2; pointer-events:auto; | |
| display:grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); | |
| gap:14px; | |
| max-height: 38vh; min-height: 0; | |
| margin: 0 0 0 0; } | |
| #bottom .panel { display:flex; flex-direction:column; | |
| min-width: 0; min-height: 0; overflow:hidden; } | |
| #bottom .panel.collapsed { padding:8px 14px; } | |
| #bottom .panel.collapsed > .panel-body { display:none; } | |
| .panel-body { flex: 1 1 auto; overflow:auto; min-height:0; min-width:0; } | |
| #right .panel { min-width:0; max-width:100%; } | |
| #right .panel > div { max-width:100%; overflow:auto; } | |
| /* Right: top features panel only — output moved to bottom band */ | |
| #right { display:flex; flex-direction:column; gap:14px; min-height:0; | |
| grid-column: 3; grid-row: 1; max-height: calc(100vh - 28px); padding-top:110px; } | |
| #features { flex: 1 1 auto; min-height: 120px; overflow-y:auto; } | |
| #features::-webkit-scrollbar { width:6px; } | |
| #features::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:3px; } | |
| .feat { padding:10px; border:1px solid var(--border); border-radius:10px; | |
| margin-bottom:8px; background:rgba(0,0,0,0.25); transition: border-color .2s, background .2s; } | |
| .feat:hover { border-color:var(--accent); } | |
| .feat.steered { border-color:var(--accent-2); background:rgba(255,125,249,0.06); } | |
| .feat-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; } | |
| .feat-id { font-family:var(--mono); font-size:12px; color:var(--fg); } | |
| .feat-act { font-family:var(--mono); font-size:11px; color:var(--good); } | |
| .feat-act.steered { color:var(--accent-2); } | |
| .slider-row { display:flex; align-items:center; gap:8px; margin-top:4px; } | |
| .slider-row input[type=range] { flex:1; -webkit-appearance:none; height:4px; background:var(--border-strong); border-radius:2px; outline:none; } | |
| .slider-row input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance:none; width:14px; height:14px; border-radius:50%; | |
| background: linear-gradient(135deg, #7df9ff, #ff7df9); cursor:pointer; | |
| box-shadow:0 0 8px rgba(125,249,255,0.5); border:none; | |
| } | |
| .slider-row input[type=range]::-moz-range-thumb { | |
| width:14px; height:14px; border-radius:50%; | |
| background: linear-gradient(135deg, #7df9ff, #ff7df9); cursor:pointer; border:none; | |
| } | |
| .slider-row .alpha-val { font-family:var(--mono); font-size:11px; color:var(--fg-dim); width:48px; text-align:right; } | |
| .feat-tools { display:flex; gap:6px; } | |
| .feat-tools button { padding:3px 8px; font-size:10px; font-weight:500; } | |
| /* Output area */ | |
| #output-wrap { flex:1; overflow-y:auto; } | |
| #output-wrap::-webkit-scrollbar { width:6px; } | |
| #output-wrap::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:3px; } | |
| .out-block { font-family:var(--mono); font-size:12px; line-height:1.55; | |
| background:rgba(0,0,0,0.4); border:1px solid var(--border); | |
| border-radius:8px; padding:10px; margin-bottom:8px; white-space:pre-wrap; | |
| word-break: break-word; color:var(--fg); min-height:36px; } | |
| .out-block.baseline { border-color:rgba(125,249,255,0.3); } | |
| .out-block.steered { border-color:rgba(255,125,249,0.3); } | |
| .out-label { display:flex; justify-content:space-between; align-items:center; margin-bottom:4px; | |
| font-size:10px; text-transform:uppercase; letter-spacing:0.12em; color:var(--fg-dim); } | |
| .verifier { font-family:var(--mono); font-size:10px; color:var(--fg-dim); margin-top:6px; padding:6px 8px; | |
| border-left:2px solid var(--border-strong); } | |
| .verifier .delta-up { color:var(--good); } | |
| .verifier .delta-down { color:var(--accent-2); } | |
| /* Hover tooltip for selected feature on 3D cloud */ | |
| #tooltip { position:fixed; z-index:25; pointer-events:none; | |
| background:var(--bg-panel-strong); border:1px solid var(--border-strong); | |
| border-radius:8px; padding:8px 12px; font-family:var(--mono); font-size:11px; | |
| line-height:1.45; | |
| color:var(--fg); display:none; transform: translate(-50%, calc(-100% - 12px)); | |
| min-width:180px; max-width:280px; backdrop-filter: blur(20px); } | |
| /* Loader */ | |
| .loader { display:inline-block; width:12px; height:12px; border:2px solid var(--border-strong); | |
| border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .empty { color:var(--fg-faint); font-size:12px; text-align:center; padding:24px 8px; } | |
| .hint { font-size:11px; color:var(--fg-faint); margin-top:6px; line-height:1.4; } | |
| .footer { position:fixed; bottom:8px; left:14px; z-index:20; font-family:var(--mono); | |
| font-size:10px; color:var(--fg-faint); letter-spacing:0.04em; } | |
| .legend { font-family:var(--mono); font-size:10px; color:var(--fg-faint); display:flex; gap:12px; margin-top:6px; } | |
| .legend span::before { content:''; display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; vertical-align:middle; } | |
| .legend .top::before { background:var(--accent); box-shadow:0 0 6px var(--accent); } | |
| .legend .pick::before { background:var(--accent-2); box-shadow:0 0 6px var(--accent-2); } | |
| .legend .dim::before { background:var(--fg-faint); } | |
| /* small-screen graceful fallback */ | |
| @media (max-width: 1100px) { | |
| #ui { grid-template-columns: 1fr; grid-template-rows: auto auto; } | |
| #left { grid-column:1; grid-row:1; max-height:none; } | |
| #right { grid-column:1; grid-row:2; padding-top:14px; max-height:none; } | |
| #middle { display:none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="scene"></canvas> | |
| <div id="header"> | |
| <span class="dot"></span> | |
| <span>QWEN-SCOPE LIVE</span> | |
| <span style="opacity:.4">·</span> | |
| <select id="model-select" title="Switch the loaded model + SAE pair"> | |
| <option>connecting…</option> | |
| </select> | |
| <span style="opacity:.4">·</span> | |
| <span id="hdr-layer-wrap" title="Click to change SAE layer"> | |
| layer <input type="number" id="layer-input" value="?" min="0" max="0" | |
| style="width:48px; padding:1px 4px; background:transparent; color:var(--accent); | |
| border:1px solid var(--border-strong); border-radius:4px; | |
| font-family:var(--mono); font-size:12px; text-align:center;" /> | |
| <span id="hdr-layer-meta">· mps · bfloat16</span> | |
| </span> | |
| <span style="opacity:.4">·</span> | |
| <span id="hdr-features">— features</span> | |
| </div> | |
| <div id="tabs"> | |
| <button class="tab active" data-tab="steering">Steering</button> | |
| <button class="tab" data-tab="evaluation">Evaluation</button> | |
| <button class="tab" data-tab="datacentric">Data-centric</button> | |
| </div> | |
| <div id="loading-overlay"> | |
| <div class="lo-card"> | |
| <div class="loader" style="width:24px;height:24px;border-width:3px;"></div> | |
| <div id="lo-msg" style="margin-top:14px; font-size:13px; color:var(--fg);">Loading…</div> | |
| <div id="lo-detail" style="margin-top:6px; font-size:11px; color:var(--fg-faint); font-family:var(--mono);"></div> | |
| </div> | |
| </div> | |
| <div id="ui"> | |
| <section id="left"> | |
| <div class="panel" data-pid="prompt" data-show-tab="steering"> | |
| <h2>1 · Prompt <button class="min-btn" data-min="prompt" title="Minimize panel">−</button></h2> | |
| <textarea id="prompt" placeholder="The capital of France is">The capital of France is</textarea> | |
| <div class="row" style="margin-top:8px;"> | |
| <label style="font-size:11px; color:var(--fg-dim); flex:0 0 auto;">top K</label> | |
| <input type="number" id="top-k" value="20" min="1" max="500" style="flex:0 0 80px;" /> | |
| <button class="primary" id="btn-encode" style="flex:1;">Encode & show top features</button> | |
| </div> | |
| <div class="hint"> | |
| Encodes the prompt's last-token residual at the SAE's layer and ranks the firing features. | |
| TopK SAE has at most K=50 nonzero features per token, so request up to ~50. | |
| </div> | |
| </div> | |
| <div class="panel" data-pid="generate" data-show-tab="steering"> | |
| <h2>2 · Generate <button class="min-btn" data-min="generate" title="Minimize panel">−</button></h2> | |
| <div class="row"> | |
| <label style="font-size:11px; color:var(--fg-dim); flex:0 0 auto;">tokens</label> | |
| <input type="number" id="max-tokens" value="40" min="5" max="200" style="flex:0 0 80px;" /> | |
| <button class="primary" id="btn-generate" disabled>Generate baseline + steered</button> | |
| </div> | |
| <div class="hint"> | |
| Runs <code>model.generate</code> twice: once with no hooks, once with all active sliders applied | |
| as residual-stream additions <code>h ← h + α · W_dec[:, feat]</code>. | |
| </div> | |
| </div> | |
| <div class="panel" data-pid="viz" data-show-tab="steering evaluation datacentric" style="flex:0 0 auto;"> | |
| <h2>3 · Visualization <button class="min-btn" data-min="viz" title="Minimize panel">−</button></h2> | |
| <div class="legend"> | |
| <span class="dim">all 32K features</span> | |
| <span class="top">top firing</span> | |
| <span class="pick">steered</span> | |
| </div> | |
| <div class="hint"> | |
| Each point is one SAE feature; positions are the top-3 PCA components of <code>W_enc</code>. | |
| Click a point to add it as a steering target. | |
| </div> | |
| </div> | |
| </section> | |
| <section id="middle"></section> | |
| <section id="right"> | |
| <div class="panel" id="features-panel" data-pid="features" data-show-tab="steering" | |
| style="flex: 0 1 auto; max-height: 38vh; display:flex; flex-direction:column; min-height:0;"> | |
| <h2>Top features <span id="feat-count" style="font-family:var(--mono); color:var(--fg-faint);"></span> | |
| <button class="min-btn" data-min="features" title="Minimize panel">−</button></h2> | |
| <div id="features" style="overflow-y:auto; flex:1 1 auto; min-height:0;"><div class="empty">Encode a prompt to populate features.</div></div> | |
| </div> | |
| <div class="panel" id="heatmap-panel" data-show-tab="steering" | |
| style="flex: 1 1 auto; display:flex; flex-direction:column; min-height:200px;"> | |
| <h2>Per-token feature heatmap | |
| <span style="display:flex; gap:8px; align-items:center;"> | |
| <label style="font-size:10px; color:var(--fg-faint);"> | |
| <input type="checkbox" id="heatmap-skip-first" style="vertical-align:middle;" /> skip first | |
| </label> | |
| <button class="min-btn" data-min="heatmap-panel" title="Minimize panel">−</button> | |
| </span> | |
| </h2> | |
| <div id="heatmap-grid" style="overflow:auto; flex:1 1 auto; min-height:0;"> | |
| <span class="empty">Encode a prompt — heatmap fills automatically.</span> | |
| </div> | |
| </div> | |
| <!-- Evaluation: corpus encoding + signature heatmap --> | |
| <div class="panel" data-show-tab="evaluation" id="eval-corpus"> | |
| <h2>Evaluation · corpus encode | |
| <button class="min-btn" data-min="eval-corpus" title="Minimize panel">−</button> | |
| </h2> | |
| <textarea id="eval-prompts" rows="6" placeholder="One prompt per line. Example: The capital of France is Bonjour comment allez-vous def fibonacci(n): The mitochondria is the powerhouse"></textarea> | |
| <div class="row" style="margin-top:8px;"> | |
| <button class="primary" id="btn-eval-encode" style="flex:1;">Encode corpus</button> | |
| </div> | |
| <div class="hint"> | |
| Encodes each prompt's last-token residual through the SAE. Returns | |
| per-sample top features and corpus-level firing rates. | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="evaluation" id="eval-corpus-features"> | |
| <h2>Corpus features <span id="eval-stats" style="font-family:var(--mono); color:var(--fg-faint);"></span> | |
| <button class="min-btn" data-min="eval-corpus-features" title="Minimize panel">−</button> | |
| </h2> | |
| <div id="eval-features-list" style="overflow-y:auto; max-height:30vh;"> | |
| <div class="empty">Encode a corpus to see feature firing rates.</div> | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="evaluation" id="eval-compare"> | |
| <h2>Compare two prompt sets | |
| <button class="min-btn" data-min="eval-compare" title="Minimize panel">−</button> | |
| </h2> | |
| <textarea id="cmp-a" rows="3" placeholder="Set A — one prompt per line"></textarea> | |
| <textarea id="cmp-b" rows="3" placeholder="Set B — one prompt per line" style="margin-top:6px;"></textarea> | |
| <div class="row" style="margin-top:6px;"> | |
| <button class="primary" id="btn-cmp" style="flex:1;">Find distinguishing features</button> | |
| </div> | |
| <div class="hint"> | |
| Encodes both sets and ranks features by |fire_rate(A) − fire_rate(B)|. | |
| Shows which features distinguish A from B. | |
| </div> | |
| </div> | |
| <!-- Data-centric: filter + steered synthesis --> | |
| <div class="panel" data-show-tab="datacentric" id="dc-corpus"> | |
| <h2>Data-centric · corpus | |
| <button class="min-btn" data-min="dc-corpus" title="Minimize panel">−</button> | |
| </h2> | |
| <textarea id="dc-prompts" rows="5" placeholder="One prompt per line — same shape as Evaluation."></textarea> | |
| <div class="row" style="margin-top:8px;"> | |
| <button class="primary" id="btn-dc-encode" style="flex:1;">Encode for filtering</button> | |
| </div> | |
| <div class="hint"> | |
| Encode docs, then filter by feature signature. | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="datacentric" id="dc-filter"> | |
| <h2>Filter | |
| <button class="min-btn" data-min="dc-filter" title="Minimize panel">−</button> | |
| </h2> | |
| <div class="row"> | |
| <label style="flex:0 0 auto; font-size:11px; color:var(--fg-dim);">feature</label> | |
| <input type="number" id="dc-filter-id" placeholder="feature id" min="0" max="199999" style="flex:1;" /> | |
| <select id="dc-filter-mode" style="flex:0 0 auto; background:rgba(0,0,0,0.35); color:var(--fg); border:1px solid var(--border-strong); border-radius:6px; padding:6px 8px; font-family:var(--mono); font-size:11px;"> | |
| <option value="include">includes</option> | |
| <option value="exclude">excludes</option> | |
| </select> | |
| </div> | |
| <div class="row" style="margin-top:8px;"> | |
| <button id="btn-dc-filter" style="flex:1;">Apply filter</button> | |
| <button id="btn-dc-clear" class="ghost" style="flex:0 0 auto;">clear</button> | |
| </div> | |
| <div class="hint"> | |
| "Includes" = keep only docs where this feature fired (any activation). | |
| "Excludes" = drop docs where it fired. | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="datacentric" id="dc-synth"> | |
| <h2>Steered synthesis | |
| <button class="min-btn" data-min="dc-synth" title="Minimize panel">−</button> | |
| </h2> | |
| <div class="row"> | |
| <label style="flex:0 0 auto; font-size:11px; color:var(--fg-dim);">feature</label> | |
| <input type="number" id="dc-synth-id" placeholder="feature id" min="0" max="199999" style="flex:1;" /> | |
| <label style="flex:0 0 auto; font-size:11px; color:var(--fg-dim);">α</label> | |
| <input type="number" id="dc-synth-alpha" value="50" min="-200" max="200" style="flex:0 0 60px;" /> | |
| </div> | |
| <div class="row" style="margin-top:6px;"> | |
| <label style="flex:0 0 auto; font-size:11px; color:var(--fg-dim);">tokens</label> | |
| <input type="number" id="dc-synth-tokens" value="30" min="5" max="200" style="flex:0 0 70px;" /> | |
| <button class="primary" id="btn-dc-synth" style="flex:1;">Synthesize from corpus</button> | |
| </div> | |
| <div class="hint"> | |
| For each prompt in the corpus above, generate a steered completion with | |
| <code>h ← h + α · W_dec[:, feature]</code>. Useful for producing | |
| targeted training examples that fire a specific feature. | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Bottom band --> | |
| <section id="bottom"> | |
| <!-- STEERING bottom: baseline / steered / heatmap --> | |
| <div class="panel" data-show-tab="steering" data-pid="baseline"> | |
| <h2>Baseline output (α=0) | |
| <span style="display:flex; gap:8px; align-items:center;"> | |
| <span id="base-time" style="font-family:var(--mono); color:var(--fg-faint);"></span> | |
| <button class="min-btn" data-min="baseline" title="Minimize panel">−</button> | |
| </span> | |
| </h2> | |
| <div class="panel-body"> | |
| <div class="out-block baseline" id="out-baseline"><span class="empty">(no run yet)</span></div> | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="steering" data-pid="steered"> | |
| <h2>Steered output | |
| <span style="display:flex; gap:8px; align-items:center;"> | |
| <span id="steered-time" style="font-family:var(--mono); color:var(--fg-faint);"></span> | |
| <button class="min-btn" data-min="steered" title="Minimize panel">−</button> | |
| </span> | |
| </h2> | |
| <div class="panel-body"> | |
| <div class="out-block steered" id="out-steered"><span class="empty">(no run yet)</span></div> | |
| <div class="verifier" id="verifier"></div> | |
| </div> | |
| </div> | |
| <!-- EVALUATION bottom: per-sample table + signature heatmap --> | |
| <div class="panel" data-show-tab="evaluation" id="eval-samples-panel" style="grid-column: 1 / 2;"> | |
| <h2>Per-sample top features | |
| <button class="min-btn" data-min="eval-samples-panel" title="Minimize panel">−</button> | |
| </h2> | |
| <div class="panel-body"> | |
| <div id="eval-samples"><span class="empty">Encode a corpus to populate.</span></div> | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="evaluation" id="eval-heatmap-panel" style="grid-column: 2 / 3;"> | |
| <h2>Signature heatmap / Compare results | |
| <span style="display:flex; gap:6px; align-items:center;"> | |
| <button id="btn-eval-view-heatmap" class="ghost" style="padding:2px 8px; font-size:10px;">heatmap</button> | |
| <button id="btn-eval-view-compare" class="ghost" style="padding:2px 8px; font-size:10px;">compare</button> | |
| <button class="min-btn" data-min="eval-heatmap-panel" title="Minimize panel">−</button> | |
| </span> | |
| </h2> | |
| <div class="panel-body"> | |
| <div id="eval-heatmap"><span class="empty">Encode a corpus to populate.</span></div> | |
| <div id="eval-compare-results" style="display:none;"><span class="empty">Run Compare to populate.</span></div> | |
| </div> | |
| </div> | |
| <!-- DATA-CENTRIC bottom: filtered list + synth output --> | |
| <div class="panel" data-show-tab="datacentric" id="dc-filtered-panel"> | |
| <h2>Filtered docs <span id="dc-filter-stats" style="font-family:var(--mono); color:var(--fg-faint);"></span> | |
| <button class="min-btn" data-min="dc-filtered-panel" title="Minimize panel">−</button> | |
| </h2> | |
| <div class="panel-body"> | |
| <div id="dc-filtered-list"><span class="empty">Encode + apply filter to populate.</span></div> | |
| </div> | |
| </div> | |
| <div class="panel" data-show-tab="datacentric" id="dc-synth-panel"> | |
| <h2>Synthesis output <span id="dc-synth-stats" style="font-family:var(--mono); color:var(--fg-faint);"></span> | |
| <button class="min-btn" data-min="dc-synth-panel" title="Minimize panel">−</button> | |
| </h2> | |
| <div class="panel-body"> | |
| <div id="dc-synth-list"><span class="empty">Run "Synthesize from corpus" to populate.</span></div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <div id="tooltip"></div> | |
| <div class="footer"> | |
| <span id="status">idle</span> | |
| </div> | |
| <script> | |
| const API = ""; // same-origin: HF Space serves API + HTML from one process | |
| const N_FEATURES = 32768; | |
| // --- State --- | |
| const state = { | |
| positions: null, // Float32Array length N*3 | |
| topFeatures: [], // [{id, act}, ...] from /encode | |
| steering: new Map(), // feature_id -> alpha | |
| picked: null, // currently hovered feature_id | |
| }; | |
| // --- Three.js scene --- | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.FogExp2(0x08080d, 0.08); | |
| const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.01, 60); | |
| camera.position.set(0, 0, 3.4); | |
| const renderer = new THREE.WebGLRenderer({ | |
| canvas: document.getElementById("scene"), | |
| antialias: true, | |
| alpha: true, | |
| }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setClearColor(0x000000, 0); | |
| // Subtle starry haze background — many tiny dim points | |
| function addStarHaze() { | |
| const N = 600; | |
| const g = new THREE.BufferGeometry(); | |
| const pos = new Float32Array(N*3); | |
| for (let i=0; i<N; i++) { | |
| const r = 18 + Math.random()*6; | |
| const theta = Math.random()*Math.PI*2; | |
| const phi = Math.acos(2*Math.random()-1); | |
| pos[i*3] = r*Math.sin(phi)*Math.cos(theta); | |
| pos[i*3+1] = r*Math.sin(phi)*Math.sin(theta); | |
| pos[i*3+2] = r*Math.cos(phi); | |
| } | |
| g.setAttribute("position", new THREE.BufferAttribute(pos, 3)); | |
| const m = new THREE.PointsMaterial({ color: 0x2a2a3a, size: 0.06, sizeAttenuation: true, transparent:true, opacity:0.5 }); | |
| scene.add(new THREE.Points(g, m)); | |
| } | |
| addStarHaze(); | |
| // Feature point cloud — built once positions arrive | |
| let featurePoints = null, featureGeometry = null; | |
| function buildFeatureCloud(positions) { | |
| featureGeometry = new THREE.BufferGeometry(); | |
| const N = positions.length / 3; | |
| featureGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); | |
| const colors = new Float32Array(N*3); | |
| const sizes = new Float32Array(N); | |
| for (let i=0; i<N; i++) { | |
| colors[i*3] = 1.0; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0; | |
| sizes[i] = 0.5; | |
| } | |
| featureGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); | |
| featureGeometry.setAttribute("size", new THREE.BufferAttribute(sizes, 1)); | |
| const material = new THREE.ShaderMaterial({ | |
| uniforms: { uPixelRatio: { value: renderer.getPixelRatio() } }, | |
| vertexShader: ` | |
| attribute float size; | |
| varying vec3 vColor; | |
| varying float vIsActive; | |
| varying float vSize; | |
| uniform float uPixelRatio; | |
| void main() { | |
| vColor = color; | |
| vIsActive = step(1.0, size); // active features (sizes attribute >= 1.5) | |
| vec4 mv = modelViewMatrix * vec4(position, 1.0); | |
| gl_Position = projectionMatrix * mv; | |
| // Base features: small fixed-pixel hard dots (~3 device px, so 6 retina px). | |
| // Active features: size attribute -> px directly, with mild perspective | |
| // (closer = larger), capped so they never balloon out. | |
| float basePx = 3.0 * uPixelRatio; | |
| float scaledPx = size * 3.0 * (3.4 / -mv.z) * uPixelRatio; | |
| scaledPx = clamp(scaledPx, basePx, 26.0 * uPixelRatio); | |
| gl_PointSize = mix(basePx, scaledPx, vIsActive); | |
| vSize = gl_PointSize; | |
| }`, | |
| fragmentShader: ` | |
| varying vec3 vColor; | |
| varying float vIsActive; | |
| varying float vSize; | |
| void main() { | |
| vec2 uv = gl_PointCoord - 0.5; | |
| float d = length(uv); | |
| if (d > 0.5) discard; | |
| // Anti-alias the rim with a fixed 1-pixel band, regardless of point size, | |
| // so even tiny points stay solid and crisp instead of going gaussian. | |
| float aa = 1.0 / max(vSize, 2.0); | |
| float core = 1.0 - smoothstep(0.5 - aa, 0.5, d); | |
| // Halo only on active features | |
| float glow = vIsActive * (1.0 - smoothstep(0.18, 0.5, d)) * 0.35; | |
| vec3 col = mix(vColor, vColor * 1.5, vIsActive); | |
| float alpha = clamp(core + glow, 0.0, 1.0); | |
| gl_FragColor = vec4(col, alpha); | |
| }`, | |
| transparent: true, | |
| depthWrite: false, | |
| vertexColors: true, | |
| blending: THREE.NormalBlending, | |
| }); | |
| featurePoints = new THREE.Points(featureGeometry, material); | |
| scene.add(featurePoints); | |
| } | |
| // Lazy orbit-like rotation (no heavy controls dep) | |
| let userInteracting = false, autoRot = 0.06; | |
| let dragging = false, lastX = 0, lastY = 0; | |
| let yaw = 0.4, pitch = 0.05, dist = 3.4; | |
| const cnv = document.getElementById("scene"); | |
| cnv.addEventListener("pointerdown", e => { dragging = true; userInteracting = true; lastX = e.clientX; lastY = e.clientY; }); | |
| window.addEventListener("pointerup", () => { dragging = false; }); | |
| window.addEventListener("pointermove", e => { | |
| if (dragging) { | |
| yaw += (e.clientX - lastX) * 0.005; | |
| pitch += (e.clientY - lastY) * 0.005; | |
| pitch = Math.max(-1.4, Math.min(1.4, pitch)); | |
| lastX = e.clientX; lastY = e.clientY; | |
| } | |
| }); | |
| cnv.addEventListener("wheel", e => { | |
| dist *= (1 + e.deltaY * 0.0012); | |
| dist = Math.max(1.3, Math.min(8, dist)); | |
| e.preventDefault(); | |
| }, { passive:false }); | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| function tick(t) { | |
| if (!userInteracting) yaw += 0.0015; | |
| camera.position.x = dist * Math.sin(yaw) * Math.cos(pitch); | |
| camera.position.y = dist * Math.sin(pitch); | |
| camera.position.z = dist * Math.cos(yaw) * Math.cos(pitch); | |
| camera.lookAt(0, 0, 0); | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(tick); | |
| } | |
| requestAnimationFrame(tick); | |
| // --- Picking (raycasting) --- | |
| const raycaster = new THREE.Raycaster(); | |
| raycaster.params.Points = { threshold: 0.04 }; | |
| const mouse = new THREE.Vector2(); | |
| const tooltip = document.getElementById("tooltip"); | |
| cnv.addEventListener("mousemove", e => { | |
| mouse.x = (e.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; | |
| if (!featurePoints) return; | |
| raycaster.setFromCamera(mouse, camera); | |
| const hits = raycaster.intersectObject(featurePoints); | |
| if (hits.length > 0) { | |
| const id = hits[0].index; | |
| state.picked = id; | |
| const top = state.topFeatures.find(t => t.id === id); | |
| const isSteered = state.steering.has(id); | |
| const alpha = isSteered ? state.steering.get(id) : null; | |
| let html = `<div style="color:var(--fg); font-weight:600;">feature ${id}</div>`; | |
| if (top) { | |
| const rank = state.topFeatures.indexOf(top); | |
| html += `<div style="margin-top:3px; color:var(--accent);">rank #${rank} of top firing · activation ${top.act.toFixed(3)}</div>`; | |
| } else { | |
| html += `<div style="margin-top:3px; color:var(--fg-faint);">not in top firing for current prompt</div>`; | |
| } | |
| if (isSteered) { | |
| const sign = alpha >= 0 ? "+" : ""; | |
| html += `<div style="margin-top:3px; color:var(--accent-2);">steered · α=${sign}${alpha.toFixed(0)}</div>`; | |
| } | |
| html += `<div style="margin-top:5px; color:var(--fg-faint); font-size:10px;">click to ${isSteered ? "edit slider" : "add steering slider"}</div>`; | |
| tooltip.innerHTML = html; | |
| tooltip.style.display = "block"; | |
| tooltip.style.left = e.clientX + "px"; | |
| tooltip.style.top = e.clientY + "px"; | |
| } else { | |
| state.picked = null; | |
| tooltip.style.display = "none"; | |
| } | |
| }); | |
| cnv.addEventListener("click", () => { | |
| if (state.picked != null) { | |
| addSteerSlot(state.picked, 0); | |
| } | |
| }); | |
| // --- Update particle attributes after encode --- | |
| function repaintCloud() { | |
| if (!featureGeometry) return; | |
| const colors = featureGeometry.attributes.color.array; | |
| const sizes = featureGeometry.attributes.size.array; | |
| const N = sizes.length; | |
| for (let i=0; i<N; i++) { | |
| colors[i*3] = 1.0; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0; | |
| sizes[i] = 0.5; | |
| } | |
| // Top firing — cyan, larger | |
| const maxAct = state.topFeatures[0]?.act || 1; | |
| for (const f of state.topFeatures) { | |
| const i = f.id; | |
| const t = Math.min(1, Math.abs(f.act) / maxAct); | |
| colors[i*3] = 0.49 * t + 1.0 * (1-t); | |
| colors[i*3+1] = 0.97 * t + 1.0 * (1-t); | |
| colors[i*3+2] = 1.00; | |
| sizes[i] = 1.5 + 4.5 * t; | |
| } | |
| // Steered — magenta override | |
| for (const [id, slot] of state.steering) { | |
| const alpha = (typeof slot === "object") ? slot.alpha : slot; | |
| if (alpha === 0) continue; | |
| const t = Math.min(1, Math.abs(alpha) / 100); | |
| colors[id*3] = 1.00; | |
| colors[id*3+1] = 0.49 * (1-t) + 0.4 * t; | |
| colors[id*3+2] = 0.97; | |
| sizes[id] = 4 + 5 * t; | |
| } | |
| featureGeometry.attributes.color.needsUpdate = true; | |
| featureGeometry.attributes.size.needsUpdate = true; | |
| } | |
| // --- API helpers --- | |
| function setStatus(msg, busy=false) { | |
| const el = document.getElementById("status"); | |
| el.innerHTML = busy ? `<span class="loader"></span> ${msg}` : msg; | |
| } | |
| async function api(path, body=null) { | |
| const opts = body ? {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)} | |
| : {method:"GET"}; | |
| const r = await fetch(API + path, opts); | |
| if (!r.ok) throw new Error(`${path} -> ${r.status} ${r.statusText}`); | |
| return await r.json(); | |
| } | |
| // --- Boot --- | |
| function applyHealthToHeader(hp) { | |
| const layerInput = document.getElementById("layer-input"); | |
| layerInput.value = hp.layer; | |
| layerInput.max = (hp.n_layers || 1) - 1; | |
| layerInput.title = `Layer 0..${hp.n_layers - 1}`; | |
| document.getElementById("hdr-layer-meta").textContent = ` · ${hp.device} · ${hp.dtype}`; | |
| document.getElementById("hdr-features").textContent = `${hp.n_features.toLocaleString()} features`; | |
| const hdr = document.getElementById("header"); | |
| hdr.classList.remove("loading"); | |
| hdr.classList.add("live"); | |
| if (hp.transferred && hp.note) { | |
| setStatus("⚠ transferred SAE: " + hp.note); | |
| } | |
| } | |
| // Layer hot-swap: change SAE layer without reloading model | |
| let layerSwapPending = null; | |
| document.getElementById("layer-input").addEventListener("change", async (ev) => { | |
| const newLayer = parseInt(ev.target.value); | |
| const hp = await api("/health"); | |
| if (newLayer === hp.layer) return; | |
| if (isNaN(newLayer) || newLayer < 0 || newLayer >= hp.n_layers) { | |
| ev.target.value = hp.layer; return; | |
| } | |
| // Clear stale UI state — features and outputs are layer-specific | |
| state.topFeatures = []; state.steering.clear(); | |
| document.getElementById("features").innerHTML = `<div class="empty">Encode a prompt to populate features.</div>`; | |
| document.getElementById("feat-count").textContent = ""; | |
| document.getElementById("out-baseline").innerHTML = `<span class="empty">(no run yet)</span>`; | |
| document.getElementById("out-steered").innerHTML = `<span class="empty">(no run yet)</span>`; | |
| document.getElementById("verifier").innerHTML = ""; | |
| document.getElementById("heatmap-grid").innerHTML = `<span class="empty">Encode a prompt — heatmap fills automatically.</span>`; | |
| document.getElementById("btn-generate").disabled = true; | |
| showLoading(`Switching to layer ${newLayer}…`, | |
| "First time may take ~20s (download + SVD); subsequent switches are <0.1s."); | |
| try { | |
| const r = await api("/set_layer", {layer: newLayer}); | |
| const hp2 = await api("/health"); | |
| applyHealthToHeader(hp2); | |
| applyPositionsToCloud(r.positions); | |
| setStatus(`layer ${newLayer}` + (r.from_cache ? " (cached)" : "")); | |
| } catch (e) { | |
| setStatus(`layer swap error: ${e.message}`); | |
| ev.target.value = hp.layer; | |
| } finally { | |
| hideLoading(); | |
| } | |
| }); | |
| function applyPositionsToCloud(positions) { | |
| const flat = new Float32Array(positions.length * 3); | |
| for (let i=0; i<positions.length; i++) { | |
| flat[i*3] = positions[i][0] * 2.2; | |
| flat[i*3+1] = positions[i][1] * 2.2; | |
| flat[i*3+2] = positions[i][2] * 2.2; | |
| } | |
| state.positions = flat; | |
| if (featurePoints) { | |
| scene.remove(featurePoints); | |
| featureGeometry.dispose(); | |
| featurePoints.material.dispose(); | |
| featurePoints = null; featureGeometry = null; | |
| } | |
| buildFeatureCloud(flat); | |
| } | |
| function fillModelDropdown(models, currentModel) { | |
| const sel = document.getElementById("model-select"); | |
| sel.innerHTML = ""; | |
| for (const m of models) { | |
| const opt = document.createElement("option"); | |
| opt.value = m.model; | |
| const xferTag = m.transferred ? " ⚠" : ""; | |
| opt.textContent = `${m.model.replace("Qwen/","")} (${m.approx_size_gb}GB · ${m.n_features.toLocaleString()}f)${xferTag}`; | |
| if (m.model === currentModel) opt.selected = true; | |
| sel.appendChild(opt); | |
| } | |
| // If only one model in the catalog (HF Space deployment), make it | |
| // visually informational rather than interactive. | |
| if (models.length <= 1) { | |
| sel.disabled = true; | |
| sel.title = "Locked to Qwen3-1.7B-Base on HF Space (free CPU). Run locally to swap models."; | |
| sel.style.cursor = "default"; | |
| sel.style.opacity = "0.85"; | |
| } | |
| } | |
| function showLoading(msg, detail="") { | |
| document.getElementById("lo-msg").textContent = msg; | |
| document.getElementById("lo-detail").textContent = detail; | |
| document.getElementById("loading-overlay").classList.add("visible"); | |
| document.getElementById("header").classList.remove("live"); | |
| document.getElementById("header").classList.add("loading"); | |
| document.getElementById("btn-encode").disabled = true; | |
| document.getElementById("btn-generate").disabled = true; | |
| } | |
| function hideLoading() { | |
| document.getElementById("loading-overlay").classList.remove("visible"); | |
| document.getElementById("btn-encode").disabled = false; | |
| } | |
| async function boot() { | |
| setStatus("loading positions…", true); | |
| try { | |
| const [hp, ph, ml] = await Promise.all([ | |
| api("/health"), | |
| api("/positions"), | |
| api("/list_models"), | |
| ]); | |
| fillModelDropdown(ml.models, hp.model); | |
| applyHealthToHeader(hp); | |
| applyPositionsToCloud(ph.positions); | |
| setStatus("ready"); | |
| if (hp.transferred && hp.note) setStatus("⚠ " + hp.note); | |
| } catch (e) { | |
| setStatus(`server unreachable at ${API}: ${e.message}`); | |
| document.getElementById("model-select").innerHTML = `<option style="color:var(--bad)">server offline</option>`; | |
| } | |
| } | |
| boot(); | |
| // Tab switching | |
| document.body.dataset.tab = "steering"; | |
| document.querySelectorAll(".tab").forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| document.querySelectorAll(".tab").forEach(b => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| document.body.dataset.tab = btn.dataset.tab; | |
| }); | |
| }); | |
| // Minimize toggle on every panel header | |
| document.querySelectorAll(".min-btn").forEach(btn => { | |
| btn.addEventListener("click", (ev) => { | |
| ev.stopPropagation(); | |
| const panel = btn.closest(".panel"); | |
| if (!panel) return; | |
| panel.classList.toggle("collapsed"); | |
| btn.textContent = panel.classList.contains("collapsed") ? "+" : "−"; | |
| btn.title = panel.classList.contains("collapsed") ? "Expand panel" : "Minimize panel"; | |
| }); | |
| }); | |
| // Model swap on dropdown change | |
| document.getElementById("model-select").addEventListener("change", async (ev) => { | |
| const newModel = ev.target.value; | |
| const ml = await api("/list_models"); | |
| const entry = ml.models.find(m => m.model === newModel); | |
| if (!entry) return; | |
| const ok = confirm( | |
| `Load ${newModel}?\n\n` + | |
| `~${entry.approx_size_gb}GB download (or cached if seen before).\n` + | |
| `${entry.n_features.toLocaleString()} SAE features, ${entry.n_layers} layers.\n` + | |
| (entry.transferred ? `⚠ TRANSFERRED SAE: ${entry.note}\n` : ``) + | |
| `\nThis blocks the server until ready.` | |
| ); | |
| if (!ok) { | |
| // revert dropdown to current | |
| const hp = await api("/health"); | |
| ev.target.value = hp.model; | |
| return; | |
| } | |
| // Reset client-side state | |
| state.topFeatures = []; state.steering.clear(); | |
| document.getElementById("features").innerHTML = `<div class="empty">Encode a prompt to populate features.</div>`; | |
| document.getElementById("feat-count").textContent = ""; | |
| document.getElementById("out-baseline").innerHTML = `<span class="empty">(no run yet)</span>`; | |
| document.getElementById("out-steered").innerHTML = `<span class="empty">(no run yet)</span>`; | |
| document.getElementById("verifier").innerHTML = ""; | |
| document.getElementById("btn-generate").disabled = true; | |
| showLoading(`Loading ${newModel}…`, | |
| `~${entry.approx_size_gb}GB · this is real, watch the server log`); | |
| try { | |
| const r = await fetch(API + "/load_model", { | |
| method:"POST", headers:{"Content-Type":"application/json"}, | |
| body: JSON.stringify({model:newModel}) | |
| }); | |
| if (!r.ok) { | |
| const txt = await r.text(); | |
| throw new Error(`${r.status}: ${txt}`); | |
| } | |
| const data = await r.json(); | |
| const hp = await api("/health"); | |
| applyHealthToHeader(hp); | |
| applyPositionsToCloud(data.positions); | |
| setStatus(`loaded ${newModel}`); | |
| if (hp.transferred && hp.note) setStatus("⚠ " + hp.note); | |
| } catch (e) { | |
| setStatus(`load failed: ${e.message}`); | |
| alert(`Load failed:\n\n${e.message}\n\nCheck the server log.`); | |
| // Try to revert dropdown | |
| try { | |
| const hp = await api("/health"); | |
| ev.target.value = hp.model; | |
| } catch {} | |
| } finally { | |
| hideLoading(); | |
| } | |
| }); | |
| // --- Encode action --- | |
| document.getElementById("btn-encode").addEventListener("click", async () => { | |
| const prompt = document.getElementById("prompt").value; | |
| if (!prompt.trim()) return; | |
| setStatus("encoding…", true); | |
| try { | |
| const t0 = performance.now(); | |
| const top_n = Math.max(1, parseInt(document.getElementById("top-k").value || "20")); | |
| const r = await api("/encode", {prompt, top_n}); | |
| const dt = ((performance.now() - t0)/1000).toFixed(2); | |
| state.topFeatures = r.top; | |
| document.getElementById("feat-count").textContent = `(K=${r.top.length} of ${r.n_features.toLocaleString()})`; | |
| renderFeatures(); | |
| repaintCloud(); | |
| document.getElementById("btn-generate").disabled = false; | |
| setStatus(`encoded in ${dt}s`); | |
| // Auto-render the per-token heatmap too | |
| renderHeatmap(prompt).catch(e => console.error("heatmap:", e)); | |
| } catch (e) { | |
| setStatus(`encode error: ${e.message}`); | |
| } | |
| }); | |
| document.getElementById("heatmap-skip-first").addEventListener("change", () => { | |
| if (state._lastHeatmapPrompt) renderHeatmap(state._lastHeatmapPrompt); | |
| }); | |
| // ===================================================================== | |
| // PILLAR 2 — EVALUATION | |
| // ===================================================================== | |
| function parsePrompts(textareaId) { | |
| return document.getElementById(textareaId).value | |
| .split("\n").map(s => s.trim()).filter(s => s.length > 0); | |
| } | |
| document.getElementById("btn-eval-encode").addEventListener("click", async () => { | |
| const prompts = parsePrompts("eval-prompts"); | |
| if (prompts.length === 0) { setStatus("paste prompts first"); return; } | |
| setStatus(`encoding ${prompts.length} prompts…`, true); | |
| document.getElementById("eval-features-list").innerHTML = `<span class="loader"></span>`; | |
| document.getElementById("eval-samples").innerHTML = `<span class="loader"></span>`; | |
| document.getElementById("eval-heatmap").innerHTML = `<span class="loader"></span>`; | |
| try { | |
| const t0 = performance.now(); | |
| const r = await api("/encode_batch", {prompts, top_n: 10}); | |
| const dt = ((performance.now()-t0)/1000).toFixed(2); | |
| state.evalResult = r; | |
| document.getElementById("eval-stats").textContent = | |
| `(${r.n_samples} samples · ${r.corpus_features.length} features fired)`; | |
| renderEvalCorpus(r); | |
| renderEvalSamples(r); | |
| renderEvalHeatmap(r); | |
| setStatus(`corpus encoded in ${dt}s`); | |
| } catch (e) { | |
| setStatus(`eval encode error: ${e.message}`); | |
| } | |
| }); | |
| function renderEvalCorpus(r) { | |
| const div = document.getElementById("eval-features-list"); | |
| if (!r.corpus_features.length) { | |
| div.innerHTML = `<div class="empty">No features fired.</div>`; | |
| return; | |
| } | |
| // Top firing features ranked by fire_rate, with bar | |
| const max = r.corpus_features[0].fire_rate; | |
| div.innerHTML = r.corpus_features.slice(0, 60).map(f => { | |
| const w = Math.max(2, 100 * f.fire_rate / max); | |
| return `<div style="padding:5px 8px; border-bottom:1px solid var(--border); font-family:var(--mono); font-size:11px;"> | |
| <div style="display:flex; justify-content:space-between; gap:10px; align-items:center;"> | |
| <span style="color:var(--accent);">feat ${f.id}</span> | |
| <span style="color:var(--fg-faint);">${(f.fire_rate*100).toFixed(0)}% · μ=${f.mean_act.toFixed(2)}</span> | |
| </div> | |
| <div style="height:3px; background:rgba(125,249,255,0.55); width:${w}%; margin-top:4px; border-radius:2px;"></div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| function renderEvalSamples(r) { | |
| const div = document.getElementById("eval-samples"); | |
| if (!r.per_sample.length) { div.innerHTML = `<span class="empty">no samples</span>`; return; } | |
| div.innerHTML = r.per_sample.map(s => { | |
| const top = s.top.slice(0,5).map(t => | |
| `<span style="display:inline-block; padding:1px 6px; margin:1px; border:1px solid var(--border-strong); border-radius:4px; font-family:var(--mono); font-size:10px; color:var(--accent);">${t.id}<span style="color:var(--fg-faint);">·${t.act.toFixed(1)}</span></span>` | |
| ).join(""); | |
| return `<div style="padding:8px; border-bottom:1px solid var(--border);"> | |
| <div style="font-family:var(--mono); font-size:11px; color:var(--fg); margin-bottom:4px;">[${s.i}] ${escapeHtml(s.preview)}</div> | |
| <div>${top}</div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| function renderEvalHeatmap(r) { | |
| const div = document.getElementById("eval-heatmap"); | |
| // Pick top 20 features that fire most across samples | |
| const topFeats = r.corpus_features.slice(0, 20); | |
| if (!topFeats.length || !r.per_sample.length) { div.innerHTML = `<span class="empty">no data</span>`; return; } | |
| // Build sample-id × feature_id activation matrix | |
| const sampleActs = r.per_sample.map(s => { | |
| const m = {}; | |
| for (const t of s.top) m[t.id] = t.act; | |
| return m; | |
| }); | |
| const max = Math.max(1e-6, ...sampleActs.flatMap(m => Object.values(m))); | |
| // Render: rows = samples, cols = features | |
| const header = `<th style="padding:3px 6px; font-size:10px; color:var(--fg-dim); background:rgba(0,0,0,0.4); position:sticky; left:0; z-index:2;">sample</th>` + | |
| topFeats.map(f => `<th title="feat ${f.id} (${(f.fire_rate*100).toFixed(0)}% rate)" style="padding:3px 4px; font-size:9px; color:var(--accent); border-bottom:1px solid var(--border-strong);">${f.id}</th>`).join(""); | |
| const rows = r.per_sample.map((s, si) => { | |
| const cells = topFeats.map(f => { | |
| const v = sampleActs[si][f.id] || 0; | |
| const t = Math.min(1, v/max); | |
| const r = Math.round(255 - 200*t), g = 255, b = Math.round(255 - 50*t); | |
| return `<td title="sample ${si} · feat ${f.id} · ${v.toFixed(2)}" style="background:rgb(${r},${g},${b}); width:22px; height:18px; border:1px solid rgba(0,0,0,0.3);"></td>`; | |
| }).join(""); | |
| return `<tr><td style="padding:2px 6px; font-family:var(--mono); font-size:10px; color:var(--fg-dim); background:rgba(0,0,0,0.3); position:sticky; left:0; z-index:1; max-width:80px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${si}: ${escapeHtml(s.preview.slice(0,16))}</td>${cells}</tr>`; | |
| }).join(""); | |
| div.innerHTML = `<table style="border-collapse:collapse; font-family:var(--mono);"><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`; | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); | |
| } | |
| // View toggle in Evaluation bottom-right panel: heatmap vs compare | |
| document.getElementById("btn-eval-view-heatmap").addEventListener("click", () => { | |
| document.getElementById("eval-heatmap").style.display = "block"; | |
| document.getElementById("eval-compare-results").style.display = "none"; | |
| }); | |
| document.getElementById("btn-eval-view-compare").addEventListener("click", () => { | |
| document.getElementById("eval-heatmap").style.display = "none"; | |
| document.getElementById("eval-compare-results").style.display = "block"; | |
| }); | |
| // Differential feature mining | |
| document.getElementById("btn-cmp").addEventListener("click", async () => { | |
| const a = parsePrompts("cmp-a"); | |
| const b = parsePrompts("cmp-b"); | |
| if (a.length === 0 || b.length === 0) { | |
| setStatus("paste both sets first"); return; | |
| } | |
| setStatus(`comparing ${a.length} vs ${b.length} prompts…`, true); | |
| // Auto-switch the bottom-right panel to compare view | |
| document.getElementById("eval-heatmap").style.display = "none"; | |
| document.getElementById("eval-compare-results").style.display = "block"; | |
| document.getElementById("eval-compare-results").innerHTML = `<span class="loader"></span> encoding…`; | |
| try { | |
| const t0 = performance.now(); | |
| const r = await api("/compare_batch", {prompts_a: a, prompts_b: b, top_n: 30}); | |
| const dt = ((performance.now()-t0)/1000).toFixed(2); | |
| renderCompareResults(r, a, b); | |
| setStatus(`compared in ${dt}s`); | |
| } catch (e) { | |
| setStatus(`compare error: ${e.message}`); | |
| document.getElementById("eval-compare-results").innerHTML = `<span class="empty">error: ${e.message}</span>`; | |
| } | |
| }); | |
| function renderCompareResults(r, setA, setB) { | |
| const div = document.getElementById("eval-compare-results"); | |
| if (!r.top_diff || !r.top_diff.length) { | |
| div.innerHTML = `<span class="empty">no distinguishing features found</span>`; | |
| return; | |
| } | |
| const max = r.top_diff[0].diff || 1; | |
| const header = ` | |
| <div style="font-size:10px; color:var(--fg-faint); padding:4px 0; display:flex; gap:14px; flex-wrap:wrap; border-bottom:1px solid var(--border-strong); margin-bottom:6px;"> | |
| <span><span style="color:#7df9ff; font-weight:700;">■ A</span> ${r.n_a} prompts</span> | |
| <span><span style="color:#ff7df9; font-weight:700;">■ B</span> ${r.n_b} prompts</span> | |
| <span>top ${r.top_diff.length} by |Δ rate|</span> | |
| </div>`; | |
| const rows = r.top_diff.map((f, i) => { | |
| const wA = Math.max(2, 60 * f.rate_a); | |
| const wB = Math.max(2, 60 * f.rate_b); | |
| const winner = f.winner === "a" ? "A" : "B"; | |
| const winnerColor = f.winner === "a" ? "#7df9ff" : "#ff7df9"; | |
| return `<tr style="border-bottom:1px solid var(--border);"> | |
| <td style="padding:3px 6px; color:var(--fg-faint); font-size:10px;">${i+1}</td> | |
| <td style="padding:3px 6px; color:var(--accent); font-family:var(--mono);">${f.id}</td> | |
| <td style="padding:3px 6px; text-align:right; font-family:var(--mono);">${(f.rate_a*100).toFixed(0)}%</td> | |
| <td style="padding:3px 6px;"> | |
| <div style="display:flex; gap:2px; align-items:center;"> | |
| <div style="width:${wA}px; height:6px; background:#7df9ff;"></div> | |
| <div style="width:${wB}px; height:6px; background:#ff7df9;"></div> | |
| </div> | |
| </td> | |
| <td style="padding:3px 6px; text-align:right; font-family:var(--mono);">${(f.rate_b*100).toFixed(0)}%</td> | |
| <td style="padding:3px 6px; font-family:var(--mono); font-size:10px; color:${winnerColor};">${winner} ▲</td> | |
| <td style="padding:3px 6px; text-align:right; font-family:var(--mono); color:var(--fg);">${(f.diff*100).toFixed(0)}%</td> | |
| </tr>`; | |
| }).join(""); | |
| div.innerHTML = ` | |
| <div> | |
| ${header} | |
| <div style="overflow:auto; max-height:32vh;"> | |
| <table style="border-collapse:collapse; width:100%; font-size:11px;"> | |
| <thead> | |
| <tr style="background:rgba(0,0,0,0.4); position:sticky; top:0; z-index:1;"> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:left;">#</th> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:left;">feat</th> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:right;">rate A</th> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:left;">A vs B</th> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:right;">rate B</th> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:left;">winner</th> | |
| <th style="padding:4px 6px; font-size:10px; color:var(--fg-dim); text-align:right;">|Δ|</th> | |
| </tr> | |
| </thead> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| </div> | |
| </div>`; | |
| } | |
| // ===================================================================== | |
| // PILLAR 3 — DATA-CENTRIC | |
| // ===================================================================== | |
| document.getElementById("btn-dc-encode").addEventListener("click", async () => { | |
| const prompts = parsePrompts("dc-prompts"); | |
| if (prompts.length === 0) { setStatus("paste prompts first"); return; } | |
| setStatus(`encoding ${prompts.length} prompts…`, true); | |
| try { | |
| const r = await api("/encode_batch", {prompts, top_n: 50}); | |
| state.dcResult = r; | |
| state.dcAllPrompts = prompts; | |
| state.dcFiltered = r.per_sample; | |
| renderDcFiltered(state.dcFiltered, prompts, null); | |
| setStatus(`encoded ${r.n_samples} prompts`); | |
| } catch (e) { | |
| setStatus(`dc encode error: ${e.message}`); | |
| } | |
| }); | |
| document.getElementById("btn-dc-filter").addEventListener("click", () => { | |
| if (!state.dcResult) { setStatus("encode first"); return; } | |
| const fid = parseInt(document.getElementById("dc-filter-id").value); | |
| const mode = document.getElementById("dc-filter-mode").value; | |
| if (isNaN(fid)) { setStatus("enter a feature id"); return; } | |
| const fired = new Set(); | |
| for (const s of state.dcResult.per_sample) { | |
| if (s.top.some(t => t.id === fid)) fired.add(s.i); | |
| } | |
| const filtered = state.dcResult.per_sample.filter(s => | |
| mode === "include" ? fired.has(s.i) : !fired.has(s.i) | |
| ); | |
| state.dcFiltered = filtered; | |
| renderDcFiltered(filtered, state.dcAllPrompts, {id: fid, mode}); | |
| }); | |
| document.getElementById("btn-dc-clear").addEventListener("click", () => { | |
| if (!state.dcResult) return; | |
| state.dcFiltered = state.dcResult.per_sample; | |
| renderDcFiltered(state.dcFiltered, state.dcAllPrompts, null); | |
| }); | |
| function renderDcFiltered(samples, prompts, filt) { | |
| const div = document.getElementById("dc-filtered-list"); | |
| const stats = document.getElementById("dc-filter-stats"); | |
| if (filt) { | |
| stats.textContent = `(filter: ${filt.mode === "include" ? "+" : "−"}feat ${filt.id} · ${samples.length} of ${state.dcResult.n_samples})`; | |
| } else { | |
| stats.textContent = `(${samples.length} docs)`; | |
| } | |
| if (!samples.length) { div.innerHTML = `<span class="empty">no docs match.</span>`; return; } | |
| div.innerHTML = samples.map(s => { | |
| const fullPrompt = prompts[s.i] || s.preview; | |
| const top = s.top.slice(0,3).map(t => | |
| `<span style="display:inline-block; padding:1px 5px; margin:1px; border:1px solid var(--border-strong); border-radius:3px; font-family:var(--mono); font-size:10px; color:var(--accent);">${t.id}</span>` | |
| ).join(""); | |
| return `<div style="padding:6px 8px; border-bottom:1px solid var(--border); font-family:var(--mono); font-size:11px;"> | |
| <div style="color:var(--fg-faint); font-size:9px;">[${s.i}]</div> | |
| <div style="color:var(--fg);">${escapeHtml(fullPrompt)}</div> | |
| <div style="margin-top:3px;">${top}</div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| document.getElementById("btn-dc-synth").addEventListener("click", async () => { | |
| const prompts = state.dcAllPrompts || parsePrompts("dc-prompts"); | |
| if (!prompts.length) { setStatus("paste seeds first"); return; } | |
| const fid = parseInt(document.getElementById("dc-synth-id").value); | |
| const alpha = parseFloat(document.getElementById("dc-synth-alpha").value); | |
| const max_new_tokens = parseInt(document.getElementById("dc-synth-tokens").value); | |
| if (isNaN(fid)) { setStatus("enter feature id"); return; } | |
| setStatus(`synthesizing ${prompts.length} steered completions…`, true); | |
| document.getElementById("dc-synth-list").innerHTML = `<span class="loader"></span> running…`; | |
| try { | |
| const r = await api("/synth_batch", { | |
| seed_prompts: prompts, | |
| steering: [{id: fid, alpha}], | |
| max_new_tokens, | |
| }); | |
| document.getElementById("dc-synth-stats").textContent = | |
| `(${r.results.length} · feat ${fid} α=${alpha})`; | |
| document.getElementById("dc-synth-list").innerHTML = r.results.map((res, i) => | |
| `<div style="padding:8px; border-bottom:1px solid var(--border); font-family:var(--mono); font-size:11px;"> | |
| <div style="color:var(--fg-faint); font-size:9px;">[${i}] seed</div> | |
| <div style="color:var(--fg-dim);">${escapeHtml(res.seed)}</div> | |
| <div style="color:var(--fg-faint); font-size:9px; margin-top:4px;">→ steered</div> | |
| <div style="color:var(--fg);">${escapeHtml(res.text)}</div> | |
| </div>` | |
| ).join(""); | |
| setStatus("synthesis done"); | |
| } catch (e) { | |
| setStatus(`synth error: ${e.message}`); | |
| document.getElementById("dc-synth-list").innerHTML = `<span class="empty">error: ${e.message}</span>`; | |
| } | |
| }); | |
| async function renderHeatmap(prompt) { | |
| const container = document.getElementById("heatmap-grid"); | |
| state._lastHeatmapPrompt = prompt; | |
| container.innerHTML = `<span class="loader"></span> computing…`; | |
| try { | |
| const r = await api("/encode_full", {prompt, top_n: 16}); | |
| let tokens = r.tokens, grid = r.grid, ids = r.feature_ids; | |
| const skipFirst = document.getElementById("heatmap-skip-first").checked; | |
| if (skipFirst && tokens.length > 1) { | |
| tokens = tokens.slice(1); | |
| grid = grid.map(row => row.slice(1)); | |
| } | |
| if (tokens.length === 0) { | |
| container.innerHTML = `<span class="empty">No tokens (after skip).</span>`; | |
| return; | |
| } | |
| // Build HTML table | |
| const rowMax = grid.map(row => Math.max(...row.map(Math.abs), 1e-6)); | |
| const tokHeader = tokens.map((t,i) => { | |
| const safe = (t.replace(/\n/g,"↵").replace(/</g,"<").replace(/>/g,">")).slice(0,8); | |
| return `<th title="pos ${i}: ${t.replace(/\n/g,'\\n').replace(/"/g,'"')}" style="padding:3px 4px; font-size:10px; color:var(--fg-dim); border-bottom:1px solid var(--border-strong); white-space:nowrap;">${safe || `[${i}]`}</th>`; | |
| }).join(""); | |
| const rows = grid.map((row, fi) => { | |
| const m = rowMax[fi]; | |
| const cells = row.map((v, pi) => { | |
| const t = Math.min(1, Math.abs(v) / m); | |
| const r = 255, g = Math.round(255 - 200*t), b = Math.round(255 - 220*t); | |
| return `<td title="feat ${ids[fi]} · pos ${pi} · act=${v.toFixed(3)}" style="background:rgb(${r},${g},${b}); width:30px; height:22px; border:1px solid rgba(0,0,0,0.3);"></td>`; | |
| }).join(""); | |
| return `<tr><td style="font-family:var(--mono); font-size:10px; padding:3px 6px; color:var(--accent); white-space:nowrap; background:rgba(0,0,0,0.3); position:sticky; left:0; z-index:1;">#${ids[fi]}</td>${cells}</tr>`; | |
| }).join(""); | |
| container.innerHTML = `<table style="border-collapse:collapse; font-family:var(--mono);"><thead><tr><th style="padding:3px 6px; font-size:10px; color:var(--fg-dim); position:sticky; left:0; z-index:2; background:rgba(0,0,0,0.4);">feat</th>${tokHeader}</tr></thead><tbody>${rows}</tbody></table>`; | |
| } catch (e) { | |
| container.innerHTML = `<span class="empty">heatmap error: ${e.message}</span>`; | |
| } | |
| } | |
| // --- Feature card render --- | |
| function renderFeatures() { | |
| const div = document.getElementById("features"); | |
| if (state.topFeatures.length === 0) { | |
| div.innerHTML = `<div class="empty">Encode a prompt to populate features.</div>`; | |
| return; | |
| } | |
| div.innerHTML = ""; | |
| for (const f of state.topFeatures) { | |
| div.appendChild(buildFeatureCard(f.id, f.act, /*topRanked=*/true)); | |
| } | |
| // Also render any steered features that weren't in top-K | |
| for (const [id, alpha] of state.steering) { | |
| if (!state.topFeatures.find(t => t.id === id)) { | |
| div.appendChild(buildFeatureCard(id, null, /*topRanked=*/false)); | |
| } | |
| } | |
| } | |
| function buildFeatureCard(id, act, topRanked) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "feat" + (state.steering.has(id) ? " steered" : ""); | |
| const alpha = state.steering.has(id) ? state.steering.get(id) : 0; | |
| const head = document.createElement("div"); | |
| head.className = "feat-head"; | |
| const left = document.createElement("div"); | |
| left.innerHTML = `<span class="feat-id">feat ${id}</span>` + | |
| (act != null ? ` <span class="feat-act">act ${act.toFixed(3)}</span>` : ` <span class="feat-act" style="color:var(--fg-faint)">(picked)</span>`); | |
| const tools = document.createElement("div"); | |
| tools.className = "feat-tools"; | |
| if (state.steering.has(id)) { | |
| const reset = document.createElement("button"); | |
| reset.textContent = "reset"; | |
| reset.title = "Set α back to 0 (no intervention) but keep slider visible"; | |
| reset.addEventListener("click", () => { | |
| state.steering.set(id, 0); | |
| renderFeatures(); | |
| repaintCloud(); | |
| }); | |
| tools.appendChild(reset); | |
| const off = document.createElement("button"); | |
| off.textContent = "remove"; | |
| off.title = "Remove this slider entirely"; | |
| off.addEventListener("click", () => { | |
| state.steering.delete(id); | |
| renderFeatures(); | |
| repaintCloud(); | |
| }); | |
| tools.appendChild(off); | |
| } else { | |
| const add = document.createElement("button"); | |
| add.textContent = "steer"; | |
| add.addEventListener("click", () => addSteerSlot(id, 0)); | |
| tools.appendChild(add); | |
| } | |
| head.appendChild(left); | |
| head.appendChild(tools); | |
| wrap.appendChild(head); | |
| if (state.steering.has(id)) { | |
| const cur = state.steering.get(id); | |
| const curAlpha = (typeof cur === "object") ? cur.alpha : cur; | |
| const curPositions = (typeof cur === "object") ? (cur.positions || "") : ""; | |
| const curOutOnly = (typeof cur === "object") ? !!cur.output_only : false; | |
| const sl = document.createElement("div"); | |
| sl.className = "slider-row"; | |
| const range = document.createElement("input"); | |
| range.type = "range"; range.min = -100; range.max = 100; range.step = 1; | |
| range.value = curAlpha; | |
| const val = document.createElement("span"); | |
| val.className = "alpha-val"; | |
| val.textContent = (curAlpha >= 0 ? "+" : "") + curAlpha.toFixed(0); | |
| range.addEventListener("input", () => { | |
| const a = parseFloat(range.value); | |
| const slot = state.steering.get(id); | |
| const next = (typeof slot === "object") ? {...slot, alpha:a} : {alpha:a, positions:"", output_only:false}; | |
| state.steering.set(id, next); | |
| val.textContent = (a >= 0 ? "+" : "") + a.toFixed(0); | |
| repaintCloud(); | |
| }); | |
| sl.appendChild(range); | |
| sl.appendChild(val); | |
| wrap.appendChild(sl); | |
| // Position-selective steering + output-only toggle | |
| const adv = document.createElement("div"); | |
| adv.style.cssText = "margin-top:6px; display:flex; gap:6px; align-items:center; flex-wrap:wrap;"; | |
| const posLbl = document.createElement("span"); | |
| posLbl.textContent = "positions"; | |
| posLbl.style.cssText = "font-size:10px; color:var(--fg-faint); flex:0 0 auto;"; | |
| const posInput = document.createElement("input"); | |
| posInput.type = "text"; | |
| posInput.placeholder = "all"; | |
| posInput.value = curPositions; | |
| posInput.style.cssText = "flex:1; min-width:60px; font-size:11px; padding:3px 6px; background:rgba(0,0,0,0.3); border:1px solid var(--border-strong); color:var(--fg); border-radius:4px; font-family:var(--mono);"; | |
| posInput.title = "Token positions to steer at: 'all' or '3-7' or '0,2,5-8'. Empty = all."; | |
| posInput.addEventListener("change", () => { | |
| const slot = state.steering.get(id); | |
| const next = (typeof slot === "object") ? {...slot, positions: posInput.value} : {alpha: slot, positions: posInput.value, output_only: false}; | |
| state.steering.set(id, next); | |
| }); | |
| const outLbl = document.createElement("label"); | |
| outLbl.style.cssText = "font-size:10px; color:var(--fg-faint); display:inline-flex; align-items:center; gap:3px; cursor:pointer; flex:0 0 auto;"; | |
| const outChk = document.createElement("input"); | |
| outChk.type = "checkbox"; | |
| outChk.checked = curOutOnly; | |
| outChk.style.cssText = "margin:0;"; | |
| outChk.addEventListener("change", () => { | |
| const slot = state.steering.get(id); | |
| const next = (typeof slot === "object") ? {...slot, output_only: outChk.checked} : {alpha: slot, positions: "", output_only: outChk.checked}; | |
| state.steering.set(id, next); | |
| }); | |
| outLbl.appendChild(outChk); | |
| outLbl.appendChild(document.createTextNode("output only")); | |
| adv.appendChild(posLbl); | |
| adv.appendChild(posInput); | |
| adv.appendChild(outLbl); | |
| wrap.appendChild(adv); | |
| const lbl = document.createElement("div"); | |
| lbl.style.cssText = "font-size:10px; color:var(--fg-faint); margin-top:4px;"; | |
| lbl.textContent = "α: −100 ← suppress … +100 → amplify"; | |
| wrap.appendChild(lbl); | |
| } | |
| return wrap; | |
| } | |
| function addSteerSlot(id, alpha=0) { | |
| state.steering.set(id, alpha); | |
| renderFeatures(); | |
| repaintCloud(); | |
| } | |
| // --- Generate action --- | |
| document.getElementById("btn-generate").addEventListener("click", async () => { | |
| const prompt = document.getElementById("prompt").value; | |
| const max_new_tokens = parseInt(document.getElementById("max-tokens").value || "40"); | |
| const steering = []; | |
| for (const [id, slot] of state.steering) { | |
| const alpha = (typeof slot === "object") ? slot.alpha : slot; | |
| if (alpha === 0) continue; | |
| const positions = (typeof slot === "object") ? (slot.positions || null) : null; | |
| const output_only = (typeof slot === "object") ? !!slot.output_only : false; | |
| steering.push({id, alpha, positions, output_only}); | |
| } | |
| setStatus("generating baseline…", true); | |
| document.getElementById("out-baseline").innerHTML = `<span class="loader"></span>`; | |
| document.getElementById("out-steered").innerHTML = `<span class="loader"></span>`; | |
| try { | |
| const t0 = performance.now(); | |
| const baseline = await api("/generate", {prompt, steering: [], max_new_tokens, return_probs: true, topk_display: 8}); | |
| const t1 = performance.now(); | |
| renderTokenChips("out-baseline", baseline, "blue"); | |
| document.getElementById("base-time").textContent = ((t1-t0)/1000).toFixed(2) + "s"; | |
| if (steering.length === 0) { | |
| document.getElementById("out-steered").innerHTML = `<span class="empty">(no sliders engaged — steered = baseline)</span>`; | |
| document.getElementById("steered-time").textContent = ""; | |
| document.getElementById("verifier").innerHTML = ""; | |
| setStatus("done"); | |
| return; | |
| } | |
| setStatus("generating steered…", true); | |
| const t2 = performance.now(); | |
| const steered = await api("/generate", {prompt, steering, max_new_tokens, return_probs: true, topk_display: 8}); | |
| const t3 = performance.now(); | |
| renderTokenChips("out-steered", steered, "magenta"); | |
| document.getElementById("steered-time").textContent = ((t3-t2)/1000).toFixed(2) + "s"; | |
| // Verifier | |
| const v = document.getElementById("verifier"); | |
| v.innerHTML = ""; | |
| for (const row of steered.verifier) { | |
| const d = row.steered - row.base; | |
| const cls = d > 0 ? "delta-up" : (d < 0 ? "delta-down" : ""); | |
| const sign = d >= 0 ? "+" : ""; | |
| const ext = []; | |
| if (row.positions) ext.push(`pos=${row.positions}`); | |
| if (row.output_only) ext.push("output-only"); | |
| const extStr = ext.length ? ` · ${ext.join(" · ")}` : ""; | |
| const div = document.createElement("div"); | |
| div.innerHTML = `feat ${row.id} · α=${row.alpha.toFixed(0)}${extStr} · base=${row.base.toFixed(2)} → steered=${row.steered.toFixed(2)} <span class="${cls}">(Δ ${sign}${d.toFixed(2)})</span>`; | |
| v.appendChild(div); | |
| } | |
| setStatus("done"); | |
| } catch (e) { | |
| setStatus(`generate error: ${e.message}`); | |
| document.getElementById("out-baseline").textContent = "(error)"; | |
| document.getElementById("out-steered").textContent = "(error)"; | |
| } | |
| }); | |
| // --- Per-token probability chip strip --- | |
| function renderTokenChips(containerId, gen, theme) { | |
| const container = document.getElementById(containerId); | |
| if (!gen.tokens || !gen.tokens.length) { | |
| container.textContent = gen.text || "(empty)"; | |
| return; | |
| } | |
| // Theme: colors for chip background | |
| const themeFns = { | |
| blue: (p) => { | |
| const t = Math.max(0, Math.min(1, p)); | |
| const r = Math.round(255 * (1 - t*0.85)); | |
| const g = Math.round(255 * (1 - t*0.55)); | |
| return [r, g, 255, t < 0.5 ? "#1e3a8a" : "#fff"]; | |
| }, | |
| magenta: (p) => { | |
| const t = Math.max(0, Math.min(1, p)); | |
| const r = 255; | |
| const g = Math.round(255 * (1 - t*0.7)); | |
| const b = Math.round(255 * (1 - t*0.5)); | |
| return [r, g, b, t < 0.5 ? "#7f1d1d" : "#fff"]; | |
| }, | |
| }; | |
| const colorize = themeFns[theme] || themeFns.blue; | |
| const chips = gen.tokens.map((row, i) => { | |
| const [r,g,b,fg] = colorize(row.prob); | |
| const tokDisp = row.tok.replace(/\n/g,"↵").replace(/\t/g,"→"); | |
| const safe = escapeHtml(tokDisp); | |
| const panelHtml = topkPanelHtml(row.topk); | |
| return `<span class="tok-chip" data-panel='${escapeAttr(panelHtml)}' style="background:rgb(${r},${g},${b}); color:${fg}; padding:2px 6px; margin:1px; border-radius:4px; cursor:pointer; display:inline-block; font-family:var(--mono); font-size:11px; white-space:nowrap;">${safe}<sub style="opacity:.65; font-size:8px; margin-left:3px;">${(row.prob*100).toFixed(1)}%</sub></span>`; | |
| }).join(""); | |
| container.innerHTML = ` | |
| <div class="tok-strip" data-prob-root style="line-height:2.4;"> | |
| <div style="font-size:10px; color:var(--fg-faint); margin-bottom:4px; font-style:italic;">click any token to see top-K candidates</div> | |
| <div>${chips}</div> | |
| <div data-topk-panel style="display:none; margin-top:6px; padding:6px; background:rgba(0,0,0,0.35); border:1px solid var(--border-strong); border-radius:6px; font-family:var(--mono); font-size:10px;"></div> | |
| </div>`; | |
| // Wire click-to-pin | |
| container.querySelectorAll(".tok-chip").forEach(chip => { | |
| chip.addEventListener("click", () => { | |
| const root = chip.closest("[data-prob-root]"); | |
| const panel = root.querySelector("[data-topk-panel]"); | |
| const wasSelected = chip.dataset.selected === "1"; | |
| root.querySelectorAll(".tok-chip").forEach(c => { | |
| c.dataset.selected = "0"; c.style.outline = ""; | |
| }); | |
| if (wasSelected) { | |
| panel.innerHTML = ""; panel.style.display = "none"; | |
| } else { | |
| chip.dataset.selected = "1"; | |
| chip.style.outline = "2px solid #94a3b8"; | |
| chip.style.outlineOffset = "-1px"; | |
| panel.innerHTML = chip.dataset.panel; | |
| panel.style.display = "block"; | |
| } | |
| }); | |
| }); | |
| } | |
| function topkPanelHtml(topk) { | |
| const rows = topk.map((c, idx) => { | |
| const safe = escapeHtml(c.tok.replace(/\n/g,"↵").replace(/\t/g,"→")); | |
| const bg = c.is_chosen ? "background:rgba(125,249,255,0.12);" : ""; | |
| const fw = c.is_chosen ? "font-weight:700;" : ""; | |
| const mark = c.is_chosen ? " ✓" : ""; | |
| return `<tr style="${bg}"> | |
| <td style="padding:2px 6px; color:var(--fg-faint); text-align:right;">${idx+1}</td> | |
| <td style="padding:2px 6px; color:var(--fg); ${fw}">${safe}${mark}</td> | |
| <td style="padding:2px 6px; text-align:right; color:var(--accent);">${(c.prob*100).toFixed(2)}%</td> | |
| </tr>`; | |
| }).join(""); | |
| return `<table style="border-collapse:collapse; width:100%;"><tbody>${rows}</tbody></table>`; | |
| } | |
| function escapeAttr(s) { return s.replace(/'/g, "'").replace(/"/g, """); } | |
| </script> | |
| </body> | |
| </html> | |