Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>ICA Explorer</title> | |
| <style> | |
| :root { | |
| --bg: #f6f7f9; | |
| --panel: #fff; | |
| --text: #151922; | |
| --muted: #647084; | |
| --border: #cbd3df; | |
| --accent: #1f6feb; | |
| --hot: #b42318; | |
| --cold: #1e5bb8; | |
| --good: #087443; | |
| --warn: #a15c00; | |
| --shadow: 0 1px 2px rgb(20 25 34 / .08), 0 10px 30px rgb(20 25 34 / .06); | |
| --token-card-width: 140px; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--text); | |
| font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| } | |
| header { | |
| position: sticky; | |
| top: 0; | |
| z-index: 5; | |
| background: var(--panel); | |
| border-bottom: 1px solid var(--border); | |
| box-shadow: var(--shadow); | |
| padding: 12px 18px; | |
| } | |
| .top { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 14px; | |
| } | |
| .nav { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| } | |
| .nav a { | |
| color: var(--accent); | |
| font-weight: 750; | |
| text-decoration: none; | |
| } | |
| .nav a:hover { text-decoration: underline; } | |
| h1 { margin: 0; font-size: 19px; letter-spacing: 0; } | |
| main { | |
| max-width: 1240px; | |
| margin: 0 auto; | |
| padding: 18px; | |
| } | |
| footer { | |
| max-width: 1240px; | |
| margin: 0 auto; | |
| padding: 0 18px 22px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| footer a { | |
| color: var(--accent); | |
| font-weight: 750; | |
| text-decoration: none; | |
| } | |
| footer a:hover { text-decoration: underline; } | |
| .controls { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 450px; | |
| gap: 12px; | |
| align-items: stretch; | |
| } | |
| .control-stack { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-content: flex-start; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .inline-control { | |
| display: grid; | |
| grid-template-columns: max-content max-content; | |
| align-items: center; | |
| column-gap: 8px; | |
| } | |
| .inline-control > span { white-space: nowrap; } | |
| .model-control { flex: 0 0 auto; } | |
| .layer-control { flex: 0 0 auto; } | |
| .control-break { flex-basis: 100%; height: 0; } | |
| .topk-control, | |
| .card-width-control, | |
| .opacity-control { flex: 0 0 auto; } | |
| .run-button { | |
| width: auto; | |
| min-height: 32px; | |
| padding: 6px 12px; | |
| border-color: #1458c8; | |
| background: var(--accent); | |
| color: #fff; | |
| font-weight: 850; | |
| } | |
| .run-button:hover { background: #1458c8; color: #fff; } | |
| .share-button { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 32px; | |
| min-height: 32px; | |
| padding: 0; | |
| color: #435066; | |
| } | |
| .share-button svg { width: 16px; height: 16px; stroke: currentColor; } | |
| .share-button.copied { border-color: var(--good); color: var(--good); background: #ecfdf3; } | |
| .memory-control { | |
| position: fixed; | |
| right: 12px; | |
| bottom: 10px; | |
| z-index: 4; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| width: auto; | |
| min-height: 24px; | |
| padding: 3px 6px; | |
| border: 1px solid #d7dee9; | |
| border-radius: 6px; | |
| background: rgb(255 255 255 / .78); | |
| color: #94a3b8; | |
| font-size: 11px; | |
| font-weight: 650; | |
| white-space: nowrap; | |
| } | |
| .memory-control input { width: auto; margin: 0; padding: 0; accent-color: #94a3b8; } | |
| .memory-control:hover { color: #64748b; border-color: #cbd5e1; background: #fff; } | |
| #topK { width: 48px; min-width: 0; } | |
| #cardWidth { width: 78px; min-width: 0; } | |
| #weakRatio { width: 58px; min-width: 0; } | |
| .model-control select, | |
| .layer-control select { width: 100%; } | |
| label { | |
| display: grid; | |
| gap: 5px; | |
| color: #435066; | |
| font-size: 12px; | |
| font-weight: 700; | |
| } | |
| textarea, select, input, button { | |
| width: 100%; | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| background: #fff; | |
| color: var(--text); | |
| font: inherit; | |
| padding: 8px 10px; | |
| } | |
| textarea { | |
| height: 92px; | |
| min-height: 92px; | |
| resize: vertical; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| } | |
| button { | |
| cursor: pointer; | |
| min-height: 39px; | |
| background: #eef2f7; | |
| font-weight: 750; | |
| } | |
| button:hover { border-color: var(--accent); color: var(--accent); background: #edf5ff; } | |
| button.primary { background: var(--accent); border-color: #1458c8; color: #fff; } | |
| button.primary:hover { background: #1458c8; color: #fff; } | |
| .panel { | |
| margin-top: 14px; | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| box-shadow: var(--shadow); | |
| padding: 12px; | |
| } | |
| .results { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(var(--token-card-width), 1fr)); | |
| gap: 10px; | |
| margin-top: 12px; | |
| } | |
| .token-card { | |
| position: relative; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 6px; | |
| background: #fff; | |
| min-width: 0; | |
| text-align: center; | |
| } | |
| .token-text { | |
| min-height: 25px; | |
| font-weight: 850; | |
| text-align: center; | |
| overflow-wrap: anywhere; | |
| margin-bottom: 6px; | |
| } | |
| .score-row { | |
| position: relative; | |
| min-height: 24px; | |
| margin-top: 3px; | |
| } | |
| .badge { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| width: 100%; | |
| min-height: 24px; | |
| padding: 3px 5px; | |
| border: 1px solid #d5dce7; | |
| border-radius: 5px; | |
| background: | |
| linear-gradient( | |
| 90deg, | |
| var(--score-bg, #edf2f7) 0, | |
| var(--score-bg, #edf2f7) var(--score-width, 100%), | |
| transparent var(--score-width, 100%), | |
| transparent 100% | |
| ); | |
| color: var(--text); | |
| font-size: 10px; | |
| font-weight: 400; | |
| text-align: left; | |
| cursor: pointer; | |
| } | |
| .badge:hover { | |
| border-color: var(--component-color, var(--accent)); | |
| color: var(--text); | |
| } | |
| .badge.hot { | |
| border-color: var(--component-color, var(--accent)); | |
| --score-bg: color-mix(in srgb, var(--component-color, var(--accent)) 16%, white); | |
| box-shadow: inset 3px 0 0 var(--component-color, var(--accent)); | |
| } | |
| .badge.hot:hover { | |
| color: var(--text); | |
| } | |
| .badge.weak { | |
| opacity: .1; | |
| } | |
| .badge.weak:hover, | |
| .badge.weak.hot { | |
| opacity: 1; | |
| } | |
| .badge-main { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 3px; | |
| min-width: 0; | |
| max-width: calc(100% - 28px); | |
| overflow: hidden; | |
| white-space: nowrap; | |
| text-overflow: ellipsis; | |
| } | |
| .badge-label { | |
| min-width: 0; | |
| overflow: hidden; | |
| white-space: nowrap; | |
| text-overflow: ellipsis; | |
| } | |
| .annotation-dot { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 13px; | |
| height: 13px; | |
| border-radius: 4px; | |
| flex: 0 0 auto; | |
| background: #e5e7eb; | |
| box-shadow: 0 0 0 1px rgb(255 255 255 / .85); | |
| color: #fff; | |
| font-size: 9px; | |
| font-weight: 850; | |
| line-height: 1; | |
| } | |
| .annotation-dot.high { background: #16a34a; box-shadow: 0 0 0 1px #15803d; } | |
| .annotation-dot.medium { color: #166534; background: #fef3c7; box-shadow: 0 0 0 1px #d9b94e; } | |
| .annotation-dot.low { color: #9f1239; background: #ffe4e6; box-shadow: 0 0 0 1px #f9a8d4; } | |
| .annotation-dot.unclear { color: #475569; background: #e5e7eb; box-shadow: 0 0 0 1px #cbd5e1; } | |
| .score { | |
| position: absolute; | |
| left: auto; | |
| right: 5px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 28px; | |
| text-align: right; | |
| color: var(--muted); | |
| font-size: 9px; | |
| font-variant-numeric: tabular-nums; | |
| pointer-events: none; | |
| } | |
| .prediction-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| min-height: 24px; | |
| margin-top: 5px; | |
| padding: 3px 5px; | |
| border: 1px solid #d8e0eb; | |
| border-radius: 5px; | |
| background: #f6f8fb; | |
| color: #314158; | |
| font-size: 10px; | |
| font-weight: 650; | |
| text-align: left; | |
| } | |
| .prediction-label { | |
| color: var(--muted); | |
| font-weight: 850; | |
| } | |
| .prediction-token { | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .empty, .error { | |
| margin-top: 12px; | |
| border: 1px dashed var(--border); | |
| border-radius: 8px; | |
| padding: 18px; | |
| color: var(--muted); | |
| background: #fff; | |
| } | |
| .error { color: #a41414; border-color: #f0b9b9; background: #fff7f7; } | |
| .request-indicator { | |
| position: fixed; | |
| left: 14px; | |
| top: 62px; | |
| z-index: 8; | |
| display: none; | |
| align-items: center; | |
| gap: 8px; | |
| min-height: 34px; | |
| padding: 8px 10px; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--panel); | |
| box-shadow: var(--shadow); | |
| color: #435066; | |
| font-size: 12px; | |
| font-weight: 800; | |
| } | |
| .request-indicator.visible { display: inline-flex; } | |
| .request-spinner { | |
| width: 14px; | |
| height: 14px; | |
| border: 2px solid #cbd5e1; | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: request-spin .8s linear infinite; | |
| } | |
| @keyframes request-spin { to { transform: rotate(360deg); } } | |
| .selection-panel { | |
| position: fixed; | |
| right: 14px; | |
| top: 82px; | |
| z-index: 6; | |
| display: none; | |
| width: 500px; | |
| max-width: calc(100vw - 28px); | |
| max-height: calc(100vh - 104px); | |
| overflow: auto; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--panel); | |
| box-shadow: var(--shadow); | |
| padding: 12px; | |
| } | |
| .selection-panel.visible { display: block; } | |
| .selection-title { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-weight: 850; | |
| } | |
| .selection-close { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 18px; | |
| height: 18px; | |
| min-height: 18px; | |
| padding: 0; | |
| border-radius: 5px; | |
| color: var(--muted); | |
| font-size: 13px; | |
| line-height: 1; | |
| } | |
| .selection-actions { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .selection-export-toggle { | |
| display: none; | |
| width: auto; | |
| min-height: 18px; | |
| padding: 2px 7px; | |
| border-radius: 5px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 800; | |
| line-height: 1.2; | |
| } | |
| .selection-export-toggle.visible { display: inline-flex; align-items: center; } | |
| .selection-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .selection-entry { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 145px; | |
| gap: 8px; | |
| align-items: stretch; | |
| } | |
| .selection-link { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| min-height: 30px; | |
| padding: 7px 10px 5px; | |
| color: var(--text); | |
| font-weight: 850; | |
| text-decoration: none; | |
| } | |
| .selection-link:hover { | |
| color: var(--accent); | |
| text-decoration: none; | |
| } | |
| .selection-item { | |
| border: 1px solid var(--component-color, var(--accent)); | |
| border-radius: 7px; | |
| background: color-mix(in srgb, var(--component-color, var(--accent)) 5%, white); | |
| overflow: hidden; | |
| box-shadow: inset 3px 0 0 var(--component-color, var(--accent)); | |
| } | |
| .selection-item .selection-link { | |
| border: 0; | |
| border-radius: 0; | |
| background: transparent; | |
| } | |
| .selection-score { | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 800; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .selection-token-stats { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| padding: 0 10px 10px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| line-height: 1.25; | |
| } | |
| .selection-neighbors { | |
| display: grid; | |
| grid-template-rows: repeat(2, minmax(58px, auto)); | |
| gap: 6px; | |
| } | |
| .neighbor-card { | |
| display: grid; | |
| gap: 3px; | |
| min-height: 58px; | |
| padding: 7px; | |
| border: 1px solid #d7dee9; | |
| border-radius: 6px; | |
| background: #fff; | |
| color: var(--text); | |
| text-decoration: none; | |
| } | |
| .neighbor-card:hover { | |
| border-color: var(--accent); | |
| color: var(--text); | |
| text-decoration: none; | |
| } | |
| .neighbor-card.missing { | |
| visibility: hidden; | |
| } | |
| .neighbor-card.loading { | |
| color: var(--muted); | |
| background: #f8fafc; | |
| cursor: default; | |
| } | |
| .neighbor-target { | |
| min-width: 0; | |
| overflow: hidden; | |
| white-space: nowrap; | |
| text-overflow: ellipsis; | |
| color: var(--muted); | |
| font-size: 10px; | |
| font-weight: 750; | |
| } | |
| .neighbor-label { | |
| min-width: 0; | |
| overflow: hidden; | |
| white-space: nowrap; | |
| text-overflow: ellipsis; | |
| color: var(--text); | |
| font-size: 13px; | |
| font-weight: 850; | |
| line-height: 1.2; | |
| } | |
| .neighbor-cos { | |
| position: relative; | |
| min-height: 18px; | |
| padding: 2px 5px; | |
| border: 1px solid #d7dee9; | |
| border-radius: 5px; | |
| background: | |
| linear-gradient( | |
| 90deg, | |
| color-mix(in srgb, var(--accent) 18%, white) 0, | |
| color-mix(in srgb, var(--accent) 18%, white) var(--cos-width, 0%), | |
| transparent var(--cos-width, 0%), | |
| transparent 100% | |
| ); | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 800; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .neighbor-metrics { | |
| color: var(--muted); | |
| font-size: 10px; | |
| font-weight: 500; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .selection-token-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| min-height: 20px; | |
| max-width: 100%; | |
| padding: 2px 6px; | |
| border: 1px solid #d7dee9; | |
| border-radius: 999px; | |
| background: #fff; | |
| color: #435066; | |
| font-size: var(--token-font-size, 11px); | |
| } | |
| .selection-token-text { | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .selection-export { | |
| position: fixed; | |
| left: 14px; | |
| bottom: 10px; | |
| z-index: 4; | |
| display: none; | |
| width: min(380px, calc(100vw - 180px)); | |
| height: 150px; | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| background: rgb(255 255 255 / .94); | |
| box-shadow: var(--shadow); | |
| color: #334155; | |
| font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| resize: vertical; | |
| } | |
| .selection-export.visible { display: block; } | |
| .selection-export-copy { | |
| position: fixed; | |
| left: 14px; | |
| bottom: 164px; | |
| z-index: 5; | |
| display: none; | |
| width: auto; | |
| min-height: 24px; | |
| padding: 3px 8px; | |
| border-radius: 6px; | |
| color: #64748b; | |
| background: rgb(255 255 255 / .92); | |
| font-size: 11px; | |
| font-weight: 800; | |
| } | |
| .selection-export-copy.visible { display: inline-flex; align-items: center; } | |
| @media (max-width: 760px) { | |
| main { padding: 12px; } | |
| footer { padding: 0 12px 18px; } | |
| .controls { grid-template-columns: 1fr; } | |
| .control-stack { align-content: flex-start; } | |
| .memory-control { right: 8px; bottom: 8px; } | |
| .top { align-items: flex-start; flex-direction: column; } | |
| .selection-panel { | |
| left: 8px; | |
| right: 8px; | |
| top: auto; | |
| bottom: 8px; | |
| width: auto; | |
| } | |
| .selection-entry { | |
| grid-template-columns: minmax(0, 1fr) 128px; | |
| } | |
| .selection-export { | |
| left: 8px; | |
| bottom: 40px; | |
| width: calc(100vw - 16px); | |
| height: 120px; | |
| } | |
| .selection-export-copy { | |
| left: 8px; | |
| bottom: 164px; | |
| } | |
| } | |
| </style> | |
| <script defer src="https://analytics.liusida.com/umami/script.js" data-website-id="64322a37-ae7f-4635-ac78-8869ef79997b"></script> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="top"> | |
| <h1>ICA Explorer</h1> | |
| <nav class="nav" aria-label="Primary"> | |
| <a href="/">Explorer</a> | |
| <a href="/sae-explorer">SAE Explorer</a> | |
| <a href="/stats">Stats</a> | |
| <a href="/annotate">Annotate</a> | |
| <a href="/random-components">Random</a> | |
| </nav> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="panel"> | |
| <div class="controls"> | |
| <label> | |
| Text | |
| <textarea id="probeText" spellcheck="false">Maya stopped at the bank before the trip, waiting in line to deposit a check and withdraw enough cash for the weekend.</textarea> | |
| </label> | |
| <div class="control-stack"> | |
| <label class="inline-control model-control"> | |
| <span>Model</span> | |
| <select id="modelSelect"></select> | |
| </label> | |
| <label class="inline-control layer-control"> | |
| <span>Layer</span> | |
| <select id="layerSelect"></select> | |
| </label> | |
| <span class="control-break" aria-hidden="true"></span> | |
| <label class="inline-control topk-control"> | |
| <span>Top K</span> | |
| <input id="topK" type="number" min="1" max="32" value="5" /> | |
| </label> | |
| <label class="inline-control card-width-control"> | |
| <span>Card Width</span> | |
| <input id="cardWidth" type="number" min="100" max="360" step="20" value="140" /> | |
| </label> | |
| <label class="inline-control opacity-control"> | |
| <span>Opacity Cutoff</span> | |
| <input id="weakRatio" type="number" min="0" max="1" step="0.05" value="0.5" /> | |
| </label> | |
| <button id="runProbe" class="run-button" type="button">Run</button> | |
| <button id="shareLink" class="share-button" type="button" title="Copy share link" aria-label="Copy share link"> | |
| <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"></path> | |
| <path d="M14 11a5 5 0 0 0-7.1 0l-2 2a5 5 0 0 0 7.1 7.1l1.1-1.1"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| <label class="memory-control" title="Keep loaded models in VRAM when switching."> | |
| <input id="keepModels" type="checkbox" checked /> | |
| <span>Cache LLMs in VRAM</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div id="message" class="empty">Choose a layer and run the probe.</div> | |
| <div id="results" class="results"></div> | |
| </main> | |
| <aside id="selectionPanel" class="selection-panel" aria-live="polite"> | |
| <div class="selection-title"> | |
| <span>Selected components</span> | |
| <div class="selection-actions"> | |
| <button id="selectionExportToggle" class="selection-export-toggle" type="button">Hide text</button> | |
| <button id="selectionClose" class="selection-close" type="button" title="Hide selected components" aria-label="Hide selected components">×</button> | |
| </div> | |
| </div> | |
| <div id="selectionList" class="selection-list"></div> | |
| </aside> | |
| <div id="requestIndicator" class="request-indicator" role="status" aria-live="polite"> | |
| <span class="request-spinner" aria-hidden="true"></span> | |
| <span>Waiting for server...</span> | |
| </div> | |
| <button id="selectionExportCopy" class="selection-export-copy" type="button">Copy</button> | |
| <textarea id="selectionExport" class="selection-export" readonly aria-label="Selected component summary"></textarea> | |
| <script> | |
| const STORAGE_KEYS = { | |
| probeText: "icaExplorer.probeText", | |
| model: "icaExplorer.model", | |
| layer: "icaExplorer.layer", | |
| topK: "icaExplorer.topK", | |
| cardWidth: "icaExplorer.cardWidth", | |
| weakRatio: "icaExplorer.opacityCutoff", | |
| keepModels: "icaExplorer.keepModels", | |
| highlights: "icaExplorer.selectedComponents", | |
| }; | |
| const SHARE_BASE_URL = "https://huggingface.co/spaces/EEEAILab/ICAExplorer"; | |
| const state = { | |
| meta: null, | |
| models: [], | |
| highlights: new Set(), | |
| currentScores: new Map(), | |
| tokenStats: new Map(), | |
| tokenStatsRetries: new Map(), | |
| componentNeighbors: new Map(), | |
| componentNeighborsRetries: new Map(), | |
| layerComponentMeta: new Map(), | |
| layerComponentMetaLoading: new Set(), | |
| selectionPanelHidden: false, | |
| selectionExportHidden: true, | |
| requestId: 0, | |
| textTimer: 0, | |
| pendingRequests: 0, | |
| lastProbeOutput: null, | |
| urlState: readUrlState(), | |
| }; | |
| const els = { | |
| text: document.getElementById("probeText"), | |
| model: document.getElementById("modelSelect"), | |
| layer: document.getElementById("layerSelect"), | |
| topK: document.getElementById("topK"), | |
| cardWidth: document.getElementById("cardWidth"), | |
| weakRatio: document.getElementById("weakRatio"), | |
| runProbe: document.getElementById("runProbe"), | |
| shareLink: document.getElementById("shareLink"), | |
| keepModels: document.getElementById("keepModels"), | |
| message: document.getElementById("message"), | |
| results: document.getElementById("results"), | |
| selectionPanel: document.getElementById("selectionPanel"), | |
| selectionList: document.getElementById("selectionList"), | |
| selectionClose: document.getElementById("selectionClose"), | |
| selectionExportCopy: document.getElementById("selectionExportCopy"), | |
| selectionExportToggle: document.getElementById("selectionExportToggle"), | |
| selectionExport: document.getElementById("selectionExport"), | |
| requestIndicator: document.getElementById("requestIndicator"), | |
| }; | |
| async function api(path, options = {}) { | |
| beginRequest(); | |
| try { | |
| const res = await fetch(path, { | |
| headers: { "content-type": "application/json" }, | |
| ...options, | |
| }); | |
| if (!res.ok) { | |
| let detail = res.statusText; | |
| try { detail = (await res.json()).detail || detail; } catch {} | |
| throw new Error(detail); | |
| } | |
| return res.json(); | |
| } finally { | |
| endRequest(); | |
| } | |
| } | |
| function beginRequest() { | |
| state.pendingRequests += 1; | |
| els.requestIndicator.classList.toggle("visible", state.pendingRequests > 0); | |
| } | |
| function endRequest() { | |
| state.pendingRequests = Math.max(0, state.pendingRequests - 1); | |
| els.requestIndicator.classList.toggle("visible", state.pendingRequests > 0); | |
| } | |
| async function init() { | |
| const savedText = localStorage.getItem(STORAGE_KEYS.probeText); | |
| restoreControlValues(); | |
| if (savedText !== null) els.text.value = savedText; | |
| if (state.urlState.text !== null) els.text.value = state.urlState.text; | |
| try { | |
| const modelsOut = await api("/api/models"); | |
| state.models = (modelsOut.models || []).filter(model => model.probe_supported); | |
| if (!state.models.length) throw new Error("No probe-supported models are available."); | |
| els.model.innerHTML = state.models.map(model => `<option value="${escapeAttr(model.model_name)}">${escapeHtml(model.display_name || model.model_name)}</option>`).join(""); | |
| const savedModel = localStorage.getItem(STORAGE_KEYS.model); | |
| if (state.urlState.model && state.models.some(model => model.model_name === state.urlState.model)) els.model.value = state.urlState.model; | |
| else if (savedModel && state.models.some(model => model.model_name === savedModel)) els.model.value = savedModel; | |
| else if (state.models.some(model => model.model_name === "gpt2")) els.model.value = "gpt2"; | |
| await loadModelMeta({ restoreLayer: true }); | |
| runProbe(); | |
| } catch (err) { | |
| showError(err.message); | |
| } | |
| } | |
| async function runProbe() { | |
| const requestId = ++state.requestId; | |
| els.message.className = "empty"; | |
| if (!els.text.value.trim()) { | |
| state.highlights.clear(); | |
| els.results.innerHTML = ""; | |
| els.message.hidden = false; | |
| els.message.textContent = "Enter text to run the probe."; | |
| updateShareUrl(); | |
| return; | |
| } | |
| if (!els.results.children.length) { | |
| els.message.hidden = false; | |
| els.message.textContent = "Running probe..."; | |
| } | |
| try { | |
| const out = await api("/api/probe", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| text: els.text.value, | |
| model_name: els.model.value, | |
| layer: els.layer.value, | |
| top_k: Number(els.topK.value || 5), | |
| highlights: [], | |
| keep_models: els.keepModels.checked, | |
| }), | |
| }); | |
| if (requestId !== state.requestId) return; | |
| restoreHighlightsForContext(out); | |
| renderResults(out); | |
| updateShareUrl(); | |
| } catch (err) { | |
| if (requestId !== state.requestId) return; | |
| showError(err.message); | |
| updateShareUrl(); | |
| } | |
| } | |
| function scheduleProbe() { | |
| localStorage.setItem(STORAGE_KEYS.probeText, els.text.value); | |
| persistControls(); | |
| persistHighlights(); | |
| renderSelectionExport(); | |
| updateShareUrl(); | |
| } | |
| function handleTextKeydown(event) { | |
| if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { | |
| event.preventDefault(); | |
| localStorage.setItem(STORAGE_KEYS.probeText, els.text.value); | |
| persistControls(); | |
| runProbe(); | |
| } | |
| } | |
| function updateCardWidth() { | |
| const value = Math.max(100, Math.min(360, Number(els.cardWidth.value || 140))); | |
| document.documentElement.style.setProperty("--token-card-width", `${value}px`); | |
| localStorage.setItem(STORAGE_KEYS.cardWidth, String(value)); | |
| } | |
| function rerenderLastProbe() { | |
| if (state.lastProbeOutput) renderResults(state.lastProbeOutput); | |
| } | |
| function renderResults(out) { | |
| state.lastProbeOutput = out; | |
| if (out.truncated) { | |
| els.message.hidden = false; | |
| els.message.className = "empty"; | |
| els.message.textContent = `Input was truncated to ${out.max_length} tokens.`; | |
| } else { | |
| els.message.hidden = true; | |
| } | |
| const annotationMeta = annotationMetaMap(out.annotated_components || []); | |
| state.annotationMeta = annotationMeta; | |
| state.currentScores = new Map(); | |
| (out.tokens || []).forEach(token => (token.top || []).forEach(pair => { | |
| const component = Number(pair.component); | |
| const score = Math.abs(Number(pair.score || 0)); | |
| const previous = state.currentScores.get(component); | |
| if (!previous || score > Math.abs(Number(previous.score || 0))) { | |
| state.currentScores.set(component, { component, score: Number(pair.score || 0) }); | |
| } | |
| })); | |
| els.results.innerHTML = out.tokens.map(token => ` | |
| <div class="token-card"> | |
| <div class="token-text" title="${escapeAttr(token.token)}">${escapeHtml(token.token_text || token.token)}</div> | |
| ${token.top.map(pair => scoreBadge(pair, annotationMeta, tokenTopAbsScore(token))).join("")} | |
| ${predictionRow(token)} | |
| </div> | |
| `).join(""); | |
| els.results.querySelectorAll(".badge").forEach(node => { | |
| node.addEventListener("click", event => selectComponent(event, Number(node.dataset.component), node.dataset.selectionKey)); | |
| }); | |
| paintHighlights(); | |
| } | |
| function predictionRow(token) { | |
| const pred = token.prediction; | |
| if (!pred) return ""; | |
| const text = visibleToken(pred.token_text || pred.token || ""); | |
| return ` | |
| <div class="prediction-row" title="${escapeAttr(`next token: ${text}`)}"> | |
| <span class="prediction-label">next</span> | |
| <span class="prediction-token">${escapeHtml(text)}</span> | |
| </div> | |
| `; | |
| } | |
| function scoreBadge(pair, annotationMeta, tokenTopAbs) { | |
| const component = Number(pair.component); | |
| const score = Number(pair.score || 0); | |
| const selectionKey = componentSelectionKey(component, score); | |
| const active = state.highlights.has(selectionKey); | |
| const ratio = tokenTopAbs > 0 ? Math.abs(score) / tokenTopAbs : 0; | |
| const width = 100 * Math.max(0, Math.min(1, ratio)); | |
| const meta = annotationForScore(annotationMeta.get(component), score); | |
| const dot = meta ? `<span class="annotation-dot ${escapeAttr(meta.confidence)}" aria-hidden="true">${escapeHtml(meta.type_letter)}</span>` : ""; | |
| const label = meta ? meta.label : `C${component}`; | |
| const title = meta ? `C${component}: ${annotationHint(meta)}` : `C${component}`; | |
| const cutoff = Math.max(0, Math.min(1, Number(els.weakRatio.value || 0.5))); | |
| const weak = Number.isFinite(tokenTopAbs) && tokenTopAbs > 0 && Math.abs(score) < tokenTopAbs * cutoff; | |
| return ` | |
| <div class="score-row"> | |
| <button class="badge ${active ? "hot" : ""} ${weak ? "weak" : ""}" type="button" data-component="${component}" data-selection-key="${escapeAttr(selectionKey)}" data-score="${escapeAttr(score)}" aria-pressed="${active ? "true" : "false"}" title="${escapeAttr(title)}" style="--score-width:${width.toFixed(1)}%;--component-color:${componentColor(component)}"> | |
| <span class="badge-main">${dot}<b class="badge-label">${escapeHtml(label)}</b></span> | |
| </button> | |
| <span class="score">${formatScore(pair.score)}</span> | |
| </div> | |
| `; | |
| } | |
| function tokenTopAbsScore(token) { | |
| const scores = (token.top || []).map(pair => Math.abs(Number(pair.score || 0))); | |
| return scores.length ? Math.max(...scores) : 0; | |
| } | |
| function selectComponent(event, component, selectionKey) { | |
| state.selectionPanelHidden = false; | |
| if (event.ctrlKey || event.metaKey) { | |
| if (state.highlights.has(selectionKey)) state.highlights.delete(selectionKey); | |
| else state.highlights.add(selectionKey); | |
| } else { | |
| state.highlights.clear(); | |
| state.highlights.add(selectionKey); | |
| } | |
| persistHighlights(); | |
| updateShareUrl(); | |
| paintHighlights(); | |
| } | |
| function highlightStorageKey(model = els.model.value, layer = els.layer.value) { | |
| return `${model}:${layer}`; | |
| } | |
| function readHighlightStore() { | |
| try { | |
| const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.highlights) || "{}"); | |
| return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| function persistHighlights() { | |
| const store = readHighlightStore(); | |
| const key = highlightStorageKey(); | |
| const values = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys); | |
| if (values.length) store[key] = values; | |
| else delete store[key]; | |
| localStorage.setItem(STORAGE_KEYS.highlights, JSON.stringify(store)); | |
| } | |
| function restoreHighlightsForContext(out) { | |
| if (state.urlState.components !== null) { | |
| state.highlights = new Set(state.urlState.components.map(normalizeSelectionKey).filter(Boolean)); | |
| persistHighlights(); | |
| state.urlState.components = null; | |
| } else { | |
| const store = readHighlightStore(); | |
| const saved = Array.isArray(store[highlightStorageKey()]) ? store[highlightStorageKey()] : []; | |
| state.highlights = new Set(saved.map(normalizeSelectionKey).filter(Boolean)); | |
| } | |
| } | |
| function paintHighlights() { | |
| els.results.querySelectorAll(".badge").forEach(node => { | |
| const active = state.highlights.has(node.dataset.selectionKey); | |
| node.classList.toggle("hot", active); | |
| node.setAttribute("aria-pressed", active ? "true" : "false"); | |
| }); | |
| renderSelectionPanel(); | |
| renderSelectionExport(); | |
| } | |
| function renderSelectionPanel() { | |
| const selections = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys).map(parseSelectionKey); | |
| els.selectionPanel.classList.toggle("visible", selections.length > 0 && !state.selectionPanelHidden); | |
| els.selectionList.innerHTML = selections.map(selection => { | |
| const component = selection.component; | |
| const score = selection.sign * Math.abs(Number(state.currentScores?.get(component)?.score || 1)); | |
| ensureLayerComponentMeta(); | |
| ensureTokenStats(component); | |
| ensureComponentNeighbors(component); | |
| const stats = state.tokenStats.get(selectionStatsKey(component)); | |
| const neighbors = state.componentNeighbors.get(selectionStatsKey(component)); | |
| const sourceLabel = sourceSideLabel(componentMeta(component), score); | |
| return ` | |
| <div class="selection-entry" style="--component-color:${componentColor(component)}"> | |
| <div class="selection-item"> | |
| <a class="selection-link" href="${escapeAttr(componentAnnotateUrl(component))}"> | |
| <span>${escapeHtml(els.layer.value)} C${component}${selection.sign < 0 ? " -" : " +"}</span> | |
| <span class="selection-score">${selectionMetrics(component)}</span> | |
| </a> | |
| <div class="selection-token-stats">${renderTokenStats(stats)}</div> | |
| </div> | |
| ${renderComponentNeighbors(neighbors, score, sourceLabel)} | |
| </div> | |
| `; | |
| }).join(""); | |
| } | |
| function renderSelectionExport() { | |
| const selections = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys).map(parseSelectionKey); | |
| if (!selections.length) { | |
| els.selectionExport.classList.remove("visible"); | |
| els.selectionExportCopy.classList.remove("visible"); | |
| els.selectionExportToggle.classList.remove("visible"); | |
| els.selectionExport.value = ""; | |
| return; | |
| } | |
| const selection = selections[0]; | |
| els.selectionExport.value = componentExportText(selection); | |
| els.selectionExportToggle.classList.add("visible"); | |
| els.selectionExportToggle.textContent = state.selectionExportHidden ? "Show text" : "Hide text"; | |
| els.selectionExport.classList.toggle("visible", !state.selectionExportHidden); | |
| els.selectionExportCopy.classList.toggle("visible", !state.selectionExportHidden); | |
| } | |
| function componentExportText(selection) { | |
| const component = selection.component; | |
| const meta = componentMeta(component); | |
| const erf = Number(meta?.effective_context_mean); | |
| const erfText = Number.isFinite(erf) ? Number(erf).toFixed(1).replace(/\.0$/, "") : "?"; | |
| const activations = componentTokenActivations(component, selection.sign); | |
| const activationLines = activations.length | |
| ? activations.map(item => `${item.index}: ${item.token} -> ${item.score.toFixed(1)}`).join("\n") | |
| : "(component is not present in the current top-k token activations)"; | |
| return [ | |
| `[Component C${component}, Effective Receptive Field=${erfText}]`, | |
| "", | |
| "INPUT TEXT:", | |
| els.text.value, | |
| "", | |
| "TOKEN ACTIVATIONS:", | |
| activationLines, | |
| "", | |
| "", | |
| ].join("\n"); | |
| } | |
| function componentTokenActivations(component, sign) { | |
| const rows = []; | |
| (state.lastProbeOutput?.tokens || []).forEach(token => { | |
| const item = (token.top || []).find(pair => Number(pair.component) === Number(component)); | |
| if (!item) return; | |
| const score = Number(item.score || 0); | |
| if (!Number.isFinite(score) || score === 0) return; | |
| if ((score < 0 ? -1 : 1) !== sign) return; | |
| rows.push({ index: Number(token.position), token: exportTokenText(token.token_text || token.token), score }); | |
| }); | |
| return rows.sort((a, b) => Math.abs(b.score) - Math.abs(a.score)); | |
| } | |
| async function copySelectionExport() { | |
| const text = els.selectionExport.value; | |
| if (!text) return; | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| } catch { | |
| els.selectionExport.focus(); | |
| els.selectionExport.select(); | |
| document.execCommand("copy"); | |
| els.selectionExport.setSelectionRange(0, 0); | |
| } | |
| const previous = els.selectionExportCopy.textContent; | |
| els.selectionExportCopy.textContent = "Copied"; | |
| window.setTimeout(() => { els.selectionExportCopy.textContent = previous; }, 900); | |
| } | |
| function selectionMetrics(component) { | |
| const parts = []; | |
| const meta = componentMeta(component); | |
| const ecl = meta?.effective_context_mean; | |
| const kurtosis = meta?.excess_kurtosis; | |
| if (Number.isFinite(ecl)) parts.push(`ERF=${Number(ecl).toFixed(1)}`); | |
| if (Number.isFinite(kurtosis)) parts.push(`K=${Number(kurtosis).toFixed(1)}`); | |
| return escapeHtml(parts.join(" · ")); | |
| } | |
| function selectionStatsKey(component) { | |
| return `${els.model.value}:${els.layer.value}:${component}`; | |
| } | |
| function layerMetaKey(model = els.model.value, layer = els.layer.value) { | |
| return `${model}:${layer}`; | |
| } | |
| function componentMeta(component) { | |
| return state.annotationMeta?.get(component) || state.layerComponentMeta.get(layerMetaKey())?.get(Number(component)) || null; | |
| } | |
| function ensureLayerComponentMeta() { | |
| const key = layerMetaKey(); | |
| if (state.layerComponentMeta.has(key) || state.layerComponentMetaLoading.has(key)) return; | |
| state.layerComponentMetaLoading.add(key); | |
| const params = new URLSearchParams({ | |
| model: state.meta?.model_name || els.model.value, | |
| layer: els.layer.value, | |
| }); | |
| api(`/api/components?${params.toString()}`) | |
| .then(data => { | |
| state.layerComponentMeta.set(key, componentMetaMap(data.components || [])); | |
| renderSelectionPanel(); | |
| renderSelectionExport(); | |
| }) | |
| .catch(() => { | |
| state.layerComponentMeta.set(key, new Map()); | |
| }) | |
| .finally(() => { | |
| state.layerComponentMetaLoading.delete(key); | |
| }); | |
| } | |
| function componentSelectionKey(component, score) { | |
| return `${Number(component)}:${Number(score) < 0 ? "-" : "+"}`; | |
| } | |
| function normalizeSelectionKey(value) { | |
| if (typeof value === "number" && Number.isFinite(value)) return `${value}:+`; | |
| const text = String(value || ""); | |
| if (/^\d+:[+-]$/.test(text)) return text; | |
| const legacy = Number(text); | |
| return Number.isFinite(legacy) ? `${legacy}:+` : ""; | |
| } | |
| function isValidSelectionKey(value) { | |
| return /^\d+:[+-]$/.test(String(value || "")); | |
| } | |
| function parseSelectionKey(value) { | |
| const [component, sign] = String(value).split(":"); | |
| return { component: Number(component), sign: sign === "-" ? -1 : 1 }; | |
| } | |
| function compareSelectionKeys(a, b) { | |
| const aa = parseSelectionKey(a); | |
| const bb = parseSelectionKey(b); | |
| return aa.component - bb.component || bb.sign - aa.sign; | |
| } | |
| function hasHighlightedComponent(component) { | |
| return state.highlights.has(`${Number(component)}:+`) || state.highlights.has(`${Number(component)}:-`); | |
| } | |
| function ensureTokenStats(component) { | |
| const key = selectionStatsKey(component); | |
| if (state.tokenStats.has(key)) return; | |
| const retries = state.tokenStatsRetries.get(key) || 0; | |
| if (retries >= 2) { | |
| state.tokenStats.set(key, { error: "token stats unavailable", tokens: [] }); | |
| return; | |
| } | |
| state.tokenStatsRetries.set(key, retries + 1); | |
| state.tokenStats.set(key, null); | |
| const params = new URLSearchParams({ | |
| model: state.meta?.model_name || els.model.value, | |
| layer: els.layer.value, | |
| component: String(component), | |
| }); | |
| api(`/api/component-token-stats?${params.toString()}`) | |
| .then(data => { | |
| state.tokenStats.set(key, data); | |
| if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel(); | |
| }) | |
| .catch(err => { | |
| state.tokenStats.set(key, { error: err.message || "token stats unavailable", tokens: [] }); | |
| if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel(); | |
| }); | |
| } | |
| function ensureComponentNeighbors(component) { | |
| const key = selectionStatsKey(component); | |
| if (state.componentNeighbors.has(key)) return; | |
| const retries = state.componentNeighborsRetries.get(key) || 0; | |
| if (retries >= 2) { | |
| state.componentNeighbors.set(key, { error: "neighbors unavailable", neighbors: [] }); | |
| return; | |
| } | |
| state.componentNeighborsRetries.set(key, retries + 1); | |
| state.componentNeighbors.set(key, null); | |
| const params = new URLSearchParams({ | |
| model: state.meta?.model_name || els.model.value, | |
| layer: els.layer.value, | |
| component: String(component), | |
| }); | |
| api(`/api/component-neighbors?${params.toString()}`) | |
| .then(data => { | |
| state.componentNeighbors.set(key, data); | |
| if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel(); | |
| }) | |
| .catch(err => { | |
| state.componentNeighbors.set(key, { error: err.message || "neighbors unavailable", neighbors: [] }); | |
| if (hasHighlightedComponent(component) && key === selectionStatsKey(component)) renderSelectionPanel(); | |
| }); | |
| } | |
| function renderComponentNeighbors(data, sourceScore, sourceLabel) { | |
| if (data === null) { | |
| return ` | |
| <div class="selection-neighbors"> | |
| ${renderLoadingNeighbor("prev")} | |
| ${renderLoadingNeighbor("next")} | |
| </div> | |
| `; | |
| } | |
| const byDirection = new Map((data?.neighbors || []).map(item => [item.direction, item])); | |
| return ` | |
| <div class="selection-neighbors"> | |
| ${renderNeighborCard(byDirection.get("prev"), "prev", sourceScore, sourceLabel)} | |
| ${renderNeighborCard(byDirection.get("next"), "next", sourceScore, sourceLabel)} | |
| </div> | |
| `; | |
| } | |
| function renderNeighborCard(neighbor, direction, sourceScore, sourceLabel) { | |
| if (!neighbor) return renderMissingNeighbor(direction); | |
| const layer = String(neighbor.neighbor_layer || ""); | |
| const component = Number(neighbor.neighbor_component); | |
| const label = neighborLabel(neighbor, sourceScore, sourceLabel); | |
| const cos = Number(neighbor.abs_cosine); | |
| const href = componentAnnotateUrl(component, layer); | |
| return ` | |
| <a class="neighbor-card" href="${escapeAttr(href)}" title="${escapeAttr(`${direction}: ${layer} C${component} ${label}`)}"> | |
| <span class="neighbor-target">${escapeHtml(`${layer} C${component}`)}</span> | |
| <span class="neighbor-label">${escapeHtml(label)}</span> | |
| <span class="neighbor-metrics">${neighborMetrics(neighbor)}</span> | |
| <span class="neighbor-cos" style="--cos-width:${cosWidth(cos)}%">cos=${Number.isFinite(cos) ? cos.toFixed(3) : "?"}</span> | |
| </a> | |
| `; | |
| } | |
| function renderMissingNeighbor(direction) { | |
| return ` | |
| <div class="neighbor-card missing" aria-hidden="true"> | |
| <span class="neighbor-target"></span> | |
| <span class="neighbor-label"></span> | |
| <span class="neighbor-metrics"></span> | |
| <span class="neighbor-cos"></span> | |
| </div> | |
| `; | |
| } | |
| function renderLoadingNeighbor(direction) { | |
| return ` | |
| <div class="neighbor-card loading"> | |
| <span class="neighbor-target">loading</span> | |
| <span class="neighbor-label"></span> | |
| <span class="neighbor-metrics"></span> | |
| <span class="neighbor-cos"></span> | |
| </div> | |
| `; | |
| } | |
| function neighborLabel(neighbor, sourceScore, sourceLabel) { | |
| const positive = visibleAnnotationLabel(neighbor?.positive_label, neighbor?.positive_confidence); | |
| const negative = visibleAnnotationLabel(neighbor?.negative_label, neighbor?.negative_confidence); | |
| const positiveMatch = labelSimilarity(sourceLabel, positive); | |
| const negativeMatch = labelSimilarity(sourceLabel, negative); | |
| if (positiveMatch > negativeMatch && positiveMatch > 0) return positive; | |
| if (negativeMatch > positiveMatch && negativeMatch > 0) return negative; | |
| const sourceSign = Number(sourceScore) < 0 ? -1 : 1; | |
| const neighborSign = Number(neighbor?.neighbor_sign) < 0 ? -1 : 1; | |
| if (sourceSign * neighborSign < 0) return negative || "unlabeled"; | |
| return positive || "unlabeled"; | |
| } | |
| function cosWidth(value) { | |
| const cos = Math.max(0, Math.min(1, Number(value) || 0)); | |
| return (100 * cos).toFixed(1); | |
| } | |
| function neighborMetrics(neighbor) { | |
| const parts = []; | |
| const ecl = Number(neighbor?.effective_context_mean); | |
| const kurtosis = Number(neighbor?.excess_kurtosis); | |
| if (Number.isFinite(ecl)) parts.push(`ERF=${ecl.toFixed(1)}`); | |
| if (Number.isFinite(kurtosis)) parts.push(`K=${kurtosis.toFixed(1)}`); | |
| return escapeHtml(parts.join(" · ")); | |
| } | |
| function sourceSideLabel(meta, score) { | |
| if (!meta) return ""; | |
| return Number(score) < 0 | |
| ? visibleAnnotationLabel(meta.negative_label, meta.negative_confidence) | |
| : visibleAnnotationLabel(meta.positive_label, meta.positive_confidence); | |
| } | |
| function labelSimilarity(a, b) { | |
| const aTokens = labelTokens(a); | |
| const bTokens = labelTokens(b); | |
| if (!aTokens.size || !bTokens.size) return 0; | |
| let overlap = 0; | |
| aTokens.forEach(token => { if (bTokens.has(token)) overlap += 1; }); | |
| return overlap / Math.max(aTokens.size, bTokens.size); | |
| } | |
| function labelTokens(value) { | |
| const text = String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); | |
| if (!text || text === "?") return new Set(); | |
| return new Set(text.split(/\s+/).filter(Boolean)); | |
| } | |
| function renderTokenStats(stats) { | |
| if (stats === null) return `<span>loading tokens...</span>`; | |
| if (stats?.error) return `<span>${escapeHtml(stats.error)}</span>`; | |
| const tokens = stats?.tokens || []; | |
| if (!tokens.length) return `<span>no example tokens</span>`; | |
| const maxCount = Math.max(1, ...tokens.map(item => Number(item.count || 0))); | |
| const tokenChips = tokens.map(item => ` | |
| <span class="selection-token-chip" title="${escapeAttr(`${visibleToken(item.token)}: ${item.count}`)}" style="--token-font-size:${tokenFontSize(item.count, maxCount)}px"> | |
| <span class="selection-token-text">${escapeHtml(visibleToken(item.token))}</span> | |
| </span> | |
| `).join(""); | |
| return tokenChips; | |
| } | |
| function tokenFontSize(count, maxCount) { | |
| const value = Math.max(0, Number(count || 0)); | |
| const ratio = maxCount <= 1 ? 0 : Math.log1p(value) / Math.log1p(maxCount); | |
| return (9 + 5 * ratio).toFixed(1); | |
| } | |
| function visibleToken(value) { | |
| const text = String(value || ""); | |
| if (text === " ") return "[space]"; | |
| if (text === "\n") return "[newline]"; | |
| return text.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t"); | |
| } | |
| function exportTokenText(value) { | |
| const text = String(value || ""); | |
| if (text === ".") return "period"; | |
| if (text === ",") return "comma"; | |
| if (text === " ") return "[space]"; | |
| if (text === "\n") return "[newline]"; | |
| return visibleToken(text).trim() || visibleToken(text); | |
| } | |
| function componentAnnotateUrl(component, layer = els.layer.value) { | |
| const params = new URLSearchParams({ | |
| model: state.meta?.model_name || els.model.value, | |
| layer, | |
| component: String(component), | |
| }); | |
| return `/annotate?${params.toString()}`; | |
| } | |
| async function loadModelMeta(options = {}) { | |
| state.meta = await api(`/api/meta?model=${encodeURIComponent(els.model.value)}`); | |
| els.layer.innerHTML = state.meta.layers.map(layer => `<option value="${escapeAttr(layer)}">${escapeHtml(layer)}</option>`).join(""); | |
| if (!state.meta.layers.length) throw new Error(`No ICA layers are available for ${state.meta.display_name || state.meta.model_name}.`); | |
| const savedLayer = localStorage.getItem(STORAGE_KEYS.layer); | |
| if (options.restoreLayer && state.urlState.layer && state.meta.layers.includes(state.urlState.layer)) els.layer.value = state.urlState.layer; | |
| else if (options.restoreLayer && savedLayer && state.meta.layers.includes(savedLayer)) els.layer.value = savedLayer; | |
| } | |
| function restoreControlValues() { | |
| setNumberInputFromStorage(els.topK, STORAGE_KEYS.topK, 1, 32); | |
| setNumberInputFromStorage(els.cardWidth, STORAGE_KEYS.cardWidth, 100, 360); | |
| setNumberInputFromStorage(els.weakRatio, STORAGE_KEYS.weakRatio, 0, 1); | |
| els.keepModels.checked = localStorage.getItem(STORAGE_KEYS.keepModels) !== "0"; | |
| if (state.urlState.topK !== null) els.topK.value = String(state.urlState.topK); | |
| if (state.urlState.cardWidth !== null) els.cardWidth.value = String(state.urlState.cardWidth); | |
| if (state.urlState.weakRatio !== null) els.weakRatio.value = String(state.urlState.weakRatio); | |
| } | |
| function setNumberInputFromStorage(input, key, min, max) { | |
| const raw = localStorage.getItem(key); | |
| if (raw === null) return; | |
| const value = Number(raw); | |
| if (!Number.isFinite(value)) return; | |
| input.value = String(Math.max(min, Math.min(max, value))); | |
| } | |
| function persistControls() { | |
| localStorage.setItem(STORAGE_KEYS.model, els.model.value); | |
| localStorage.setItem(STORAGE_KEYS.layer, els.layer.value); | |
| localStorage.setItem(STORAGE_KEYS.topK, els.topK.value); | |
| localStorage.setItem(STORAGE_KEYS.cardWidth, els.cardWidth.value); | |
| localStorage.setItem(STORAGE_KEYS.weakRatio, els.weakRatio.value); | |
| localStorage.setItem(STORAGE_KEYS.keepModels, els.keepModels.checked ? "1" : "0"); | |
| } | |
| function readUrlState() { | |
| const params = new URLSearchParams(window.location.search); | |
| const topK = readNumericUrlParam(params, ["top_k", "topK"]); | |
| const cardWidth = readNumericUrlParam(params, ["card_width", "cardWidth"]); | |
| const weakRatio = readNumericUrlParam(params, ["opacity_cutoff", "weak_ratio", "weakRatio"]); | |
| const componentsText = params.get("components") || params.get("c"); | |
| return { | |
| model: params.get("model") || "", | |
| layer: params.get("layer") || "", | |
| text: params.has("text") ? params.get("text") : null, | |
| topK: Number.isFinite(topK) ? Math.max(1, Math.min(32, topK)) : null, | |
| cardWidth: Number.isFinite(cardWidth) ? Math.max(100, Math.min(360, cardWidth)) : null, | |
| weakRatio: Number.isFinite(weakRatio) ? Math.max(0, Math.min(1, weakRatio)) : null, | |
| components: componentsText !== null | |
| ? componentsText.split(",").map(normalizeSelectionKey).filter(Boolean) | |
| : null, | |
| }; | |
| } | |
| function readNumericUrlParam(params, names) { | |
| for (const name of names) { | |
| if (!params.has(name)) continue; | |
| const value = Number(params.get(name)); | |
| return Number.isFinite(value) ? value : null; | |
| } | |
| return null; | |
| } | |
| function updateShareUrl() { | |
| const url = `${window.location.pathname}?${buildShareParams().toString()}`; | |
| window.history.replaceState(null, "", url); | |
| } | |
| function buildShareParams() { | |
| const params = new URLSearchParams(); | |
| if (els.model.value) params.set("model", els.model.value); | |
| if (els.layer.value) params.set("layer", els.layer.value); | |
| if (els.text.value) params.set("text", els.text.value); | |
| params.set("top_k", String(Math.max(1, Math.min(32, Number(els.topK.value || 5))))); | |
| params.set("card_width", String(Math.max(100, Math.min(360, Number(els.cardWidth.value || 140))))); | |
| params.set("opacity_cutoff", String(Math.max(0, Math.min(1, Number(els.weakRatio.value || 0.5))))); | |
| const components = [...state.highlights].filter(isValidSelectionKey).sort(compareSelectionKeys); | |
| if (components.length) params.set("components", components.join(",")); | |
| return params; | |
| } | |
| function shareUrl() { | |
| return `${SHARE_BASE_URL}?${buildShareParams().toString()}`; | |
| } | |
| async function copyShareUrl() { | |
| persistControls(); | |
| persistHighlights(); | |
| updateShareUrl(); | |
| const url = shareUrl(); | |
| try { | |
| await navigator.clipboard.writeText(url); | |
| } catch { | |
| const temp = document.createElement("textarea"); | |
| temp.value = url; | |
| temp.setAttribute("readonly", ""); | |
| temp.style.position = "fixed"; | |
| temp.style.left = "-9999px"; | |
| document.body.appendChild(temp); | |
| temp.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(temp); | |
| } | |
| els.shareLink.classList.add("copied"); | |
| els.shareLink.title = "Copied"; | |
| window.setTimeout(() => { | |
| els.shareLink.classList.remove("copied"); | |
| els.shareLink.title = "Copy share link"; | |
| }, 900); | |
| } | |
| function showError(message) { | |
| els.message.hidden = false; | |
| els.message.className = "error"; | |
| els.message.textContent = message; | |
| } | |
| function componentColor(componentId) { | |
| return `hsl(${(37 * Number(componentId)) % 360} 78% 48%)`; | |
| } | |
| function annotationMetaMap(raw) { | |
| const out = new Map(); | |
| if (!Array.isArray(raw)) return out; | |
| raw.forEach(item => { | |
| const component = Number(item?.component); | |
| if (!Number.isFinite(component)) return; | |
| out.set(component, { | |
| positive_label: String(item.positive_label || "").trim(), | |
| positive_confidence: normalizedConfidence(item.positive_confidence), | |
| positive_types: Array.isArray(item.positive_types) ? item.positive_types.map(String) : [], | |
| negative_label: String(item.negative_label || "").trim(), | |
| negative_confidence: normalizedConfidence(item.negative_confidence), | |
| negative_types: Array.isArray(item.negative_types) ? item.negative_types.map(String) : [], | |
| excess_kurtosis: Number.isFinite(Number(item.excess_kurtosis)) ? Number(item.excess_kurtosis) : null, | |
| effective_context_mean: Number.isFinite(Number(item.effective_context_mean)) ? Number(item.effective_context_mean) : null, | |
| }); | |
| }); | |
| return out; | |
| } | |
| function componentMetaMap(raw) { | |
| const out = new Map(); | |
| if (!Array.isArray(raw)) return out; | |
| raw.forEach(item => { | |
| const component = Number(item?.component); | |
| if (!Number.isFinite(component)) return; | |
| out.set(component, { | |
| positive_label: String(item.positive_label || "").trim(), | |
| positive_confidence: normalizedConfidence(item.positive_confidence), | |
| positive_types: Array.isArray(item.positive_types) ? item.positive_types.map(String) : [], | |
| negative_label: String(item.negative_label || "").trim(), | |
| negative_confidence: normalizedConfidence(item.negative_confidence), | |
| negative_types: Array.isArray(item.negative_types) ? item.negative_types.map(String) : [], | |
| excess_kurtosis: Number.isFinite(Number(item.excess_kurtosis)) ? Number(item.excess_kurtosis) : null, | |
| effective_context_mean: Number.isFinite(Number(item.effective_context_mean)) ? Number(item.effective_context_mean) : null, | |
| }); | |
| }); | |
| return out; | |
| } | |
| function annotationForScore(meta, score) { | |
| if (!meta) return null; | |
| const positive = Number(score || 0) >= 0; | |
| const label = positive ? meta.positive_label : meta.negative_label; | |
| const confidence = positive ? meta.positive_confidence : meta.negative_confidence; | |
| const types = positive ? meta.positive_types : meta.negative_types; | |
| if (!visibleAnnotationLabel(label, confidence)) return null; | |
| return { | |
| label: visibleAnnotationLabel(label, confidence), | |
| confidence, | |
| type_letter: typeLetter(types), | |
| }; | |
| } | |
| function annotationHint(meta) { | |
| return `${meta.label} (${meta.confidence})`; | |
| } | |
| function visibleAnnotationLabel(value, confidence) { | |
| const text = String(value || "").trim(); | |
| if (!text) return ""; | |
| if (text === "?" && normalizedConfidence(confidence) === "unclear") return ""; | |
| return text.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t"); | |
| } | |
| function normalizedConfidence(value) { | |
| const confidence = String(value || "unclear").toLowerCase(); | |
| return ["high", "medium", "low", "unclear"].includes(confidence) ? confidence : "unclear"; | |
| } | |
| function typeLetter(types) { | |
| const labels = Array.isArray(types) ? types : []; | |
| const priority = [ | |
| ["Form", "F"], | |
| ["Word", "W"], | |
| ["Phrase", "P"], | |
| ["Sentence", "S"], | |
| ["Long-Range Context", "L"], | |
| ["Global", "G"], | |
| ["Position", "O"], | |
| ["Sophisticated", "X"], | |
| ]; | |
| for (const [name, letter] of priority) { | |
| if (labels.some(label => String(label).toLowerCase() === name.toLowerCase())) return letter; | |
| } | |
| const first = labels.find(label => String(label).trim()); | |
| return first ? String(first).trim()[0].toUpperCase() : ""; | |
| } | |
| function formatScore(value) { | |
| const abs = Math.abs(value); | |
| if (abs >= 100) return value.toFixed(0); | |
| if (abs >= 10) return value.toFixed(1); | |
| return value.toFixed(2); | |
| } | |
| function escapeHtml(value) { | |
| return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); | |
| } | |
| function escapeAttr(value) { | |
| return escapeHtml(value); | |
| } | |
| els.model.addEventListener("change", async () => { | |
| persistHighlights(); | |
| state.highlights.clear(); | |
| state.tokenStats.clear(); | |
| state.tokenStatsRetries.clear(); | |
| state.componentNeighbors.clear(); | |
| state.componentNeighborsRetries.clear(); | |
| persistControls(); | |
| try { | |
| await loadModelMeta(); | |
| persistControls(); | |
| runProbe(); | |
| } catch (err) { | |
| showError(err.message); | |
| } | |
| }); | |
| els.layer.addEventListener("change", () => { | |
| persistHighlights(); | |
| state.highlights.clear(); | |
| state.componentNeighbors.clear(); | |
| state.componentNeighborsRetries.clear(); | |
| persistControls(); | |
| runProbe(); | |
| }); | |
| els.topK.addEventListener("change", () => { persistControls(); updateShareUrl(); runProbe(); }); | |
| els.topK.addEventListener("input", () => { persistControls(); updateShareUrl(); runProbe(); }); | |
| els.cardWidth.addEventListener("change", () => { updateCardWidth(); updateShareUrl(); }); | |
| els.cardWidth.addEventListener("input", () => { updateCardWidth(); updateShareUrl(); }); | |
| els.weakRatio.addEventListener("change", () => { persistControls(); rerenderLastProbe(); updateShareUrl(); }); | |
| els.keepModels.addEventListener("change", () => { persistControls(); if (!els.keepModels.checked) runProbe(); }); | |
| els.selectionClose.addEventListener("click", () => { | |
| state.selectionPanelHidden = true; | |
| renderSelectionPanel(); | |
| }); | |
| els.selectionExportToggle.addEventListener("click", () => { | |
| state.selectionExportHidden = !state.selectionExportHidden; | |
| renderSelectionExport(); | |
| }); | |
| els.selectionExportCopy.addEventListener("click", copySelectionExport); | |
| els.weakRatio.addEventListener("input", () => { persistControls(); rerenderLastProbe(); updateShareUrl(); }); | |
| els.text.addEventListener("input", scheduleProbe); | |
| els.text.addEventListener("keydown", handleTextKeydown); | |
| els.runProbe.addEventListener("click", () => { | |
| localStorage.setItem(STORAGE_KEYS.probeText, els.text.value); | |
| persistControls(); | |
| runProbe(); | |
| }); | |
| els.shareLink.addEventListener("click", copyShareUrl); | |
| updateCardWidth(); | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |