PageSourceSearch

https://transformer-circuits.pub/2026/workspace/public/bundle.js

js transformer-circuits.pub collected 2026-10-01 09:02:57 UTC 443,303 bytes, 9,940 lines download raw bytes

1// ─── style.css backstop (see _bundle_head) ───
2;(function(){var want = ['public/intro-functional/style.css', 'public/intro-structural/style.css', 'public/ooo-heatmap/style.css', 'public/ooo-abcd/style.css', 'public/ooo-patch/style.css', 'public/feature-examples/style.css', 'public/feature-coverage/style.css', 'public/comp-lines/style.css', 'public/comp-strata/style.css', 'public/comp-tail-energy/style.css', 'public/mlp-gain/style.css', 'public/lens-similarity/style.css', 'public/transcoder-arith/style.css', 'public/transcoder-translation/style.css', 'public/attn-panel/style.css', 'public/jlens-circuit-graph/style.css', 'public/attn-broadcast-reselect/style.css', 'public/ablation-bars/style.css', 'public/ablation-examples/style.css', 'public/ablation-strength/style.css', 'public/blackmail-clamp/style.css', 'public/lensapp-panel/style.css', 'public/reflection-training/style.css', 'public/dn-tracecond/style.css', 'public/dn-exclusion/style.css', 'public/refl-training-examples/style.css', 'public/modulation-readout/style.css', 'public/modulation-lines/style.css', 'public/modulation-prompts/style.css', 'public/latent-patching/style.css', 'public/multihop-swap-success/style.css', 'public/repeat-switch/style.css', 'public/verbal-report/style.css', 'public/verbal-introspection/style.css', 'public/top-down-summoning/style.css', 'public/flex-gen-systematic/style.css', 'public/selectivity-linecount/style.css', 'public/selectivity-language/style.css', 'public/post-training/style.css', 'public/lens-inline/style.css', 'public/methods-qualitative/style.css', 'public/jlens-audit/style.css', 'public/jlens-rm-bias/style.css', 'public/jlens-rm-bias-examples/style.css', 'public/pref-violation-lens/style.css', 'public/layer-diagram/style.css', 'public/line-panels/style.css', 'public/capacity-fve-occupancy/style.css', 'public/broadcast-ablation/style.css', 'public/lens-callout/style.css', 'public/misalign-lens/style.css', 'public/roleplay-lens/style.css', 'public/reward-hack-readout/style.css', 'public/reward-hack-quant/style.css', 'public/modulation-probe/style.css', 'public/probe-swap/style.css', 'public/verbal-report-decomposition-merged/style.css', 'public/selfreport/style.css', 'public/slice-inline/style.css', 'public/slice-stack/style.css', 'public/metacog-alarm/style.css', 'public/ignition/style.css', 'public/eval-awareness-probe/style.css', 'public/dual-task-simple/style.css', 'public/capacity-final-band/style.css', 'public/flex-generalization-example/style.css', 'public/flex-generalization-systematic/style.css', 'public/capacity-band-vs-single/style.css', 'public/app-mt-examples/style.css', 'public/oracle-lens/style.css', 'public/app-mt-bytok/style.css', 'public/app-mt-paper/style.css']
3  var have = new Set([...document.querySelectorAll('link[rel=stylesheet]')]
4    .map(l => (l.getAttribute('href')||'').replace(/\?.*/,'')))
5  want.forEach(function(h){ if (have.has(h)) return
6    var l = document.createElement('link')
7    l.rel = 'stylesheet'; l.href = h
8    document.head.appendChild(l) })
9})()
10// ─── public/util.js ───
11window.util = (function(){
12
13  // ───── url params ─────
14  // URL params become fetch bases and hrefs; keep them on this page's origin.
15  // Rejects scheme injection (javascript:, data:, https://other-host) and
16  // protocol-relative //host. Relative paths (incl. ../) and same-origin
17  // /absolute paths pass through unchanged.
18  function safeRelPath(p, fallback){
19    return p && !/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(p) ? p : fallback
20  }
21
22  var params = {
23    get: key => {
24      var v = new URLSearchParams(location.search).get(key)
25      return v && decodeURIComponent(v)
26    },
27    set: (key, value) => {
28      var url = new URL(location)
29      if (value == null) url.searchParams.delete(key)
30      else url.searchParams.set(key, encodeURIComponent(value))
31      history.replaceState(null, '', url)
32    },
33    getAll: () => {
34      var rv = {}
35      for (var [k, v] of new URLSearchParams(location.search)) rv[k] = decodeURIComponent(v)
36      return rv
37    },
38  }
39
40  // ───── render fan-out ─────
41  function initRenderAll(labels){
42    var rv = {}
43    labels.forEach(label => {
44      rv[label] = ev => Object.values(rv[label].fns).forEach(d => d(ev))
45      rv[label].fns = []
46    })
47    return rv
48  }
49
50  function attachRenderAllHistory(renderAll, visState, skipKeys=['hoverId', 'hoverIdx']){
51    Object.keys(renderAll).forEach(key => {
52      renderAll[key].fns.push(() => {
53        if (skipKeys.includes(key)) return
54        var url = new URL(location)
55        if (visState[key] == url.searchParams.get(key)) return
56        url.searchParams.set(key, visState[key])
57        history.pushState({...visState}, '', url)
58      })
59    })
60    d3.select(window).on('popstate.updateState', ev => {
61      if (!ev.state) return
62      Object.keys(renderAll).forEach(key => {
63        if (skipKeys.includes(key) || visState[key] == ev.state[key]) return
64        visState[key] = ev.state[key]
65        renderAll[key]()
66      })
67    })
68  }
69
70  // ───── data loading ─────
71  // window-scoped cache survives hot-reload re-runs of init()
72  window.__datacache = window.__datacache || {}
73
74  async function loadParquet(url){
75    var buffer = await fetch(url, {cache: 'default'}).then(r => r.arrayBuffer())
76    var rows
77    await hyparquet.parquetRead({file: buffer, rowFormat: 'object', onComplete: d => rows = d})
78    rows.forEach(r => { for (var k in r) if (typeof r[k] === 'bigint') r[k] = Number(r[k]) })
79    return rows
80  }
81
82  function cachedParquet(url){
83    if (!__datacache[url]) __datacache[url] = loadParquet(url)
84    return __datacache[url]
85  }
86
87  async function loadNpy(url){
88    return npyjs.parse(await fetch(url, {cache: 'default'}).then(r => r.arrayBuffer()))
89  }
90
91  async function getFile(path){
92    if (!__datacache[path]) __datacache[path] = (async () => {
93      var res = await fetch(path, {cache: 'no-cache'})
94      if (!res.ok) throw Error(res.status + ' ' + path)
95      var type = path.split('?')[0].replaceAll('..', '').split('.').at(-1)
96      if (type == 'csv')   return d3.csvParse(await res.text())
97      if (type == 'npy')   return npyjs.parse(await res.arrayBuffer())
98      if (type == 'json')  return res.json()
99      if (type == 'jsonl') return (await res.text()).split(/\r?\n/).filter(d => d).map(JSON.parse)
100      if (type == 'parquet') return loadParquet(path)
101      return res.text()
102    })()
103    return __datacache[path]
104  }
105
106  // ───── chart styling ─────
107  function addAxisLabel(c, xText, yText, title='', xOffset=0, yOffset=0, titleOffset=0){
108    c.svg.select('.x').append('g').translate([c.width/2, xOffset + 27])
109      .append('text.axis-label').text(xText).at({textAnchor: 'middle'})
110    c.svg.select('.y').append('g').translate([yOffset - 30, c.height/2])
111      .append('text.axis-label').text(yText).at({textAnchor: 'middle', transform: 'rotate(-90)'})
112    if (title) c.svg.append('g.axis').translate([c.width/2, titleOffset - 10])
113      .append('text.axis-label.axis-title').text(title).at({textAnchor: 'middle'})
114  }
115
116  function ggPlot(c){
117    c.svg.append('rect.bg-rect').at({width: c.width, height: c.height, fill: c.isBlack ? '#000' : '#EAECED'}).lower()
118    c.svg.selectAll('.domain').remove()
119    ggPlotUpdate(c)
120  }
121
122  function ggPlotUpdate(c){
123    // inline style, not attr — several per-figure sheets set `.axis path { stroke: var(--tick-line) }`
124    // and CSS beats SVG presentation attrs, which turned the gridlines #ddd.
125    var grid = c.isBlack ? '#444' : '#fff'
126    c.svg.selectAll('.tick line').remove()
127    c.svg.selectAll('.x text').at({y: 4})
128    c.svg.selectAll('.x .tick').selectAppend('path').at({d: 'M 0 0 V -' + c.height, strokeWidth: 1}).st({stroke: grid})
129    c.svg.selectAll('.y text').at({x: -3})
130    c.svg.selectAll('.y .tick').selectAppend('path').at({d: 'M 0 0 H ' + c.width, strokeWidth: 1}).st({stroke: grid})
131  }
132
133  // workspace-layer band with consistent z-order: sits on top of bg-rect but
134  // BELOW the white gridlines that ggPlot draws inside .x/.y .tick paths.
135  // Bracket + label sit above the data in the same kraft tone as the band so
136  // they read as part of the band, not as a chart annotation.
137  function addWsBand(c, lo, hi, opts={}){
138    var bg = c.svg.select('.bg-rect').node()
139    var x0 = c.x(lo), x1 = c.x(hi), tk = 5
140    var r = c.svg.insert('rect', bg ? () => bg.nextSibling : ':first-child')
141      .at({class: 'ws-band', x: x0, width: x1 - x0, y: 0, height: c.height})
142    if (opts.label !== false){
143      var g = c.svg.append('g.ws-annot')
144      g.append('path.ws-bracket').at({d: `M${x0},${tk} V0 H${x1} V${tk}`})
145      g.append('text.ws-label').text(opts.label || 'Workspace layers')
146        .at({x: (x0 + x1)/2, y: 7, textAnchor: 'middle'})
147    }
148    return r
149  }
150
151  // Canonical layer-axis ticks: pub indices that display as 0/25/50/75/100.
152  // Filtered to [0, nLayers-1] so figures whose data starts late or stops
153  // short of L24 just drop the out-of-range ticks.
154  function layerTicks(nLayers, nTicks){
155    var canon = [0, 6, 12, 18, 24]
156    return canon.filter(t => t < nLayers)
157  }
158
159  // Display mapping for published layer indices: 0..24 → 0..100. Data and scale
160  // domains stay 0..24; this only formats the visible number.
161  var DISPLAY_LAYERS = [0,4,8,12,17,21,25,29,33,38,42,46,50,54,58,62,67,71,75,79,83,88,92,96,100]
162  function layerLabel(i){ var d = DISPLAY_LAYERS[i]; return d == null ? i : d }
163
164  // ───── colors ─────
165  // Visual vocab. Source of truth for figure colors/spacing — `syncVars()`
166  // writes these to CSS custom properties at load so shared-styles.css and
167  // figure CSS read the same values. Don't hardcode hex in figure JS or CSS;
168  // use util.gray/accent/series or var(--gray-*) etc.
169  //
170  // gray ramp = neutral, distill-derived (transformer-circuits.pub body).
171  // series/accents = Paul Tol vibrant (CVD-safe).
172  var gray = {
173    100: '#fafafa',  // soft bg / card bg
174    200: '#eee',     // rules, hairline borders
175    300: '#ddd',     // grid lines, tick lines
176    400: '#bbb',     // faint text, disabled
177    500: '#888',     // light text, secondary labels
178    600: '#555',     // tick labels
179    700: '#333',     // body text
180    900: '#111',     // emphasis
181  }
182  // Category10-ish, ordered for hue separation: blue, green, brown, purple,
183  // orange, red. Orange is in the series AND is the activation/accent hue —
184  // figures that cycle series and also use accent.main will show it twice.
185  var series = ['#1f77b4', '#2ca02c', '#8c564b', '#9467bd', '#ee7733', '#d62728']
186  // Named aliases into `series` so figure code can read `tol.blue`/`tol.teal`
187  // and get the well-spaced palette. Names are approximate (teal→green,
188  // magenta→purple, cyan→brown).
189  var tol = {
190    blue:    series[0],
191    teal:    series[1],
192    cyan:    series[2],
193    magenta: series[3],
194    orange:  series[4],
195    red:     series[5],
196  }
197  var accent = {
198    main:      tol.orange,
199    secondary: tol.blue,
200    pro:       tol.magenta,
201    danger:    tol.red,
202  }
203  // Brand palette — for non-data accents: callout boxes, brand moments, soft
204  // fills. Not for series.
205  // manilla/kraft are the J-space marker colors (intro-functional thought
206  // bubble, intro-structural sliver, .ws-band shading, layer-diagram, …);
207  // values are lifted from the J-lens readout box in the J-lens schematic
208  // figure so every "this is the workspace" fill in the paper matches.
209  var brand = {
210    clay:      '#d98058',
211    bookCloth: '#cc7d5c',
212    kraft:     '#dd9e57',
213    manilla:   '#fdf4e3',
214    lilypad:   '#7eba8f',
215  }
216  var pos = tol.blue, neg = tol.orange
217  // Same model → same color, every figure that compares across models.
218  var modelColors = { haiku: '#f7b380', sonnet: tol.orange, opus: '#a8460e' }
219  // Ablation tier → orange shade (shared by ablation-bars/-strength/-examples).
220  var ablationColors = { light: '#f8c08a', medium: tol.orange, heavy: '#b34d10', random: gray[400] }
221  // Same lens type → same color, every figure that compares lens variants.
222  // jacobian stays blue (paper's namesake); logit/tuned get purple/green for
223  // separation — the old blue/cyan/teal triple was indistinguishable.
224  var lensColors = { jacobian: series[0], logit: series[3], tuned: series[1] }
225  var space = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }
226  // Solid tint instead of opacity — equivalent visual but bars stay opaque.
227  // tint(color, 0.5) = halfway to white.
228  function tint(c, t){ return d3.interpolateRgb(c, '#fff')(t) }
229  // J-space decomposition conditions — shared by probe-swap (Fig 15) and
230  // verbal-report-decomposition-merged (Fig 7) so a reader learns the
231  // mapping once. Clamped variants are a 0.5 tint of their parent.
232  var jspaceColors = {
233    jlens: tol.blue, clean: gray[300], random: gray[300],
234    aligned: tol.orange, probe_jpart2: tol.orange,
235    residual: gray[500], probe_ortho2: gray[500],
236    full: tol.teal,
237    probe_jpart2_oclamp: tint(tol.orange, .5),
238    probe_ortho2_jclamp: tint(gray[500], .5),
239    residual_jclamp: tint(gray[500], .5),
240  }
241  // Inline style for shading a token by activation strength t ∈ [0,1].
242  // t=0 → white, t=1 → tinted color at ~60%. Use for any "how much does this
243  // token activate" shading so figures share one ramp.
244  function activationBg(color, t){ return `background:${tint(color, 1 - t * 0.6)}` }
245
246  // NYT-style direct line label: colored text with a halo so it reads over the
247  // plot. x/y are pixel coords (caller converts via c.x/c.y); dx/dy nudge.
248  // halo defaults to the ggPlot bg since every line chart in the paper uses it.
249  // Pass leader:[lx,ly] (px) to draw a thin gray connector from label to line.
250  function directLabel(svg, {x, y, text, color, dx=0, dy=0, anchor='start', halo='#EAECED', leader, key}){
251    var g = svg.append('g.direct-label').translate([x, y])
252    if (leader) g.append('path').at({
253      d: `M${dx + (anchor == 'end' ? -2 : anchor == 'middle' ? 0 : 2)},${dy - 3} L${leader[0]-x},${leader[1]-y}`,
254      stroke: gray[400], strokeWidth: 1, fill: 'none',
255    })
256    var t = g.append('text.dlabel').text(text).at({x: dx, y: dy, textAnchor: anchor, fill: color})
257      .st({paintOrder: 'stroke', stroke: halo, strokeWidth: 1, strokeLinejoin: 'round',
258           fontSize: 'var(--fs-label)', fontWeight: 600})
259    if (key) t.at({'data-series': key})
260    return g
261  }
262
263  // Cross-panel line+label hover: any element with data-series=<k> highlights
264  // every [data-series=<k>] inside figSel on mouseover. Figures tag their line
265  // paths with .line-series + data-series, pass key to directLabel, then call
266  // this once on the figure root. Only binds when there are many series — for a
267  // handful of labelled lines the highlight is just visual noise.
268  function bindSeriesHover(figSel, {minSeries=9}={}){
269    if (params.get('tune') === '1') return  // .raise() detaches d3.drag mid-gesture
270    var keys = new Set(); figSel.selectAll('[data-series]').each(function(){ keys.add(this.dataset.series) })
271    if (keys.size < minSeries) return
272    figSel.on('mouseover.series', e => {
273      var k = e.target.closest('[data-series]')?.dataset.series
274      var all = figSel.selectAll('[data-series]').classed('on', false)
275      if (k) all.filter(function(){ return this.dataset.series === k }).classed('on', true).raise()
276    }).on('mouseleave.series', () => figSel.selectAll('[data-series]').classed('on', false))
277  }
278
279  // Expanded hit-area for small jitter circles: mousemove on svgSel picks the
280  // nearest dataSel circle within `radius` px (Delaunay if loaded, else linear
281  // scan). Picked el gets .hov; onHover(d, el) fires while one is picked,
282  // onLeave() when none. Coords read from cx/cy — assumes circles share
283  // svgSel's coordinate space.
284  function nearestHover(svgSel, dataSel, {radius=40, onHover, onLeave}={}){
285    var nodes = dataSel.nodes(), data = dataSel.data()
286    var pts = nodes.map(n => [+n.getAttribute('cx'), +n.getAttribute('cy')])
287    var del = d3.Delaunay && pts.length ? d3.Delaunay.from(pts) : null
288    var find = del ? (x, y) => del.find(x, y) : (x, y) => {
289      var bi = -1, bd = Infinity
290      for (var i = 0; i < pts.length; i++){
291        var dd = (pts[i][0]-x)**2 + (pts[i][1]-y)**2
292        if (dd < bd){ bd = dd; bi = i }
293      }
294      return bi
295    }
296    var cur = -1
297    svgSel.on('mousemove.nearestHover', e => {
298      var [mx, my] = d3.pointer(e, svgSel.node())
299      var i = pts.length ? find(mx, my) : -1
300      if (i >= 0 && Math.hypot(pts[i][0]-mx, pts[i][1]-my) > radius) i = -1
301      if (i !== cur){
302        if (cur >= 0) nodes[cur].classList.remove('hov')
303        if (i >= 0) nodes[i].classList.add('hov'); else onLeave?.()
304        cur = i
305      }
306      if (i >= 0) onHover?.(data[i], nodes[i], e)
307    }).on('mouseleave.nearestHover', () => {
308      if (cur < 0) return
309      nodes[cur].classList.remove('hov'); onLeave?.(); cur = -1
310    })
311  }
312
313  // Stack a set of labels along a vertical edge without overlap: pass anchors
314  // as [{text,color,y,...}] sorted by data-y; returns the same with y nudged so
315  // adjacent labels are ≥ minGap apart, preserving order.
316  function spreadLabels(specs, minGap=13){
317    var s = specs.slice().sort((a,b) => a.y - b.y)
318    for (var i = 1; i < s.length; i++) if (s[i].y - s[i-1].y < minGap) s[i].y = s[i-1].y + minGap
319    for (var i = s.length - 2; i >= 0; i--) if (s[i+1].y - s[i].y < minGap) s[i].y = s[i+1].y - minGap
320    return specs
321  }
322
323  // Error-bar whisker: vertical line from lo→hi (pixel coords) with top
323/bottom
324  // caps. capW=0 for no caps; stroke defaults to gray[700].
325  function barWhisker(svg, {x, lo, hi, capW=4, stroke=gray[700]}){
326    var s = {stroke, strokeWidth: 1.2}
327    svg.append('line').at({x1: x, x2: x, y1: lo, y2: hi, ...s})
328    if (!capW) return
329    svg.append('line').at({x1: x - capW, x2: x + capW, y1: lo, y2: lo, ...s})
330    svg.append('line').at({x1: x - capW, x2: x + capW, y1: hi, y2: hi, ...s})
331  }
332
333  function syncVars(){
334    var root = document.documentElement.style
335    Object.entries(gray).forEach(([k,v]) => root.setProperty('--gray-'+k, v))
336    Object.entries(tol).forEach(([k,v]) => root.setProperty('--tol-'+k, v))
337    Object.entries(accent).forEach(([k,v]) => root.setProperty('--accent-'+k, v))
338    Object.entries(brand).forEach(([k,v]) => root.setProperty('--brand-'+k, v))
339    series.forEach((c,i) => root.setProperty('--series-'+i, c))
340    Object.entries(modelColors).forEach(([k,v]) => root.setProperty('--model-'+k, v))
341    Object.entries(lensColors).forEach(([k,v]) => root.setProperty('--lens-'+k, v))
342    Object.entries(space).forEach(([k,v]) => root.setProperty('--space-'+k, v+'px'))
343  }
344  syncVars()
345
346  function rankColorCapped(cap){
347    return d3.scaleSequential(d3.interpolateViridis).domain([Math.log10(cap), 0])
348  }
349  var rankColor = rankColorCapped(10000)
350  var lineColors = series  // legacy alias; new code should use util.series
351
352  function drawColorbar(sel, {label='rank', w=300, h=10, cap=10000}={}){
353    var n = 100
354    var rc = rankColorCapped(cap)
355    var ticks = cap <= 1000 ? [1, 10, 100, 1000] : [1, 10, 100, 1000, 10000]
356    var svg = sel.append('svg.colorbar').at({width: w + 60, height: h + 20}).st({overflow: 'visible'})
357    var g = svg.append('g').translate([30, 0])
358    g.appendMany('rect', d3.range(n)).at({x: i => i*w/n, width: w/n+1, height: h, fill: i => rc(i/n * Math.log10(cap))})
359    var x = d3.scaleLog().domain([1, cap]).range([0, w])
360    g.appendMany('text', ticks).text(d => d >= cap ? (cap >= 1000 ? cap/1000+'k+' : cap+'+') : d >= 1000 ? d/1000+'k' : d).at({x: d => x(d), y: h+12, textAnchor: 'middle'})
361    g.append('text.axis-label').text(label).at({x: -4, y: h-1, textAnchor: 'end'})
362    return rc
363  }
364
365  // ───── drag-tune (?tune=1) ─────
366  // On a figure's standalone page, ?tune=1 makes registered elements draggable.
367  // Positions land in ?pos= and a fixed config panel — copy either back to
368  // hardcode. items: [{sel, key, get:()=>({x,y}), set:({x,y})=>void}].
369  // ns groups keys in the URL/panel so multiple dragTune calls coexist.
370  var _dt
371  function _dtInit(){
372    if (_dt) return _dt
373    var pos = {}
374    ;(params.get('pos') || '').split(';
374').filter(Boolean).forEach(e => {
375      var m = e.match(/^([^.]+)\.(.+):(-?\d+),(-?\d+)$/); if (!m) return
376      ;(pos[m[1]] ||= {})[m[2]] = {x: +m[3], y: +m[4]}
377    })
378    var panel = d3.select('body').selectAppend('div.dragtune-panel')
379      .st({position: 'fixed', bottom: 0, left: 0, right: 0, background: '#111', color: '#eee',
380           font: '11px/1.4 monospace', padding: '8px 12px', zIndex: 9999, maxHeight: '30vh', overflow: 'auto'})
381    panel.selectAppend('button').text('copy URL').st({float: 'right', cursor: 'pointer'})
382      .on('click', () => navigator.clipboard.writeText(location.href))
383    var pre = panel.selectAppend('pre').st({margin: 0, whiteSpace: 'pre-wrap'})
384    function sync(){
385      var enc = Object.entries(pos).flatMap(([n, ks]) =>
386        Object.entries(ks).map(([k, p]) => `${n}.${k}:${p.x},${p.y}`)).join(';')
387      var q = new URLSearchParams(location.search); q.delete('pos')
388      history.replaceState(null, '', '?' + q + (enc ? '&pos=' + enc : ''))
389      pre.text(Object.entries(pos).map(([n, ks]) =>
390        `// ${n}\n` + Object.entries(ks).map(([k, p]) => `  ${k}: {x: ${p.x}, y: ${p.y}},`).join('\n')
391      ).join('\n'))
392    }
393    return _dt = {pos, sync}
394  }
395  function dragTune(items, ns='fig'){
396    if (params.get('tune') !== '1') return
397    var {pos, sync} = _dtInit()
398    items.forEach(it => {
399      var sel = it.sel.call ? it.sel : d3.select(it.sel)
400      var saved = pos[ns]?.[it.key], p = saved ? {...saved} : it.get()
401      if (saved) it.set(p)
402      ;(pos[ns] ||= {})[it.key] = {x: Math.round(p.x), y: Math.round(p.y)}
403      sel.st({cursor: 'move'}).call(d3.drag()
404        .on('drag', e => { p.x += e.dx; p.y += e.dy; it.set(p) })
405        .on('end', () => { pos[ns][it.key] = {x: Math.round(p.x), y: Math.round(p.y)}; sync() }))
406    })
407    sync()
408  }
409  // Auto-register every text.dlabel under sel — key from data-series or text.
410  function dragTuneLabels(sel, ns='dlabel'){
411    if (params.get('tune') !== '1') return
412    var items = []
413    ;(sel || d3.select('body')).selectAll('text.dlabel').each(function(){
414      var el = d3.select(this), key = (this.dataset.series || el.text()).replace(/\W+/g, '_').slice(0, 24)
415      items.push({sel: el, key,
416        get: () => ({x: +el.attr('x') || 0, y: +el.attr('y') || 0}),
417        set: p => el.at({x: p.x, y: p.y})})
418    })
419    dragTune(items, ns)
420  }
421
422  // ───── misc ─────
423  function throttle(fn, delay){
424    var last = 0
425    return (...a) => { if (Date.now() - last < delay) return; last = Date.now(); fn(...a) }
426  }
427  function debounce(fn, delay){
428    var t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), delay) }
429  }
430  function throttleDebounce(fn, delay){
431    var last = 0, t
432    return function(...a){
433      clearTimeout(t)
434      var rem = delay - (Date.now() - last)
435      if (rem <= 0){ last = Date.now(); fn.apply(this, a) }
436      else t = setTimeout(() => { last = Date.now(); fn.apply(this, a) }, rem)
437    }
438  }
439  function sleep(ms){ return new Promise(r => setTimeout(r, ms)) }
440  function cache(fn){
441    var c = {}
442    return function(...a){ var k = JSON.stringify(a); if (!(k in c)) c[k] = fn.apply(this, a); return c[k] }
443  }
444  // per-panel caption (.psub). First call per figure gets the "Figure N: " prefix.
445  function panelCaption(sel, opts, text){
446    var n = opts._pc = (opts._pc || 0) + 1
447    var div = sel.append('div.psub')
448    if (n == 1 && opts.figNum) div.append('span.fig-num').text(`Figure ${opts.figNum}: `)
449    div.append('span').html(text)
450    return div
451  }
452  // Wrap occurrences of each word in the figure's <figcaption> with a colored
453  // span so caption text visually keys to series colors in the chart.
454  // replacements: {word: '#hex' | 'var(--x)' | 'css-class' | {bg: '#hex', color?: '#hex'}}.
455  // {bg:} sets a background highlight instead of foreground — use when the
456  // series color is gray-ish and would vanish against the caption text color.
457  // Case-insensitive whole-word; warns on miss.
458  function colorCaption(figSel, replacements){
459    var cap = (figSel.node?.() || figSel)?.closest('figure')?.querySelector('figcaption')
460    if (!cap) return
461    Object.entries(replacements).forEach(([word, val]) => {
462      var re = new RegExp('(?<![\\w-])' + word + '(?![\\w-])', 'gi'), found = false
463      var isObj = val && typeof val == 'object'
464      var firstOnly = isObj && val.firstOnly
465      var isColor = !isObj && /^(#|rgb|hsl|var\()/i.test(val)
466      var style = s => isObj ? (s.style.cssText = `background:${val.bg};border-radius:2px;padding:0 2px` + (val.color ? `;color:${val.color}` : ''))
467                     : isColor ? (s.style.color = val) : (s.className = val)
468      var reFull = new RegExp('^' + word + '$', 'i')
469      ;(function walk(n){
470        for (var c of [...n.childNodes]){
471          if (firstOnly && found) return
472          if (c.nodeType == 1) {
473            // if a wrapper span's entire text is the word, replace the wrapper
474            // itself so the new class doesn't nest inside the chip styling.
475            if (c.tagName == 'SPAN' && !c.children.length && reFull.test(c.textContent.trim())){
476              found = true
477              var s = document.createElement('span'); s.textContent = c.textContent
478              style(s); c.replaceWith(s); continue
479            }
480            walk(c); continue
481          }
482          if (c.nodeType != 3) continue
483          var txt = c.data, m, last = 0, out = []
484          re.lastIndex = 0
485          while ((m = re.exec(txt))){
486            found = true
487            if (m.index > last) out.push(txt.slice(last, m.index))
488            var s = document.createElement('span'); s.textContent = m[0]
489            style(s); out.push(s); last = re.lastIndex
490            if (firstOnly) break
491          }
492          if (last) { if (last < txt.length) out.push(txt.slice(last)); c.replaceWith(...out) }
493        }
494      })(cap)
495      if (!found) console.warn(`[colorCaption] no match for "${word}" in figcaption`)
496    })
497  }
498  function resolveFigRefs(){
499    document.querySelectorAll('a.fig-ref').forEach(a => {
500      var ref = a.dataset.ref ?? a.hash.slice(5)
501      var fignum = document.getElementById('fig-' + ref)?.dataset.fignum
502      if (fignum != null) { a.textContent = fignum; return }
503      var sec = window.__secNum?.[ref]
504      if (sec != null) { a.textContent = '§' + sec; a.setAttribute('href', '#' + ref); return }
505      a.textContent = '??'
506    })
507  }
508
509  function ppToken(s, keepNl){
510    if (s == null) return ''
511    var out = String(s)
512      .replaceAll(/\u25b2[\u00b7\u237d\u205f]*(\p{L})/gu, (_, c) => c.toUpperCase())
513      .replaceAll(/\u2191[\u00b7\u237d\u205f]*(\p{L})/gu, (_, c) => c.toUpperCase())
514      .replaceAll(/\u21ea[\u00b7\u237d\u205f]*(\p{L}+)/gu, (_, w) => w.toUpperCase())
515      .replaceAll(/[\u25b2\u2191\u21ea]/gu, '')
516      .replaceAll(/[\u237d\u2028\u205f\u00b7]/gu, ' ')
517    return keepNl ? out : out.replaceAll('\n', '\u23ce')
518  }
519
520  // ranks above the cap are not informative
521  function ppRank(r, cap=999){ return r >= cap ? cap + '+' : r }
522
523  // ppToken for cell/chip display: spaces visible, never empty
524  function ppCell(s){
525    var t = ppToken(s)
526    return t.trim() ? t : t.replaceAll(' ', '\u2423').replaceAll('\u23ce', '\u23ce') || '\u2205'
527  }
528
529  // ───── lens-slice shared (lens-slice + lens-inline) ─────
530  // {slug}.json: {slug, title, ctxTokens:[str], n_layers, default_ctx}
531  // {slug}.parquet: rows of {layer, ctx, rank, str, prob} \u2014 long format, rank 0 = argmax
532  async function loadLensSlice(base, slug){
533    var [d, rows] = await Promise.all([getFile(base + slug + '.json'), getFile(base + slug + '.parquet')])
534    d.layers = d.layers || d3.range(d.n_layers)
535    d.nCtx = d.ctxTokens.length
536    var by = d3.group(rows, r => r.layer, r => r.ctx)
537    d.cell = (ctx, layer) => by.get(layer)?.get(ctx) || []
538    d.argmax = (ctx, layer) => d.cell(ctx, layer)[0]?.str
539    d.probOf = (ctx, layer, str) => (d.cell(ctx, layer).find(e => e.str === str)?.prob) || 0
540    var rankBy = {}
541    d.rankOf = (ctx, layer, str) => {
542      var m = rankBy[str]
543      if (m) return (m.get(layer)?.get(ctx)?.[0]?.rank ?? -1) + 1 || null
544      var e = d.cell(ctx, layer).find(e => e.str === str)
545      return e ? e.rank + 1 : null
546    }
547    // Per-token rank trajectory; no-op when the slug ships without rankFiles
548    // (lens-inline) so callers don't have to check.
549    d.loadRanks = async (str) => {
550      if (rankBy[str] || d.rankFiles?.[str] == null) return
551      var rr = await getFile(base + slug + '_ranks/' + d.rankFiles[str] + '.parquet')
552      rankBy[str] = d3.group(rr, r => r.layer, r => r.ctx)
553    }
554    return d
555  }
556
557  function pinnedStyle(color){
558    if (!color) return ''
559    return `background:${color}20;text-decoration:underline 2px ${color};text-underline-offset:2px`
560  }
561
562  // clickable prompt-token paragraph; returns the span selection
563  function appendPromptPara(sel, ctxTokens, {keepNl=true}={}){
564    return sel.appendMany('span.ptok', d3.range(ctxTokens.length))
565      .each(function(i){
566        var s = ppToken(ctxTokens[i], keepNl)
567        d3.select(this).text(s).classed('empty', !s)
568      })
569  }
570
571  // one row of lens top-k chips, fixed-k so hover never reflows
572  function appendLensTopk(sel, items, k=10){
573    return sel.appendMany('span.lenstk', d3.range(k))
574      .each(function(i){
575        var it = items[i]
576        d3.select(this).text(it ? ppCell(it.str) : '\u00a0')
577          .at({title: it ? ppCell(it.str) : null}).datum(it).classed('empty', !it)
578      })
579  }
580
581  // Scale a figure mount down when its natural width overflows the <figure>
582  // container. Works for SVG and div-based charts alike. Stacking via
583  // .panel-row/.figgrid is the first defense; this catches fixed-width
584  // d3.conventions output and any intermediate-width overflow (768\u20131100px).
585  function fitMount(mount){
586    var fig = mount.closest('figure')
587    if (!fig) return
588    // measure natural size with any prior scale/fit removed — fit-scaled's
589    // explicit width changes what container queries inside the mount resolve to.
590    // width:fit-content during the measure makes --mount-w track the rendered
591    // content (so figcaption max-width matches it), not the column the block
592    // mount happens to fill. Skip container-typed mounts: intrinsic sizing
593    // collapses them to 0.
594    var prev = mount.style.transform, prevW = mount.style.width
595    var prevH = mount.style.height, prevM = mount.style.marginLeft
596    var prevC = mount.classList.contains('fit-scaled')
597    mount.style.transform = 'none'
598    mount.classList.remove('fit-scaled')
599    // height/margin-left are fit outputs too — measuring with a stale clamp
600    // from the previous run makes consecutive measurements state-dependent.
601    mount.style.height = ''
602    mount.style.marginLeft = ''
603    if (getComputedStyle(mount).containerType == 'normal') mount.style.width = 'fit-content'
604    var natural = mount.scrollWidth
605    var naturalH = mount.scrollHeight
606    mount.style.width = prevW
607    // available space is the figure's content box, not clientWidth — the
608    // figure-render gallery card has 14px horizontal padding, and a scale
609    // computed against clientWidth leaves ~28px of overflow that the card's
610    // overflow-x:auto fallback turns into a scrollbar. (Paper figures have no
611    // padding, so this is a no-op there.)
612    var cs = getComputedStyle(fig)
613    var avail = fig.clientWidth
614      - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0)
615    var restore = () => {
616      mount.style.transform = prev
617      mount.style.height = prevH
618      mount.style.marginLeft = prevM
619      if (prevC) mount.classList.add('fit-scaled')
620    }
621    if (!natural || !avail) { restore(); return }
622    // Idempotence guard: this runs under a ResizeObserver watching the very
623    // <figure> the writes below resize, so re-applying on ≤1px measurement
624    // noise (integer scrollWidth vs fractional clientWidth) feeds back into
625    // an endless remeasure loop at fit-boundary widths. If the inputs moved
626    // ≤1px and the verdict is unchanged, keep the prior state untouched.
627    var f0 = mount.__fit, fits = natural <= avail
628    if (f0 && fits == f0.fits && Math.abs(natural - f0.natural) <= 1
629        && Math.abs(avail - f0.avail) <= 1 && Math.abs(naturalH - f0.naturalH) <= 1){
630      restore(); return
631    }
632    mount.__fit = {natural, naturalH, avail, fits}
633    fig.style.setProperty('--mount-w', natural + 'px')
634    if (natural <= avail){
635      mount.style.transform = ''
636      mount.style.height = ''
637      mount.style.marginLeft = ''
638      fig.style.overflowX = ''
639      mount.classList.remove('fit-scaled')
640      return
641    }
642    var min = +mount.dataset.minScale || 0
643    var s = Math.max(avail / natural, min)
644    mount.classList.add('fit-scaled')
645    mount.style.transform = `scale(${s})`
646    // collapse the dead vertical space the transform leaves behind
647    mount.style.height = (naturalH * s) + 'px'
648    // center the visual box — transform-origin is top-left and several figure
649    // mounts set max-width:100% (overriding .fit-scaled's max-width:none), so
650    // the layout box can resolve narrower than `natural`; scale then leaves a
651    // gap on the right. Read the actual box width and offset so the scaled
652    // visual sits mid-figure regardless of which width won.
653    var ow = mount.offsetWidth
654    mount.style.marginLeft = Math.max(0, (avail - ow * s) / 2) + 'px'
655    // floored scale still overflows: let the <figure> h-scroll instead of clipping
656    fig.style.overflowX = natural * s > avail + 1 ? 'auto' : ''
657  }
658
659  // Shared sliver schematic for intro-structural + layer-diagram: body box,
660  // residual column, manilla J-space band (same width as the sliver), and the
661  // right-side "J-space" label with a double-arrow spanning the full column.
662  function drawJSliver(svg, {bx0, bx1, H, capH=26, pad=14, jFrac=[0.20, 0.74], arrowId}){
663    var jEdge = d3.color(brand.kraft).darker(0.5).formatHex()
664    var cx = (bx0 + bx1)/2, bw = bx1 - bx0, gap = 8
665    var by0 = capH + gap, by1 = H - capH - gap
666    var rTop = by0 + pad, rBot = by1 - pad
667    var jTop = rTop + (rBot - rTop)*jFrac[0], jBot = rTop + (rBot - rTop)*jFrac[1]
668    var rr = (x, y, w, h, r, a) => svg.append('rect').at({x, y, width: w, height: h, rx: r, ry: r, ...a})
669    svg.append('defs').append('marker').at({id: arrowId, viewBox: '0 0 10 10', refX: 8, refY: 5,
670      markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse'})
671      .append('path').at({d: 'M1,1 L8,5 L1,9', fill: 'none', stroke: gray[400],
672        strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round'})
673    rr(bx0, 0, bw, capH, 4, {fill: gray[100], stroke: gray[200], strokeWidth: 1})
674    rr(bx0, H - capH, bw, capH, 4, {fill: gray[100], stroke: gray[200], strokeWidth: 1})
675    svg.append('text.io').text('Output').translate([cx, capH/2]).at({textAnchor: 'middle', dy: '.35em'})
676    svg.append('text.io').text('Input').translate([cx, H - capH/2]).at({textAnchor: 'middle', dy: '.35em'})
677    rr(bx0, by0, bw, by1 - by0, 12, {fill: gray[100], stroke: gray[300], strokeWidth: 1})
678    rr(cx - 7, rTop, 14, rBot - rTop, 7, {fill: gray[300], opacity: .65})
679    rr(cx - 7, jTop, 14, jBot - jTop, 4, {fill: brand.manilla})
680    rr(cx - 7, jTop, 14, jBot - jTop, 4, {fill: 'none', stroke: jEdge, strokeWidth: 1.1, strokeDasharray: '4 3'})
681    var jx = cx + 18
682    svg.append('line').at({x1: jx, y1: rTop + 2, x2: jx, y2: rBot - 2,
683      stroke: gray[400], strokeWidth: 1, markerStart: `url(#${arrowId})`, markerEnd: `url(#${arrowId})`})
684    svg.append('text.jspace').text('J-space')
685      .at({transform: `translate(${jx + 11},${(rTop + rBot)/2}) rotate(-90)`, textAnchor: 'middle'})
686    return {cx, bx0, bx1, by0, by1, rTop, rBot, jTop, jBot, sliverL: cx - 7}
687  }
688
689  var __fitRO
690  function fitFigures(){
691    var mounts = () => document.querySelectorAll('figure > div[class]')
692    mounts().forEach(fitMount)
693    if (!__fitRO) __fitRO = new ResizeObserver(
694      throttleDebounce(() => mounts().forEach(fitMount), 100))
695    mounts().forEach(m => { var f = m.closest('figure'); if (f) __fitRO.observe(f) })
696  }
697
698  return {
699    params, safeRelPath, initRenderAll, attachRenderAllHistory,
700    loadParquet, cachedParquet, loadNpy, getFile,
701    addAxisLabel, ggPlot, ggPlotUpdate, addWsBand, layerTicks, layerLabel, DISPLAY_LAYERS, drawJSliver,
702    gray, tol, accent, brand, series, pos, neg, modelColors, lensColors, ablationColors, jspaceColors, space, tint, activationBg, barWhisker,
703    directLabel, spreadLabels, bindSeriesHover, nearestHover, dragTune, dragTuneLabels,
704    rankColor, lineColors, drawColorbar,
705    throttle, debounce, throttleDebounce, sleep, cache, resolveFigRefs, panelCaption, colorCaption,
706    ppToken, ppCell, ppRank, loadLensSlice, appendPromptPara, appendLensTopk, pinnedStyle,
707    rankColorCapped, fitMount, fitFigures,
708  }
709})()
710
711window.init?.()
712
713// ─── public/intro-functional/init-intro-functional.js ───
714// Figure 1 — five functional properties of a global workspace.
715// Pure illustration: hardcoded content, no data fetch.
716window.initIntroFunctional = function(opts){
717  var sel = d3.select(opts?.sel || '.intro-functional').html('')
718
719  var arr = `<span class='if-arr'>→</span>`
720  var cards = [
721    {
722      letter: 'A', title: 'Verbal report',
723      q: '“What are you thinking about?”',
724      op: 'swap',
725      thought: {kind: 'swap', from: 'banana', to: 'elephant'},
726      out: {kind: 'lines', from: '…about a banana.', to: '…about an elephant.'},
727    },
728    {
729      letter: 'B', title: 'Directed modulation',
730      q: 'Compute 3² − 2 while writing<br>“The old painting hung…”',
731      op: null,
732      thought: {kind: 'chain', steps: ['math', 'calc', 'nine', 'seven', 'equals']},
733      out: {kind: 'plain', text: 'The old painting hung crookedly on the wall.'},
734    },
735    {
736      letter: 'C', title: 'Internal reasoning',
737      q: '“What color is the planet fourth from the sun?”',
738      op: 'swap',
739      thought: {kind: 'swap', from: 'Mars', to: 'Earth'},
740      out: {kind: 'lines', from: 'red', to: 'blue'},
741    },
742    {
743      letter: 'D', title: 'Flexible generalization',
744      q: '“What is the capital of France?”',
745      op: 'swap',
746      thought: {kind: 'swap', from: 'France', to: 'China'},
747      out: {kind: 'table', rows: [
748        ['capital', 'Paris', 'Beijing'],
749        ['language', 'French', 'Chinese'],
750        ['continent', 'Europe', 'Asia'],
751        ['currency', 'Euro', 'Yuan'],
752      ]},
753    },
754    {
755      letter: 'E', title: 'Selectivity',
756      q: '<em>(a battery of tasks)</em>',
757      op: 'ablate',
758      thought: {kind: 'ablate', text: 'task plan step<br>reason answer'},
759      out: {kind: 'check', title: 'Doable without workspace', rows: [
760        ['parse inputs', true],
761        ['recall facts', true],
762        ['speak fluently', true],
763        ['reason internally', false],
764        ['make complex inferences', false],
765      ]},
766    },
767  ]
768
769  var rows = [sel.append('div.if-row.if-row-3'), sel.append('div.if-row.if-row-2')]
770  cards.forEach((d, i) => {
771    var cell = rows[i < 3 ? 0 : 1].append('div.if-cell')
772    cell.append('div.if-hdr.fig-title').text(d.title)
773    var card = cell.append('div.card.light.if-card')
774    card.append('div.if-q.prompt-block').html(d.q)
775
776    var tw = card.append('div.if-mid').append('div.if-thoughtwrap')
777    if (d.op) tw.append('div.if-op.mlabel').text(d.op)
778    var th = tw.append('div.if-thought').classed('if-thought-ablated', d.thought.kind == 'ablate')
779    if (d.thought.kind == 'swap'){
780      th.append('div.if-from').text(d.thought.from)
781      th.append('div.if-to').text(d.thought.to)
782    } else if (d.thought.kind == 'chain'){
783      th.classed('if-thought-chain', true)
784      // meandering chain: tokens at irregular y, curvy connectors with arrowheads
785      var svg = th.append('svg.if-chain-svg')
786      svg.append('defs').html(
787        `<marker id='if-arrhead' viewBox='0 0 6 6' refX='5' refY='3' ` +
788        `markerWidth='5' markerHeight='5' orient='auto'>` +
789        `<path d='M0,0 L6,3 L0,6' fill='none' stroke='var(--text-faint)' stroke-width='1'/></marker>`)
790      var xs = [14, 53, 94, 138, 186], ys = [30, 8, 38, 11, 33]
791      var bend = [-8, 7, -9, 6]
792      // half-word-width + 2px gap so arrow tail/head clear the text on both ends
793      var hw = d.thought.steps.map(s => s.length * 3.3 + 2)
794      var arrD = j => {
795        var x0 = xs[j] + hw[j], y0 = ys[j] - 4, x1 = xs[j+1] - hw[j+1], y1 = ys[j+1] - 4
796        var mx = (x0 + x1)/2, my = (y0 + y1)/2 + bend[j]
797        return `M${x0},${y0} Q${mx},${my} ${x1},${y1}`
798      }
799      var toks = svg.appendMany('text.if-chain-tok', d.thought.steps)
800        .at({x: (_, j) => xs[j], y: (_, j) => ys[j], textAnchor: 'middle'}).text(String)
801      var arrs = svg.appendMany('path.if-chain-arr', d3.range(d.thought.steps.length - 1))
802        .at({markerEnd: 'url(#if-arrhead)', d: arrD})
803      util.dragTune(toks.nodes().map((n, j) => ({
804        sel: n, key: d.thought.steps[j],
805        get: () => ({x: xs[j], y: ys[j]}),
806        set: p => { xs[j] = p.x; ys[j] = p.y; d3.select(n).at({x: p.x, y: p.y}); arrs.at({d: arrD}) },
807      })), 'chain')
808    } else if (d.thought.kind == 'ablate'){
809      th.append('div.if-from').html(d.thought.text)
810    }
811    // Clawd left of the speech bubble; thought-bubble tail circles drop from
812    // .if-thoughtwrap's bottom-left down to his head.
813    var bot = card.append('div.if-bottom')
814    bot.append('div.if-robot').html(
815      `<svg width="32" height="25" viewBox="0 0 66 52" xmlns="http://www.w3.org/2000/svg">` +
816      `<path fill="var(--brand-clay)" d="M60 13H66V26H60V52H54V39H48V52H42V39H24V52H18V39H12V52H6V26H0V13H6V0H60V13Z"/>` +
817      `<rect x="12" y="13" width="6" height="6" fill="var(--gray-900)"/>` +
818      `<rect x="48" y="13" width="6" height="6" fill="var(--gray-900)"/>` +
819      `</svg>`)
820
821    var out = bot.append('div.if-out')
822    var role = `<span class='prompt-role'>Assistant:</span> `
823    if (d.out.kind == 'lines'){
824      out.html(`${role}<span class='if-out-from'>${d.out.from}</span> ` +
825               `<span class='if-out-to'>${d.out.to}</span>`)
826    } else if (d.out.kind == 'plain'){
827      out.html(`${role}${d.out.text}`)
828    } else if (d.out.kind == 'table'){
829      out.append('div.if-ttable').appendMany('div.if-trow', d.out.rows).html(r =>
830        `<span class='if-tk'>${r[0]}</span>${arr}` +
831        `<span class='if-out-from'>${r[1]}</span>` +
832        `<span class='if-out-to'>${r[2]}</span>`)
833    } else if (d.out.kind == 'check'){
834      if (d.out.title) out.append('div.if-ctitle').text(d.out.title)
835      var cols = out.append('div.if-ccols')
836      ;[true, false].forEach(ok => cols.append('div.if-ccol')
837        .appendMany('div.if-crow', d.out.rows.filter(r => r[1] == ok)).html(r =>
838          `<span class='if-cmark ${r[1] ? 'ok' : 'nope'}'>${r[1] ? '✓' : '✗'}</span>` +
839          `<span class='if-ctxt'>${r[0]}</span>`))
840    }
841  })
842}
843
844window.init?.()
845
846// ─── public/intro-structural/init-intro-structural.js ───
847window.initIntroStructural = function(opts={}){
848  var sel = d3.select('.intro-structural').html('')
849  if (!sel.size()) return
850
851  // Visual vocab: paper shared styles. Gray ramp for structure; brand-manilla
852  // marks the J-space (same color as `.ws-band` on the line charts) so the
853  // reader recognizes "this is the workspace" everywhere it appears.
854  var gray     = util.gray
855  var manilla  = util.brand.manilla
856  var jBand    = manilla
857  var jEdge    = d3.color(util.brand.kraft).darker(0.5).formatHex()
858  var jDot     = d3.color(util.brand.kraft).darker(0.45).formatHex()
859  var soft     = gray[100]            // var(--bg-soft)
860  var softEdge = gray[300]
861
862  function rrect(g, x, y, w, h, r, attrs){
863    return g.append('rect').at({x, y, width: w, height: h, rx: r, ry: r, ...attrs})
864  }
865  function brace(g, x, y0, y1, bw){
866    // `{` opening right — curl ends on the box edge, point sticks left toward text.
867    var ym = (y0 + y1) / 2
868    g.append('path.brace').at({fill: 'none', stroke: gray[400], d:
869      `M ${x+bw},${y0} Q ${x},${y0} ${x},${y0+bw} V ${ym-bw}` +
870      ` Q ${x},${ym} ${x-bw},${ym} Q ${x},${ym} ${x},${ym+bw}` +
871      ` V ${y1-bw} Q ${x},${y1} ${x+bw},${y1}`
872    })
873  }
874
875  var diag   = sel.append('div.is-diagram')
876  var panels = sel.append('div.is-panels')
877
878  // ───── left: vertical layer diagram ─────
879  drawLayerDiagram(diag)
880  function drawLayerDiagram(host){
881    var W = 360, H = 580
882    var svg = host.append('svg').at({width: W, height: H, viewBox: `0 0 ${W} ${H}`})
883
884    var g = util.drawJSliver(svg, {bx0: 124, bx1: 252, H, capH: 28, pad: 16, arrowId: 'is-arrow'})
885    var {cx, bx0, bx1, rTop, rBot, jTop, jBot, sliverL} = g
886
887    // left annotations — motor/sensory outside, workspace inside w/ dashed leader
888    var bwBrace = 8, tip = 3
889    var bxBrace = bx0 - bwBrace - tip, txtX = bxBrace - bwBrace - 8
890    function annoText(ym, lines){
891      var ty0 = ym - (lines.length - 1) * 8
892      var txt = svg.append('text').at({textAnchor: 'end', x: txtX})
893      lines.forEach((t, i) => txt.append(i === 0 ? 'tspan.anno-key' : 'tspan.anno-sub')
894        .text(t).at({x: txtX, y: ty0 + i*16, dy: '.35em'}))
895    }
896    ;[{y0: rTop, y1: jTop, key: 'Motor', sub: ['determining', 'the output']},
897      {y0: jBot, y1: rBot, key: 'Sensory', sub: ['input parsing,', 'preprocessing']}]
898      .forEach(a => {
899        brace(svg, bxBrace, a.y0 + tip, a.y1 - tip, bwBrace)
900        annoText((a.y0 + a.y1)/2, [a.key, ...a.sub])
901      })
902    var wym = (jTop + jBot) / 2, ibx = sliverL - bwBrace - tip
903    brace(svg, ibx, jTop + tip, jBot - tip, bwBrace)
904    svg.append('line').at({x1: ibx - bwBrace, y1: wym, x2: txtX + 4, y2: wym,
905      stroke: gray[400], strokeWidth: 1, strokeDasharray: '3 3'})
906    annoText(wym, ['Workspace', 'lives here'])
907
908    // right: layer arcs (residual skip-connections, bottom → top)
909    var nArc = 8, ax = bx1 + 4, span = (rBot - rTop) / nArc, bulge = span * 1.45, arcGap = 9
910    d3.range(nArc).forEach(i => {
911      var yb = rTop + (i + 1) * span - arcGap/2, yt = rTop + i * span + arcGap/2
912      svg.append('path.layer-arc').at({
913        d: `M ${ax},${yb} C ${ax + bulge},${yb} ${ax + bulge},${yt} ${ax + 1},${yt + 1}`,
914        fill: 'none', stroke: gray[400], markerEnd: 'url(#is-arrow)',
915      })
916    })
917    svg.append('text.layers-lbl').text('Layers')
918      .at({x: ax + bulge*0.78 + 2, y: (rTop + rBot)/2, dy: '.35em'})
919  }
920
921  // ───── right: panels ─────
922  function panel(title, desc){
923    var c = panels.append('div.card')
924    c.append('div.fig-title').text(title)
925    c.append('div.desc').text(desc)
926    return c
927  }
928
929  panel('Intermediate processing stage',
930    'J-space carries workspace-like content only at intermediate depths')
931
932  var pB = panel('Limited capacity',
933    'Few concepts active at once; minority of activation variance; most features lie outside it')
934  drawCapacityIllo(pB.append('div.illo'))
935
936  var pC = panel('Broadcast format',
937    'J-lens vectors compose with many upstream output weights and downstream input weights')
938  drawBroadcastIllo(pC.append('div.illo'))
939
940  // ───── B: J-space as a small dashed sub-box inside one shared container ─────
941  function drawCapacityIllo(host){
942    var W = 380, H = 102, bH = 72
943    var svg = host.append('svg').at({viewBox: `0 0 ${W} ${H}`}).st({maxWidth: W + 'px'})
944    var pad = 8, sw = 72
945
946    function scatter(x0, y0, w, h, nx, ny, jit, seed){
947      var rng = d3.randomLcg(seed), cw = w/nx, ch = h/ny, pts = []
948      d3.range(nx*ny).forEach(k => {
949        var i = k % nx, j = (k - i)/nx
950        pts.push([x0 + (i + .5 + (rng() - .5)*jit)*cw,
951                  y0 + (j + .5 + (rng() - .5)*jit)*ch])
952      })
953      return pts
954    }
955
956    // shared outer container
957    rrect(svg, 0, 0, W, bH, 4, {fill: soft, stroke: softEdge, strokeWidth: 1})
958    svg.appendMany('circle', scatter(pad + sw + 10, 8, W - pad - sw - 20, bH - 16, 17, 4, 1.1, 53))
959      .at({cx: d => d[0], cy: d => d[1], r: 2.6, fill: gray[400]})
960    // J-space dashed sub-box inset on the left
961    rrect(svg, pad, pad, sw, bH - 2*pad, 4, {fill: jBand})
962    svg.appendMany('circle', scatter(pad + 7, pad + 7, sw - 14, bH - 2*pad - 14, 5, 3, 1.1, 11))
963      .at({cx: d => d[0], cy: d => d[1], r: 3, fill: jDot})
964    rrect(svg, pad, pad, sw, bH - 2*pad, 4, {fill: 'none', stroke: jEdge, strokeWidth: 1.1, strokeDasharray: '4 3'})
965
966    svg.append('text.illo-key').text('J-space').at({x: pad + sw/2, y: bH + 20, textAnchor: 'middle'})
967    svg.append('text').text('Other representational features')
968      .at({x: pad + sw + (W - pad - sw)/2, y: bH + 20, textAnchor: 'middle'})
969  }
970
971  // ───── C: fan-in / fan-out broadcast hub ─────
972  function drawBroadcastIllo(host){
973    var W = 420, H = 158
974    var svg = host.append('svg').at({viewBox: `0 0 ${W} ${H}`}).st({maxWidth: W + 'px'})
975    svg.append('defs').html(
976      `<marker id='is-arrow-o' viewBox='0 0 10 10' refX='7' refY='5' markerWidth='5.5' markerHeight='5.5' orient='auto'>` +
977      `<path d='M1,1 L8,5 L1,9' fill='none' stroke='${gray[500]}' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></marker>`
978    )
979
980    var n = 5, bw = 76, bh = 20, vgap = 6
981    var spanH = n * bh + (n - 1) * vgap, y0 = 4
982    var hx = W/2 - 32, hw = 64, hh = 36, hy = y0 + spanH/2 - hh/2
983    var rx = W - bw
984
985    d3.range(n).forEach(i => {
986      var by = y0 + i * (bh + vgap), cy = by + bh/2
987      var ht = hy + 5 + (hh - 10) * i/(n - 1)
988      rrect(svg, 0, by, bw, bh, 4, {fill: soft, stroke: softEdge, strokeWidth: 1})
989      rrect(svg, rx, by, bw, bh, 4, {fill: soft, stroke: softEdge, strokeWidth: 1})
990      svg.append('line').at({x1: bw + 2, y1: cy, x2: hx - 4, y2: ht,
991        stroke: gray[500], strokeWidth: 1, markerEnd: 'url(#is-arrow-o)'})
992      svg.append('line').at({x1: hx + hw + 2, y1: ht, x2: rx - 6, y2: cy,
993        stroke: gray[500], strokeWidth: 1, markerEnd: 'url(#is-arrow-o)'})
994    })
995    rrect(svg, hx, hy, hw, hh, 4, {fill: jBand})
996    rrect(svg, hx, hy, hw, hh, 4, {fill: 'none', stroke: jEdge, strokeWidth: 1.1, strokeDasharray: '4 3'})
997
998    svg.append('text.illo-key').text('Upstream').at({x: bw/2, y: y0 + spanH + 16, textAnchor: 'middle'})
999    svg.append('text.illo-key').text('circuits').at({x: bw/2, y: y0 + spanH + 30, textAnchor: 'middle'})
1000    svg.append('text.illo-key').text('Downstream').at({x: rx + bw/2, y: y0 + spanH + 16, textAnchor: 'middle'})
1001    svg.append('text.illo-key').text('circuits').at({x: rx + bw/2, y: y0 + spanH + 30, textAnchor: 'middle'})
1002  }
1003}
1004
1005window.init?.()
1006
1007// ─── public/ooo-heatmap/init-ooo-heatmap.js ───
1008// Canvas heatmap: number_tokens (x-axis) × layers (y-axis), colored by log10(rank).
1009// Wide-format rows: each row is one (x, layer) with token strings as columns.
1010window.initOooHeatmap = function({visState, renderAll, sel, cfg, byX}){
1011  var tokens = cfg.number_tokens
1012  var layers = cfg.layers || d3.range(cfg.n_layers)
1013  var nLayers = layers.length
1014  var rowOf = new Map(layers.map((l, i) => [l, i]))
1015  var cellW = tokens.length > 220 ? 2 : 3
1016  var w = tokens.length * cellW, h = 140
1017  var margin = {top: 14, right: 10, bottom: 24, left: 42}
1018
1019  var c = d3.conventions({sel, width: w, height: h, margin, layers: 'scs'})
1020  var ctx = c.layers[1]
1021  var svg = c.layers[2]
1022
1023  var cellH = c.height / nLayers
1024
1025  c.x.domain([-0.5, tokens.length - 0.5])
1026  c.y.domain([-0.5, nLayers - 0.5])
1027  c.xAxis.ticks(8).tickFormat(i => `"${util.ppToken(tokens[Math.round(i)])}"`).tickSizeOuter(0).tickSizeInner(4)
1028  // y-axis ticks at evenly-spaced row indices so they line up with the cell rects.
1029  var tickRows = d3.ticks(0, nLayers - 1, 4).map(Math.round)
1030  c.yAxis.tickValues(tickRows).tickFormat(i => util.layerLabel(layers[i])).tickSizeOuter(0).tickSizeInner(4)
1031  c.drawAxis()
1032  util.addAxisLabel(c, 'Token →', 'Layer (reindexed) →')
1033
1034  var nLabels = cfg.line_labels.length
1035  var markG = svg.append('g.value-marks')
1036
1037  function drawMarks(){
1038    var xi = cfg.xs.indexOf(visState.x)
1039    var vals = cfg.line_vals_per_x?.[xi] || []
1040    var marks = cfg.line_labels.map((lbl, j) => {
1041      var ti = tokens.indexOf(String(vals[j]))
1042      return ti < 0 ? null : {ti, color: (cfg.colors || util.lineColors)[(nLabels-1-j) % (cfg.colors || util.lineColors).length]}
1043    }).filter(Boolean)
1044    var s = markG.selectAll('path').data(marks)
1045    s.enter().append('path').merge(s)
1046      .at({d: d => `M${d.ti*cellW + cellW/2},-2 l-4,-7 l8,0 z`, fill: d => d.color, stroke: '#fff', strokeWidth: 0.5})
1047    s.exit().remove()
1048  }
1049  renderAll.x.fns.push(drawMarks)
1050
1051  var hoverRect = svg.append('rect').at({width: c.width, height: c.height, fill: 'transparent'})
1052  var dot = svg.append('circle').at({r: 3, fill: 'none', stroke: '#f0f', strokeWidth: 1, opacity: 0, pointerEvents: 'none'})
1053  var ttSel = d3.select('.tooltip')
1054
1055  var currentRows = []  // [n_layers] rows for current x, indexed by layer
1056
1057  hoverRect
1058    .on('mousemove', function(e){
1059      var [mx, my] = d3.pointer(e)
1060      var ti = Math.floor(mx / cellW)
1061      var ri = nLayers - 1 - Math.floor(my / cellH)
1062      var inBounds = ti >= 0 && ti < tokens.length && ri >= 0 && ri < nLayers
1063      if (!inBounds) return hide()
1064      dot.at({cx: ti * cellW + cellW/2, cy: (nLayers - 1 - ri) * cellH + cellH/2, opacity: 1})
1065      var r = currentRows[ri]?.[tokens[ti]]
1066      ttSel.html(`at L${util.layerLabel(layers[ri])} "${util.ppToken(tokens[ti])}" is rank ${r != null ? util.ppRank(r, cfg.rank_cap || 9999) : '?'}`)
1067        .classed('tooltip-hidden', 0)
1068        .st({left: e.clientX + 12, top: e.clientY + 12})
1069    })
1070    .on('mouseleave', hide)
1071  function hide(){ dot.at({opacity: 0}); ttSel.classed('tooltip-hidden', 1) }
1072
1073  function draw(){
1074    var rows = byX.get(visState.x) || []
1075    currentRows = new Array(nLayers)
1076    ctx.clearRect(0, 0, c.width, c.height)
1077    for (var r of rows){
1078      var ri = rowOf.get(r.layer)
1079      if (ri == null) continue
1080      currentRows[ri] = r
1081      var y = (nLayers - 1 - ri) * cellH
1082      for (var ti = 0; ti < tokens.length; ti++){
1083        var rank = r[tokens[ti]]
1084        if (!rank) continue
1085        ctx.fillStyle = util.rankColor(Math.log10(rank))
1086        ctx.fillRect(ti * cellW, y, cellW + 0.5, cellH + 0.5)
1087      }
1088    }
1089  }
1090  renderAll.x.fns.push(draw)
1091}
1092
1093window.init?.()
1094
1095// ─── public/ooo-heatmap/init-ooo-lines.js ───
1096// Line chart: layer → rank (log, reversed). One line per line_label (wide-format column).
1097window.initOooLines = function({visState, renderAll, sel, cfg, byX}){
1098  var nLayers = cfg.n_layers
1099  var labels = [...cfg.line_labels].reverse()
1100  var w = 320, h = 140
1101  var margin = {top: 14, right: 10, bottom: 24, left: 44}
1102
1103  var c = d3.conventions({sel, width: w, height: h, margin})
1104  c.x.domain([0, nLayers - 1]).rangeRound([0, c.width])
1105  c.y = d3.scaleLog().domain([10000, 1]).rangeRound([c.height, 0])
1106  c.xAxis.tickValues(util.layerTicks(nLayers)).tickFormat(util.layerLabel)
1107  c.yAxis = d3.axisLeft(c.y).ticks(4, '~s').tickFormat(d => d >= 9999 ? '10k+' : d3.format('~s')(d))
1108  c.drawAxis()
1109  util.ggPlot(c)
1110  util.addAxisLabel(c, 'Layer (reindexed) →', cfg.yLabel || 'J-Lens rank →')
1111
1112  var colors = cfg.colors || util.lineColors
1113  var line = d3.line().x(d => c.x(d.layer)).y(d => c.y(Math.max(1, d.rank)))
1114
1115  var pathSel = c.svg.appendMany('path.line', labels)
1116    .at({fill: 'none', strokeWidth: 1.5, stroke: (d, i) => colors[i % colors.length]})
1117
1118  var dotSel = c.svg.appendMany('g.dots', labels)
1119    .at({fill: (d, i) => colors[i % colors.length]})
1120
1121  function draw(){
1122    var rows = (byX.get(visState.x) || []).slice().sort((a, b) => a.layer - b.layer)
1123    var xi = cfg.xs.indexOf(visState.x)
1124    var valStrs = cfg.line_vals_per_x?.[xi] || []  // what value each label resolves to at this x
1125
1126    labels.forEach((lbl, i) => {
1127      var col = cfg.colFor ? cfg.colFor(lbl, xi) : lbl
1128      var pts = rows.map(r => ({layer: r.layer, rank: r[col]})).filter(d => d.rank > 0)
1129      pathSel.filter(d => d === lbl).at({d: pts.length ? line(pts) : ''})
1130
1131      var g = dotSel.filter(d => d === lbl)
1132      var dots = g.selectAll('circle').data(pts)
1133      dots.enter().append('circle').at({r: 2}).merge(dots)
1134        .at({cx: d => c.x(d.layer), cy: d => c.y(Math.max(1, d.rank))})
1135        .each(function(d){ d.label = lbl; d.value = valStrs[cfg.line_labels.indexOf(lbl)] })
1136        .call(d3.attachTooltip, null, [
1137          d => `${d.label} = ${d.value}`,
1138          d => `at L${util.layerLabel(d.layer)} is rank ${util.ppRank(d.rank, cfg.rank_cap || 9999)}`,
1139        ])
1140      dots.exit().remove()
1141    })
1142  }
1143  renderAll.x.fns.push(draw)
1144}
1145
1146window.init?.()
1147
1148// ─── public/ooo-abcd/init-ooo-abcd.js ───
1149window.abcdState = window.abcdState || {active: 'A', val: 4}
1150window.abcdRedrawFns = window.abcdRedrawFns || []
1151
1152// shared meta + parquet loader (both figures use the same data)
1153async function abcdShared(base, layerFilter){
1154  var fkey = layerFilter ? [...layerFilter].sort((a, b) => a - b).join(',') : ''
1155  if (window.__abcd?.base == base && window.__abcd?.fkey == fkey) return window.__abcd
1156  var meta = await util.getFile(base + 'meta.json?' + Date.now())
1157  if (!layerFilter && meta.layers) layerFilter = new Set(meta.layers)
1158  if (layerFilter){
1159    var ls = [...layerFilter].sort((a, b) => a - b)
1160    // leave cfg.n_layers at the full extent so the line chart's x-axis stays
1161    // 0..24; only the heatmap reads cfg.layers (compact rows).
1162    meta = {...meta, layers: ls,
1163      cfgs: Object.fromEntries(Object.entries(meta.cfgs || {}).map(([k, v]) => [k, {...v, layers: ls}]))}
1164  }
1165  var pq = {}
1166  async function loadVar(v, probe){
1167    var k = v + '/' + probe
1168    if (!pq[k]) pq[k] = util.loadParquet(base + `${v}/${meta.slug}/${probe}.parquet`)
1169      .then(rows => d3.group(layerFilter ? rows.filter(d => layerFilter.has(d.layer)) : rows, d => d.x))
1170    return pq[k]
1171  }
1172  return window.__abcd = {base, fkey, meta, loadVar}
1173}
1174
1175function abcdHeader(sel, titleText, meta, colors){
1176  var hdr = sel.append('div.abcd-title')
1177  hdr.append('span.fig-title').text(titleText)
1178  if (!meta.vars?.length) return
1179  var expr = hdr.append('span.expr')
1180  var scrubs = {}, stacks = {}, ops = []
1181  ;['calc: ( ', 'A', ' + ', 'B', ' ) * ', 'C', ' + ', 'D', ' ='].forEach(part => {
1182    if (!meta.vars.includes(part)) return ops.push(expr.append('span.op').text(part).node())
1183    var v = part, [lo, hi] = meta.ranges?.[v] || [1, 10], dft = meta.defaults[v]
1184    var cur = v == abcdState.active ? abcdState.val : dft
1185    var stack = expr.append('span.scrub-stack')
1186    stacks[v] = stack.node()
1187    var num = stack.append('span.scrub').at({tabindex: 0}).datum({v, lo, hi, dft}).text(cur)
1188    var slider = stack.append('input').at({type: 'range', min: lo, max: hi, value: cur, step: 1, tabindex: -1})
1189      .on('input', function(){ setVar(v, +this.value) })
1190    scrubs[v] = {num, slider, lo, hi, dft}
1191  })
1192
1193  // operand brackets under the sliders: each intermediate's span, in its line color
1194  function drawBrackets(){
1195    if (!colors || !stacks.A) return
1196    var er = expr.node().getBoundingClientRect()
1197    if (!er.width) return
1198    var bk = expr.selectAll('.op-brackets').data([0])
1199    bk = bk.enter().append('div.op-brackets').merge(bk)
1200    // ops[0]='calc: ( ', ops[2]=' ) * '. Innermost bracket spans the parens;
1201    // each outer bracket steps 3px further left so the three nest visibly.
1202    var ch = ops[0].getBoundingClientRect().width / ops[0].textContent.length
1203    var x0base = ops[0].getBoundingClientRect().right - er.left - 2 * ch  // back over '( '
1204    var x1paren = ops[2] ? ops[2].getBoundingClientRect().left - er.left + 2 * ch : null  // through ' )'
1205    var spans = [['B', x1paren], ['C'], ['D']]
1206      .filter(([b]) => stacks[b])
1207      .map(([b, x1e], i) => {
1208        var x0 = x0base - i * 3
1209        var x1 = x1e ?? (stacks[b].getBoundingClientRect().right - er.left)
1210        return {left: x0, width: x1 - x0, bottom: i * 4, color: colors[i % colors.length]}
1211      })
1212    var s = bk.selectAll('.op-bracket').data(spans)
1213    s.enter().append('div.op-bracket').merge(s)
1214      .st({left: d => d.left, width: d => d.width, bottom: d => d.bottom, borderColor: d => d.color})
1215    s.exit().remove()
1216  }
1217  abcdRedrawFns.push(drawBrackets)
1218  d3.select(window).on('resize.abcdBrackets', drawBrackets)
1219  requestAnimationFrame(drawBrackets)
1220  var redrawAll = util.throttleDebounce(() => abcdRedrawFns.forEach(f => f()), 60)
1221  function sync(){
1222    meta.vars.forEach(u => {
1223      var x = u == abcdState.active ? abcdState.val : scrubs[u].dft
1224      scrubs[u].num.text(x); scrubs[u].slider.node().value = x
1225    })
1226  }
1227  function setVar(v, val){
1228    var s = scrubs[v]
1229    val = Math.max(s.lo, Math.min(s.hi, val))
1230    if (abcdState.active == v && abcdState.val == val) return
1231    abcdState.active = v; abcdState.val = val
1232    redrawAll()
1233  }
1234  abcdRedrawFns.push(sync)
1235  var previewing = null
1236  function revertPreview(){
1237    if (!previewing) return
1238    var p = previewing; previewing = null
1239    expr.selectAll('.scrub').classed('dragging', false)
1240    setVar(p.active, p.val)
1241  }
1242  d3.select(window).on('keyup.abcdPreview', ev => { if (ev.key == 'Shift') revertPreview() })
1243  expr.selectAll('.scrub')
1244    .on('pointerdown', function(ev, d){
1245      ev.preventDefault()
1246      previewing = null
1247      var startX = ev.clientX, startVal = +this.textContent
1248      this.setPointerCapture(ev.pointerId)
1249      d3.select(this).classed('dragging', true)
1250        .on('pointermove', ev2 => setVar(d.v, startVal + Math.round((ev2.clientX - startX)/(ev2.shiftKey ? 8 : 2))))
1251        .on('pointerup pointercancel', function(ev2){
1252          this.releasePointerCapture(ev2.pointerId)
1253          d3.select(this).classed('dragging', false).on('pointermove pointerup pointercancel', null)
1254        })
1255    })
1256    .on('mousemove', function(ev, d){
1257      if (!ev.shiftKey || ev.buttons) return
1258      if (!previewing) previewing = {active: abcdState.active, val: abcdState.val}
1259      var box = this.getBoundingClientRect(), [lo, hi] = meta.ranges[d.v]
1260      setVar(d.v, lo + Math.round((ev.clientX - box.left)/box.width * (hi - lo)))
1261      d3.select(this).classed('dragging', true)
1262    })
1263    .on('mouseleave', revertPreview)
1264    .on('keydown', function(ev, d){
1265      var dx = ev.key == 'ArrowLeft' ? -1 : ev.key == 'ArrowRight' ? 1 : 0
1266      if (!dx) return
1267      ev.preventDefault()
1268      setVar(d.v, +this.textContent + dx * (ev.shiftKey ? 5 : 1))
1269    })
1270}
1271
1272function drawProbeRow(mount, {visState, renderAll, cfg, byX, withLegend, yLabel, colors}){
1273  colors = colors || util.lineColors
1274  var hmCol = mount.append('div')
1275  var hmSel = hmCol.append('div.heatmap')
1276  var lnCol = mount.append('div')
1277  var lnSel = lnCol.append('div.lines')
1278  initOooHeatmap({visState, renderAll, sel: hmSel, cfg: {...cfg, colors}, byX})
1279  initOooLines({visState, renderAll, sel: lnSel, cfg: {...cfg, yLabel, colors}, byX})
1280  if (!withLegend) return
1281  util.drawColorbar(hmCol.append('div.heatmap-legend'))
1282  var lnLeg = lnCol.append('div.line-legend')
1283  var n = cfg.line_labels.length
1284  var legendItems = lnLeg.appendMany('div.legend-item', d3.range(n).reverse())
1285    .each(function(i){
1286      d3.select(this).append('div.legend-swatch').st({background: colors[(n-1-i) % colors.length]})
1287      d3.select(this).append('span.lbl').text(cfg.line_labels[i] + '=')
1288      d3.select(this).append('span.val')
1289    })
1290  renderAll.x.fns.push(() => {
1291    var xi = cfg.xs.indexOf(visState.x)
1292    var vals = cfg.line_vals_per_x?.[xi] || []
1293    legendItems.select('span.val').text(i => vals[i] ?? '?')
1294  })
1295}
1296
1297window.initOooAbcd = async function(opts){
1298  var sel = d3.select('.ooo-abcd').html('')
1299  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1300  var {meta, loadVar} = await abcdShared(base, opts?.layerFilter)
1301  var renderAll = util.initRenderAll(['x'])
1302  var visState = {x: abcdState.val}
1303
1304  new IntersectionObserver((es, o) => {
1305    if (!es.some(e => e.isIntersecting)) return
1306    var probes = meta.probes || ['resid']
1307    meta.vars.forEach(v =>
1307 probes.forEach(p => loadVar(v, p)))
1308    o.disconnect()
1309  }).observe(sel.node())
1310
1311  var colors = [util.gray[500], util.series[4], util.series[2]]  // warm orange/brown — distinct from each other, don't blend with viridis heatmap; match ooo-patch
1312
1313  abcdRedrawFns.length = 0
1314  abcdHeader(sel, 'Residual-Stream J-Lens Rank', meta, colors)
1315
1316  var rowMount = sel.append('div.probe-row')
1317
1318  async function draw(){
1319    var v = abcdState.active, cfg = meta.cfgs[v]
1320    visState.x = abcdState.val
1321    renderAll.x.fns = []
1322    var byX = await loadVar(v, 'resid')
1323    rowMount.html('')
1324    drawProbeRow(rowMount, {visState, renderAll, cfg, byX, withLegend: true, yLabel: 'Residual-stream J-Lens rank →', colors})
1325    renderAll.x()
1326  }
1327
1328  abcdRedrawFns.push(draw)
1329  await draw()
1330}
1331
1332window.init?.()
1333
1334// ─── public/ooo-patch/init-ooo-patch.js ───
1335window.initOooPatch = async function(opts){
1336  var sel = d3.select('.ooo-patch').html('')
1337  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1338  var model = opts?.state?.model || 'opus-4.5'
1339  var d = await util.getFile(base + model + '.json')
1340
1341  sel.append('div.op-title').append('span.ttl').text('Patching intermediate values localizes to the same layers')
1342  var row = sel.append('div.panel-row')
1343  var W = 220, H = 140, M = {top: 16, right: 10, bottom: 24, left: 44}
1344
1345  var allDiff = d.cols.flatMap(col => d.panels[col].diff.flat())
1346  var yExt = d3.extent(allDiff)
1347  var yPad = (yExt[1] - yExt[0]) * 0.04
1348
1349  // s = [textX, textY, ax, ay, bx, by, cx, cy]  arc a→c through b, arrowhead at c
1350  var annos = {
1351    inter1: {L: 17, text: `Mean patching A+B at L${util.layerLabel(17)}\nusually flips the answer`, s: [-136,-42, -40,-22, -30,  0, -13,  0]},
1352    inter2: {L: 19, text: '(A+B)*C flips\n~10% later',                          s: [-140,-35, -45,-37, -23,-32, -10,-21]},
1353    ans:    {L: 22, text: 'and the answer itself\nin the final layers',         s: [-167, -5, -80, 14, -57, 33, -14, 12]},
1354  }
1355  var dragOn = new URLSearchParams(location.search).get('drag')
1356
1357  var panelColor = {inter1: util.gray[500], inter2: util.series[4], ans: util.series[2]}  // warm orange/brown — distinct from each other, don't blend with viridis heatmap; match ooo-abcd
1358
1359  d.cols.forEach((col, ci) => {
1360    var p = d.panels[col]
1361    var pc = panelColor[col]
1362    var cell = row.append('div.op-cell')
1363
1364    var c = d3.conventions({sel: cell.append('div.op-chart'), width: W, height: H, margin: M})
1365    d3.select(c.svg.node().ownerSVGElement).append('defs').append('marker')
1366      .at({id: 'oop-arrow', viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: 'auto'})
1367      .append('path').at({d: 'M 0 0 L 10 5 L 0 10 z', fill: '#333'})
1368    c.x.domain([0, d.n_layers - 1]).rangeRound([0, c.width])
1369    c.y.domain([yExt[0] - yPad, yExt[1] + yPad]).nice()
1370    c.xAxis.tickValues(util.layerTicks(d.n_layers)).tickFormat(util.layerLabel)
1371    c.yAxis.ticks(5)
1372    c.drawAxis()
1373    util.ggPlot(c)
1374    util.addAxisLabel(c, ci == 1 ? 'Layer (reindexed) →' : '', ci == 0 ? `Patched logit − "${d.orig_ans}" logit →` : '')
1375    c.svg.append('text.panel-title').at({x: c.width/2, y: -5, textAnchor: 'middle'}).text(p.expr)
1376
1377    var line = d3.line().x((_, i) => c.x(i)).y(v => c.y(v))
1378    var ttSel = d3.select('.tooltip')
1379    var expr = p.expr
1380
1381    var tgtG = c.svg.append('g')
1382    var paths = tgtG.appendMany('path.tgt', p.targets)
1383      .at({d: (_, i) => line(p.diff[i]), fill: 'none', stroke: pc, strokeWidth: 0.7, opacity: 0.25})
1384
1385    var hi = -1
1386    c.svg.append('rect.hover')
1387      .at({width: c.width, height: c.height, fill: 'none', pointerEvents: 'all'})
1388      .on('mousemove', function(ev){
1389        var [mx, my] = d3.pointer(ev, c.svg.node())
1390        var L = d3.clamp(0, Math.round(c.x.invert(mx)), d.n_layers - 1)
1391        var yv = c.y.invert(my)
1392        var ti = d3.minIndex(p.diff, s => Math.abs(s[L] - yv))
1393        if (ti !== hi){
1394          if (hi >= 0) paths.filter((_, i) => i === hi).at({strokeWidth: 0.7, opacity: 0.25})
1395          paths.filter((_, i) => i === ti).raise().at({strokeWidth: 1.5, opacity: 1})
1396          hi = ti
1397        }
1398        var v = p.targets[ti]
1399        ttSel.classed('tooltip-hidden', false)
1400          .st({left: ev.clientX + 12, top: ev.clientY + 12})
1401          .html(`L${util.layerLabel(L)} · ${d3.format('+.2f')(p.diff[ti][L])}<br>${expr} = ${v} → ans ${p.new_ans[v]}`)
1402      })
1403      .on('mouseleave', function(){
1404        if (hi >= 0) paths.filter((_, i) => i === hi).at({strokeWidth: 0.7, opacity: 0.25})
1405        hi = -1
1406        ttSel.classed('tooltip-hidden', true)
1407      })
1408
1409    var a = annos[col]
1410    if (a) drawSwoopy(c, c.x(a.L), c.y(p.mean[a.L]), a, col)
1411  })
1412
1413  if (!d3.select('.tooltip').size()) d3.select('body').append('div.tooltip.tooltip-hidden')
1414
1415  function drawSwoopy(c, tx, ty, a, key){
1416    var s = a.s
1417    var g = c.svg.append('g.anno').translate([tx, ty])
1418    var path = g.append('path').at({stroke: '#000', fill: 'none', strokeWidth: 1, 'marker-end': 'url(#oop-arrow)'})
1419    var text = g.append('text').at({fontSize: 12, fill: '#000'})
1420    a.text.split('\n').forEach((ln, i) => text.append('tspan').at({x: 0, dy: i ? 12 : 0}).text(ln))
1421    function render(){
1422      text.translate([s[0], s[1]])
1423      path.at({d: arcThrough([s[2],s[3]], [s[4],s[5]], [s[6],s[7]])})
1424    }
1425    render()
1426    if (!dragOn) return
1427    g.appendMany('circle.drag-handle', [[0,1],[2,3],[4,5],[6,7]])
1428      .at({r: 7, fill: 'rgba(0,0,0,0)', stroke: '#333', strokeDasharray: '2 2', cursor: 'move'})
1429      .translate(d => [s[d[0]], s[d[1]]])
1430      .call(d3.drag()
1431        .subject((e,d) =>
1431 ({x: s[d[0]], y: s[d[1]]}))
1432        .on('drag', function(e, d){
1433          s[d[0]] = Math.round(e.x); s[d[1]] = Math.round(e.y)
1434          d3.select(this).translate([s[d[0]], s[d[1]]])
1435          render()
1436          console.log(Object.entries(annos).map(([k,v]) => `    ${k}: {L: ${v.L}, text: ${JSON.stringify(v.text)}, s: [${v.s.join(',')}]},`).join('\n'))
1437        }))
1438  }
1439  function dist(a,b){ return Math.hypot(a[0]-b[0], a[1]-b[1]) }
1440  function arcThrough(a, b, c){
1441    var A=dist(c,b), B=dist(b,a), C=dist(a,c)
1442    var ang = Math.acos((A*A + B*B - C*C)/(2*A*B))
1443    var K = .5*A*B*Math.sin(ang)
1444    if (!K) return 'M'+a+' L'+c
1445    var r = Math.round(A*B*C/4/K*1000)/1000
1446    var laf = +(Math.PI/2 > ang), saf = +((c[0]-a[0])*(b[1]-a[1])-(c[1]-a[1])*(b[0]-a[0]) < 0)
1447    return ['M',a,'A',r,r,0,laf,saf,c].join(' ')
1448  }
1449
1450}
1451
1452window.init?.()
1453
1454// ─── public/feature-examples/init-feature-examples.js ───
1455window.initFeatureExamples = async function(opts){
1456  var sel = d3.select('.feature-examples').html('')
1457  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1458  var d = await util.getFile(base + 'feature_examples.json?v=' + Date.now())
1459
1460  var orange = t => d3.interpolateOranges(0.1 + 0.55*t)
1461
1462  d.rows.forEach(row => {
1463    var col = sel.append('div.fe-col')
1464    var head = col.append('div.fe-colhead')
1465    head.append('div.fig-title').text(row.label)
1466    head.append('div.fe-colsub').text(row.sublabel)
1467
1468    col.appendMany('div.cell.fchip', row.chips).each(function(f){
1469      var s = d3.select(this)
1470      var hdr = s.append('div.fchip-head')
1471      hdr.append('span.fe-key').text('Label')
1472      hdr.append('span.clerp').text(f.clerp || '—').at({title: f.clerp || ''})
1473      hdr.append('span.kurt').text(`κ = ${(f.kurt - 3).toFixed(1)}`)
1474      var tk = s.append('div.toks')
1475      tk.append('span.fe-key').text('Top J-tokens')
1476      tk.appendMany('span.tok', f.top_tokens).text(util.ppToken)
1477      s.appendMany('div.exrow', f.examples)
1478        .at({title: ex => ex.map(t => util.ppToken(t.t)).join('')})
1479        .each(function(ex){
1480        if (!ex.length) return
1481        var max = d3.max(ex, t => t.a) || 1
1482        var pi = d3.maxIndex(ex, t => t.a)
1483        var row = d3.select(this)
1484        function paint(t){
1485          if (t.a <= 0.02*max) return
1486          var bg = orange(t.a/max)
1487          d3.select(this).st({background: bg, color: d3.hsl(bg).l < 0.6 ? '#fff' : null})
1488        }
1489        row.append('span.exleft').appendMany('span.t', ex.slice(0, pi))
1490          .text(t => util.ppToken(t.t)).each(paint)
1491        row.append('span.expeak').appendMany('span.t', [ex[pi]])
1492          .text(t => util.ppToken(t.t)).each(paint)
1493        row.append('span.exright').appendMany('span.t', ex.slice(pi + 1))
1494          .text(t => util.ppToken(t.t)).each(paint)
1495      })
1496    })
1497  })
1498}
1499
1500window.init?.()
1501
1502// ─── public/feature-coverage/init-feature-coverage.js ───
1503window.initFeatureCoverage = async function(opts){
1504  var sel = d3.select('.feature-coverage').html('')
1505  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1506  var d = await util.getFile(base + 'feature_coverage.json')
1507
1508  var [wlo, whi] = d.workspace_band
1509  var xMax = d.layers[d.layers.length - 1]
1510  var sBlue = util.tol.blue
1511  var sOrange = util.tol.orange  // SAE features in (a) — keep distinct from the blue series
1512  var sGray = util.gray[500]
1513
1514  // direct labels: multi-line tspans + draggable via ?tune=1 (util.dragTune,
1515  // same pattern as modulation-lines). dx/dy printed by the tune panel map back
1516  // to the dx/dy passed here.
1517  var tuneItems = []
1518  var dLabel = (svg, {x, y, dx = 0, dy = 0, anchor = 'start', lines, color, key, tuneKey}) => {
1519    var t = svg.append('g.direct-label').translate([x, y]).append('text.dlabel')
1520      .at({textAnchor: anchor, 'data-series': key})
1521      .st({fill: color, paintOrder: 'stroke', stroke: '#EAECED', strokeWidth: 1, strokeLinejoin: 'round',  // lint-ok hex-js: ggPlot bg halo
1522           fontSize: 'var(--fs-label)', fontWeight: 600})
1523    lines.forEach((ln, li) => t.append('tspan').text(ln).at({x: 0, dy: li ? '1.15em' : 0}))
1524    var p = {x: dx, y: dy}
1525    t.translate([p.x, p.y])
1526    tuneItems.push({sel: t, key: tuneKey, get: () => ({...p}), set: q => { p = q; t.translate([q.x, q.y]) }})
1527    return t
1528  }
1529
1530  var row = sel.append('div.panel-row')
1531
1532  // panels first so flex/stack layout settles, then size every chart off the
1533  // measured panel width — fills the row on desktop, full width stacked on mobile
1534  var panels = [0, 1, 2].map(() => row.append('div.fc-panel'))
1535  var panelW = Math.max(240, Math.min(420, opts?._panelW || panels[0].node().clientWidth || 270))
1536  var pMargin = () => ({left: 48, right: 8, top: 32, bottom: 32})
1537  // titles centered over the full panel (not just the plot area) so the three
1538  // panels share one title baseline and long titles don't clip on mobile
1539  var panelTitle = (c, text) => c.svg.append('g.axis').translate([panelW/2 - c.margin.left, -10])
1540    .append('text.axis-label.axis-title').text(text).at({textAnchor: 'middle'})
1541
1542  // ── (a) histogram ──────────────────────────────────────────────────
1543  var ch = d3.conventions({
1544    sel: panels[0].append('div'),
1545    totalWidth: panelW, height: 230,
1546    margin: pMargin(),
1547  })
1548  var h = d.hist
1549  var nz = i => h.sae[i] > 0 || h.cov[i] > 0
1550  var i0 = h.x.findIndex((_, i) => nz(i))
1551  var i1 = h.x.length - 1 - [...h.sae].reverse().findIndex(v => v > 0)
1552  var xs = h.x.slice(i0, i1 + 2), sae = h.sae.slice(i0, i1 + 2), cov = h.cov.slice(i0, i1 + 2)
1553  ch.x = d3.scaleLog().domain([xs[0] * 0.9, xs[xs.length-1] * 1.05]).range([0, ch.width])
1554  ch.y = d3.scaleLog().domain([1, d3.max(sae.concat(cov))]).range([ch.height, 0]).nice().clamp(true)
1555  var xticks = [1, 2, 3, 5, 10, 20, 30, 50, 100]
1556    .filter(v => v >= ch.x.domain()[0] && v <= ch.x.domain()[1])
1557  ch.xAxis.scale(ch.x).tickValues(xticks).tickFormat(v => '' + v)
1558  ch.yAxis.scale(ch.y).ticks(6, '~s')
1559  ch.drawAxis(); util.ggPlot(ch)
1560  util.addAxisLabel(ch, 'Kurtosis →', 'Feature count →')
1561  panelTitle(ch, `Layer ${util.layerLabel(d.hist_layer)} J-lens kurtosis`)
1562
1563  var stepLine = ys => d3.line()
1564    .x((_,i) => ch.x(xs[i])).y(v => ch.y(Math.max(v, 0.5)))
1565    .curve(d3.curveStepAfter)(ys)
1566  ch.svg.append('path.line-series').at({d: stepLine(cov), fill: 'none', stroke: sGray, strokeWidth: 2, 'data-series': 'cov'})
1567  ch.svg.append('path.line-series').at({d: stepLine(sae), fill: 'none', stroke: sOrange, strokeWidth: 2, 'data-series': 'sae'})
1568
1569  ch.svg.append('line.ref-line').at({
1570    x1: ch.x(h.thr), x2: ch.x(h.thr), y1: 0, y2: ch.height,
1571  })
1572  // hand-placed via ?tune=1 — offsets from the threshold line / each percentile dot
1573  var annoOff = {x: 3, y: -42}
1574  var anno = ch.svg.append('text.fc-anno').text(`${d.kappa_mult}× null max`)
1575    .at({x: ch.x(h.thr) + annoOff.x, y: ch.height - 5 + annoOff.y, textAnchor: 'start'})
1576  tuneItems.push({sel: anno, key: 'a-kappa-anno',
1577    get: () => ({...annoOff}),
1578    set: q => { annoOff = q; anno.at({x: ch.x(h.thr) + q.x, y: ch.height - 5 + q.y}) }})
1579  var pctOff = {90: {x: -19, y: 14}, 95: {x: -22, y: 12}, 99: {x: -20, y: 12}}
1580  h.pcts.forEach(q => {
1581    var x = ch.x(q.k), y = ch.y(Math.max(1, q.y))
1582    ch.svg.append('circle').at({cx: x, cy: y, r: 4, fill: sOrange, stroke: '#fff', strokeWidth: 1.5, 'data-series': 'sae'})
1583    var off = pctOff[q.p] || {x: 5, y: -6}
1584    var pctT = ch.svg.append('text.fc-pct').text('p' + q.p)
1585      .at({x: x + off.x, y: y + off.y, textAnchor: 'start'})
1586    tuneItems.push({sel: pctT, key: 'a-p' + q.p,
1587      get: () => ({...off}),
1588      set: qq => { off = qq; pctT.at({x: x + qq.x, y: y + qq.y}) }})
1589  })
1590
1591  // direct labels: SAE has the long right tail; null is the sharp left peak.
1592  var iSae = sae.findIndex(v => v > 0 && v < 200)
1593  dLabel(ch.svg, {x: ch.x(xs[iSae]), y: ch.y(sae[iSae]), dx: 8, dy: -7,
1594    lines: ['SAE features'], color: sOrange, key: 'sae', tuneKey: 'a-sae'})
1595  var iCov = cov.findIndex(v => v > 0 && v < 100)
1596  dLabel(ch.svg, {x: ch.x(xs[iCov]), y: ch.y(cov[iCov]), dx: 156, dy: 45, anchor: 'start',
1597    lines: ['Random null'], color: sGray, key: 'cov', tuneKey: 'a-null'})
1598
1599  // ── (b) workspace fraction lines ────────────────────────────────────
1600  var c = d3.conventions({
1601    sel: panels[1].append('div'),
1602    totalWidth: panelW, height: 230,
1603    margin: pMargin(),
1604  })
1605  var allFrac = d.lines.upper.concat(d.lines.workspace)
1606  c.x.domain([0, xMax])
1607  c.y.domain([0, d3.max(allFrac) * 1.08])
1608  c.xAxis.scale(c.x).tickValues(util.layerTicks(xMax + 1, 6)).tickFormat(util.layerLabel)
1609  c.yAxis.scale(c.y).ticks(5).tickFormat(d3.format('.0%'))
1610  c.drawAxis(); util.ggPlot(c)
1611  util.addAxisLabel(c, 'Layer (reindexed) →', 'Fraction of features →')
1612  panelTitle(c, 'J-space-aligned fraction by layer')
1613
1614  util.addWsBand(c, wlo, whi)
1615
1616  var line = d3.line().x((v,i) =>
1616 c.x(d.layers[i])).y(v => c.y(v))
1617  var lineSpec = [
1618    ['upper', sGray, 1.5, [`All (κ > ${d.kappa_mult}× null max)`], {at: 16, dy: -35, dx: 16, anchor: 'end'}, 'b-all'],
1619    ['workspace', sBlue, 2.5, ['Excluding motor', 'features'],     {at: 22, dy:  32, dx:  3, anchor: 'end'}, 'b-excl-motor'],
1620  ]
1621  lineSpec.forEach(([k, col, sw, lab, L, tk]) => {
1622    var ys = d.lines[k]
1623    c.svg.append('path.line-series').at({d: line(ys), fill: 'none', stroke: col, strokeWidth: sw, 'data-series': k})
1624    c.svg.appendMany('circle', ys.map((v,i) => ({v, i})))
1625      .at({cx: q => c.x(d.layers[q.i]), cy: q => c.y(q.v), r: 2.2, fill: col, 'data-series': k})
1626    dLabel(c.svg, {x: c.x(L.at), y: c.y(ys[d.layers.indexOf(L.at)]),
1627      dx: L.dx ?? 0, dy: L.dy, anchor: L.anchor, lines: lab, color: col, key: k, tuneKey: tk})
1628  })
1629
1630  // ── (c) excluding-motor fraction under three weightings ──────────────
1631  // dict line is identical to (b)'s blue "Excluding motor features";
1632  // l0/l1 re-weight by firing count / activation mass.
1633  var w = d.weighted
1634  var ct = d3.conventions({
1635    sel: panels[2].append('div'),
1636    totalWidth: panelW, height: 230,
1637    margin: pMargin(),
1638  })
1639  var allW = w.dict.concat(w.l0, w.l1)
1640  ct.x.domain([0, xMax])
1641  ct.y.domain([0, d3.max(allW) * 1.08])
1642  ct.xAxis.scale(ct.x).tickValues(util.layerTicks(xMax + 1, 6)).tickFormat(util.layerLabel)
1643  ct.yAxis.scale(ct.y).ticks(5).tickFormat(d3.format('.0%'))
1644  ct.drawAxis(); util.ggPlot(ct)
1645  util.addAxisLabel(ct, 'Layer (reindexed) →', 'Fraction →')
1646  panelTitle(ct, 'Aligned fraction, activation-weighted')
1647  util.addWsBand(ct, wlo, whi, {label: false})
1648
1649  var lineT = d3.line().x((v,i) => ct.x(d.layers[i])).y(v => ct.y(v))
1650  var wSpec = [
1651    ['dict', sBlue,         2.5, null,  ['Excluding motor features'],    {at: 24, dy:  38, dx: -41, anchor: 'end'}, 'c-excl-motor'],
1652    ['l1',   util.tol.magenta, 1.8, '4,3', ['L1-weighted'],              {at: 13, dy:  -72, dx:  0, anchor: 'end'}, 'c-l1'],
1653    ['l0',   util.tol.teal,    1.8, '4,3', ['L0-weighted'],              {at: 23, dy:   24, dx:  5, anchor: 'end'}, 'c-l0'],
1654  ]
1655  wSpec.forEach(([k, col, sw, dash, lab, L, tk]) => {
1656    var ys = w[k]
1657    ct.svg.append('path.line-series')
1658      .at({d: lineT(ys), fill: 'none', stroke: col, strokeWidth: sw,
1659           strokeDasharray: dash, 'data-series': k})
1660    ct.svg.appendMany('circle', ys.map((v,i) => ({v, i})))
1661      .at({cx: q => ct.x(d.layers[q.i]), cy: q => ct.y(q.v), r: 2.2,
1662           fill: col, 'data-series': k})
1663    dLabel(ct.svg, {x: ct.x(L.at), y: ct.y(ys[d.layers.indexOf(L.at)]),
1664      dx: L.dx ?? 0, dy: L.dy, anchor: L.anchor, lines: lab, color: col, key: k, tuneKey: tk})
1665  })
1666
1667  util.bindSeriesHover(sel)
1668  // ?tune=1 on the standalone page makes the labels draggable; copy dx/dy back here
1669  util.dragTune(tuneItems, 'feature-coverage')
1670
1671  // rendering can add a page scrollbar and shrink the panels — re-render once at the settled width
1672  var settledW = panels[0].node().clientWidth
1673  if (!opts?._panelW && settledW && Math.abs(settledW - panelW) > 2)
1674    return window.initFeatureCoverage({...opts, _panelW: settledW})
1675}
1676
1677window.init?.()
1678
1679// ─── public/comp-lines/init-comp-lines.js ───
1680window.initCompLines = async function(opts){
1681  var sel = d3.select('.comp-lines-attn-adj').html('')
1682  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1683  var d = await util.getFile(base + 'comp_lines.json')
1684
1685  // Match comp-tail-energy: jlens = orange accent, SAE percentile bins =
1686  // Blues ramp (dark→light = top→mid), neuron = purple, random = gray
1687  // (y=1 reference line + legend row).
1688  var saeBlue = i => d3.interpolateBlues(0.9 - 0.7 * i / 2)
1689  var COLOR = {
1690    jlens_vocab: util.tol.orange,
1691    sae_top1:    saeBlue(0),
1692    sae_top5:    saeBlue(1),
1693    sae_mid:     saeBlue(2),
1694    neuron_out:  util.series[3],
1695    neuron_in:   util.series[3],
1696    random:      util.gray[500],
1697  }
1698  var srcs = d.sources.map(s => ({...s, color: COLOR[s.key]}))  // lint-ok data-color-js: COLOR override
1699
1700  var [wlo, whi] = d.workspace_band
1701  var xLo = 4, xMax = util.DISPLAY_LAYERS.length - 1
1702  var iLo = d.layers.findIndex(L => L >= xLo)
1703  var allMed = d.panels.flatMap(p =>
1704    srcs.flatMap(s => (d.data[p.key][s.key] ?? []).slice(iLo)))
1705    .filter(v => v != null)
1706  var yDom = [Math.min(0.85, d3.min(allMed) * 0.9), d3.max(allMed) * 1.1]
1707
1708  var mountW = sel.node()?.getBoundingClientRect().width || 0
1709  var stacked = mountW > 0 && mountW < 700
1710  var panelW = stacked ? Math.min(420, Math.max(240, mountW - 52)) : 290
1711
1712  var prow = sel.append('div.panel-row')
1713  d.panels.forEach((p, pi) => {
1714    var hasY = stacked || !pi
1715    var c = d3.conventions({
1716      sel: prow.append('div.cl-panel').append('div'),
1717      width: panelW, height: 240,
1718      margin: {left: hasY ? 48 : 6, right: 4, top: 24, bottom: 32},
1719    })
1720    c.x.domain([xLo, xMax])
1721    c.y = d3.scaleLog().domain(yDom).range([c.height, 0])
1722    c.xAxis.scale(c.x).tickValues(util.layerTicks(xMax + 1, 6).filter(t => t >= xLo)).tickFormat(util.layerLabel)
1723    c.yAxis.scale(c.y).ticks(5, '~g')
1724    c.drawAxis(); util.ggPlot(c)
1725    if (!hasY) c.svg.select('.y').selectAll('text').remove()
1726    var rw = {attn_v_var: ' (read)', attn_qk_var: ' (read)', attn_w_var: ' (write)'}[p.key] || ''
1727    util.addAxisLabel(c, 'Source layer (reindexed) →', hasY ? 'Median composition ratio →' : '', p.title + rw)
1728    util.addWsBand(c, wlo, whi, {label: pi === d.panels.length - 1 && 'Workspace layers'})
1729    c.svg.append('line.ref-line')
1730      .at({x1: 0, x2: c.width, y1: c.y(1), y2: c.y(1), stroke: COLOR.random})
1731
1732    var line = d3.line().defined((v, i) => v != null && d.layers[i] >= xLo)
1733      .x((v, i) => c.x(d.layers[i])).y(v => c.y(v))
1734    srcs.slice().reverse().forEach(s => {
1735      var vals = d.data[p.key][s.key]
1736      if (!vals) return
1737      c.svg.append('path.line-series').at({
1738        d: line(vals), fill: 'none', 'data-series': s.key,
1739        stroke: s.color, strokeWidth: s.key === 'jlens_vocab' ? 2.2 : 1.8,
1740      })
1741      c.svg.appendMany('circle',
1742        vals.map((v, i) => ({v, i})).filter(m => m.v != null && d.layers[m.i] >= xLo))
1743        .at({cx: m => c.x(d.layers[m.i]), cy: m => c.y(m.v), r: 2.2, fill: s.color})  // lint-ok data-color-js
1744    })
1745  })
1746
1747  // Side legend column. neuron_out/in collapse to one "Neuron" row.
1748  var seen = new Set()
1749  var legRows = srcs.filter(s => !seen.has(s.label) && seen.add(s.label))
1750    .concat([{key: 'random', label: 'Random direction', color: COLOR.random}])
1751  prow.append('div.cl-attn-legend')
1752    .appendMany('div.cl-leg-row', legRows)
1753    .at({'data-series': s => s.key})
1754    .each(function(s){
1755      d3.select(this).append('span.cl-swatch').st({background: s.color})  // lint-ok data-color-js
1756      d3.select(this).append('span').text(s.label)
1757    })
1758  util.bindSeriesHover(sel)
1759}
1760
1761window.init?.()
1762
1763// ─── public/comp-strata/init-comp-strata.js ───
1764window.initCompStrata = async function(opts){
1765  var variant = opts?.variant ?? 'sae6'
1766  var sel = d3.select(`.comp-strata[data-variant="${variant}"]`).html('')
1767  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1768  var d = await util.getFile(base + 'comp_strata.json')
1769  var v = d[variant]
1770
1771  // sae6 bins are an ordinal lens-readability percentile → single-hue ramp.
1772  // Blues by default (matches the matplotlib original; light end stays
1773  // legible). ?ramp=viridis|orange to compare.
1774  var rampName = new URLSearchParams(location.search).get('ramp') || 'blues'
1775  var RAMPS = {
1776    blues:   t => d3.interpolateBlues(0.9 - 0.7 * t),
1777    viridis: t => d3.interpolateViridis(0.15 + 0.75 * t),
1778    orange:  t => util.tint(util.tol.orange, 0.05 + 0.75 * t),
1779  }
1780  var ramp = RAMPS[rampName] || RAMPS.blues
1781  var COLOR = variant === 'sae6'
1782    ? Object.fromEntries(v.bins.map((b, i) =>
1783        [b.key, ramp(i / (v.bins.length - 1))]))
1784    : {jlens_vocab: util.lensColors.jacobian, neuron_out: util.series[3]}
1785
1786  var keys = v.bins.map(b => b.key)
1787  var [wlo, whi] = d.workspace_band
1788  var xMax = util.DISPLAY_LAYERS.length - 1
1789  var SIDES = [
1790    {key: 'read',  title: 'MLP input (read)'},
1791    {key: 'write', title: 'MLP output (write)'},
1792  ]
1793
1794  var prow = sel.append('div.panel-row')
1795  SIDES.forEach((side, pi) => {
1796    var rows = d.layers.map((L, i) => {
1797      var r = {layer: L}
1798      keys.forEach(k => r[k] = v.data[side.key][k][i])
1799      return r
1800    })
1801    var stack = d3.stack().keys(keys)(rows)
1802
1803    var c = d3.conventions({
1804      sel: prow.append('div.cs-panel').append('div'),
1805      width: 320, height: 220,
1806      margin: {left: pi ? 6 : 50, right: pi ? 92 : 6, top: 24, bottom: 32},
1807    })
1808    c.x.domain([d.layers[0], xMax])
1809    c.y.domain([0, 1])
1810    c.xAxis.tickValues(util.layerTicks(xMax + 1, 6)).tickFormat(util.layerLabel)
1811    c.yAxis.ticks(4).tickFormat(d3.format('.0%'))
1812    c.drawAxis(); util.ggPlot(c)
1813    if (pi) c.svg.select('.y').selectAll('text').remove()
1814    util.addAxisLabel(c, 'Source layer (reindexed) →',
1815      pi ? '' : 'Neurons best-matched →', side.title, 0, -3)
1816
1817    var area = d3.area()
1818      .x((p, i) =>
1818 c.x(d.layers[i]))
1819      .y0(p => c.y(p[0])).y1(p => c.y(p[1]))
1820    // own <g> so bindSeriesHover's .raise() stays under edges/ref-lines
1821    c.svg.append('g').appendMany('path.cs-area', stack)
1822      .at({d: area, fill: s => COLOR[s.key], 'data-series': s => s.key})
1823    c.svg.appendMany('path.cs-edge', stack.slice(0, -1))
1824      .at({d: s => d3.line().x((p,i) => c.x(d.layers[i]))
1825        .y(p => c.y(p[1]))(s), fill: 'none'})
1826
1827    // uniform-prior reference grid: 1/n lines on top of the fills
1828    d3.range(1, keys.length).forEach(k =>
1829      c.svg.append('line.ref-line')
1830        .at({x1: 0, x2: c.width, y1: c.y(k/keys.length), y2: c.y(k/keys.length)}))
1831
1832    // workspace-band: bracket at the bottom of the plot interior, ticks
1833    // descending toward the x-tick numbers; label just above the bar.
1834    var bx0 = c.x(wlo), bx1 = c.x(whi), bh = 5, by = c.height - bh - 1
1835    c.svg.append('path.ws-bracket')
1836      .at({d: `M${bx0},${by+bh} V${by} H${bx1} V${by+bh}`})
1837    c.svg.append('text.ws-label').text('Workspace layers')
1838      .translate([(bx0 + bx1) / 2, by - 14]).st({textAnchor: 'middle'})
1839
1840    if (pi !== 1) return
1841    var iEnd = d.layers.length - 1
1842    var anchors = stack.map(s => ({
1843      key: s.key, y: c.y((s[iEnd][0] + s[iEnd][1]) / 2),
1844      label: v.bins.find(b => b.key === s.key).label,
1845    }))
1846    util.spreadLabels?.(anchors, 13, 0, c.height)
1847    // light-tint label text is unreadable; clamp label color to ramp(≤0.4).
1848    var labelColor = k => variant === 'sae6'
1849      ? ramp(Math.min(0.4, keys.indexOf(k) / (keys.length - 1)))
1850      : COLOR[k]
1851    anchors.forEach(a => util.directLabel(c.svg, {
1852      x: c.width, y: a.y, dx: 6, dy: 4, anchor: 'start',
1853      text: a.label, color: labelColor(a.key), key: a.key,
1854    }))
1855    if (variant === 'sae6')
1856      c.svg.append('text').text('by J-lens kurtosis')
1857        .translate([c.width + 6, -10])
1858        .st({fontSize: 'var(--fs-label)', fill: 'var(--text-light)'})
1859  })
1860  util.bindSeriesHover(sel)
1861}
1862
1863window.init?.()
1864
1865// ─── public/comp-tail-energy/init-comp-tail-energy.js ───
1866window.initCompTailEnergy = async function(opts){
1867  var sel = d3.select('.comp-tail-energy').html('')
1868  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1869  var d = await util.getFile(base + 'comp_tail_energy.json')
1870
1871  // Match the matplotlib + comp-strata palette: SAE percentile bins = Blues
1872  // ramp (dark→light = top→bottom); jlens = orange so it separates from the
1873  // blue SAE stack; neuron = purple. Random sits at y=1 by construction →
1874  // drawn as a reference line, listed in the legend.
1875  var saeKeys = d.families.map(f => f.key).filter(k => k.startsWith('sae_'))
1876  var saeRamp = i => d3.interpolateBlues(0.9 - 0.7 * i / (saeKeys.length - 1))
1877  var COLOR = {
1878    jlens_vocab: util.tol.orange,
1879    neuron_out:  util.series[3],
1880    random:      util.gray[500],
1881    ...Object.fromEntries(saeKeys.map((k, i) => [k, saeRamp(i)])),
1882  }
1883  var LW = k => k === 'jlens_vocab' ? 2.2 : k === 'neuron_out' ? 1.8 : 1.6
1884
1885  var [wlo, whi] = d.workspace_band
1886  var xLo = d.layers[0], xMax = util.DISPLAY_LAYERS.length - 1
1887  var allMed = ['read', 'write'].flatMap(s =>
1888    d.families.flatMap(f => d.data[s][f.key]).filter(v => v != null))
1889  var yMax = d3.max(allMed) * 1.05
1890
1891  var SIDES = [
1892    {key: 'read',  title: 'MLP input weights (read)'},
1893    {key: 'write', title: 'MLP output weights (write)'},
1894  ]
1895
1896  var mountW = sel.node()?.getBoundingClientRect().width || 0
1897  var stacked = mountW > 0 && mountW < 700
1898  var panelW = stacked ? Math.min(420, Math.max(260, mountW - 52)) : 360
1899
1900  var prow = sel.append('div.panel-row')
1901  SIDES.forEach((side, pi) => {
1902    var hasY = stacked || !pi
1903    var c = d3.conventions({
1904      sel: prow.append('div.cte-panel').append('div'),
1905      width: panelW, height: 250,
1906      margin: {left: hasY ? 44 : 6, right: 4, top: 24, bottom: 32},
1907    })
1908    c.x.domain([xLo, xMax])
1909    c.y.domain([0, yMax])
1910    c.xAxis.tickValues(util.layerTicks(xMax + 1, 6).filter(t => t >= xLo)).tickFormat(util.layerLabel)
1911    c.yAxis.ticks(5)
1912    c.drawAxis(); util.ggPlot(c)
1913    if (!hasY) c.svg.select('.y').selectAll('text').remove()
1914    util.addAxisLabel(c, 'Source layer (reindexed) →', hasY ? 'Tail-energy ratio →' : '', side.title)
1915    util.addWsBand(c, wlo, whi, {label: pi === 1 && 'Workspace layers'})
1916    c.svg.append('line.ref-line')
1917      .at({x1: 0, x2: c.width, y1: c.y(1), y2: c.y(1), stroke: COLOR.random})
1918
1919    var line = d3.line().defined(v => v != null)
1920      .x((v, i) => c.x(d.layers[i])).y(v => c.y(v))
1921    d.families.slice().reverse().forEach(f => {
1922      var vals = d.data[side.key][f.key]
1923      c.svg.append('path').at({
1924        d: line(vals), fill: 'none',
1925        stroke: COLOR[f.key], strokeWidth: LW(f.key),
1926      })
1927      c.svg.appendMany('circle', vals.map((v, i) => ({v, i})).filter(m => m.v != null))
1928        .at({cx: m => c.x(d.layers[m.i]), cy: m => c.y(m.v), r: 2.0, fill: COLOR[f.key]})  // lint-ok data-color-js
1929    })
1930
1931  })
1932
1933  // Legend column to the right of the panels.
1934  var legRows = [...d.families, {key: 'random', label: 'Random direction'}]
1935  var leg = prow.append('div.cte-legend')
1936  leg.appendMany('div.cte-leg-row', legRows)
1937    .each(function(f){
1938      d3.select(this).append('span.cte-swatch').st({background: COLOR[f.key]})  // lint-ok data-color-js
1939      d3.select(this).append('span').text(f.label)
1940    })
1941}
1942
1943window.init?.()
1944
1945// ─── public/mlp-gain/init-mlp-gain.js ───
1946window.initMlpGain = async function(opts){
1947  var sel = d3.select('.mlp-gain').html('')
1948  var base = (opts?.datapath || './').replace(/\/?$/, '/')
1949  var d = await util.getFile(base + 'mlp_gain.json')
1950
1951  // left = lens comparisons (paper palette); right = SAE strata (Blues ramp,
1952  // matches comp-strata so the readability ordinal reads the same way).
1953  var saeKeys = d.panels[1].families.map(f => f.key)
1954  var blues = t => d3.interpolateBlues(0.9 - 0.6 * t)
1955  var COLOR = {
1956    jlens_vocab: util.tol.orange,
1957    neuron_out:  util.gray[600],
1958    ...Object.fromEntries(saeKeys.map((k, i) => [k, blues(i / (saeKeys.length - 1))])),
1959  }
1960
1961  var [wlo, whi] = d.workspace_band
1962  var xMax = util.DISPLAY_LAYERS.length - 1
1963  var allKeys = d.panels.flatMap(p => p.families.map(f => f.key))
1964  var allV = allKeys.flatMap(k => d.data[k])
1965  var yDom = [Math.min(0.85, d3.min(allV) * 0.95), d3.max(allV) * 1.1]
1966
1967  var mountW = sel.node()?.getBoundingClientRect().width || 0
1968  var stacked = mountW > 0 && mountW < 700
1969  var panelW = stacked ? Math.min(420, Math.max(240, mountW - 56)) : 340
1970
1971  var LABEL = {
1972    jlens_vocab: {at: 14, dy: -10, dx: -2, anchor: 'end'},
1973    neuron_out:  {at:  3, dy: -10, dx:  4, anchor: 'start'},
1974  }
1975
1976  var prow = sel.append('div.panel-row')
1977  d.panels.forEach((p, pi) => {
1978    var hasY = stacked || !pi
1979    var c = d3.conventions({
1980      sel: prow.append('div.mg-panel').append('div'),
1981      width: panelW, height: 250,
1982      margin: {left: hasY ? 50 : 6, right: pi ? 110 : 8, top: 24, bottom: 32},
1983    })
1984    c.x.domain([0, xMax])
1985    c.y = d3.scaleLog().domain(yDom).range([c.height, 0])
1986    c.xAxis.scale(c.x).tickValues(util.layerTicks(xMax + 1, 6)).tickFormat(util.layerLabel)
1987    c.yAxis.scale(c.y).ticks(5, '~g')
1988    c.drawAxis(); util.ggPlot(c)
1989    if (!hasY) c.svg.select('.y').selectAll('text').remove()
1990    util.addAxisLabel(c, 'Source layer (reindexed) →', hasY ? 'Median MLP block gain →' : '', p.title)
1991
1992    util.addWsBand(c, wlo, whi, {label: pi === 1 && 'Workspace layers'})
1993    c.svg.append('line.ref-line').at({x1: 0, x2: c.width, y1: c.y(1), y2: c.y(1)})
1994    if (!pi) c.svg.append('text').text('Random direction')
1995      .at({x: c.width - 4, y: c.y(1) - 5, textAnchor: 'end'})
1996      .st({fill: 'var(--text-light)', fontSize: 'var(--fs-label)'})
1997
1998    var line = d3.line().x((v, i) => c.x(d.layers[i])).y(v => c.y(v))
1999    p.families.slice().reverse().forEach(f => {
2000      var vals = d.data[f.key], col = COLOR[f.key]
2001      c.svg.append('path.line-series').at({
2002        d: line(vals), fill: 'none', 'data-series': f.key,
2003        stroke: col, strokeWidth: f.key === 'jlens_vocab' ? 2.2 : 1.8,
2004      })
2005      c.svg.appendMany('circle', vals.map((v, i) => ({v, i})))
2006        .at({cx: m => c.x(d.layers[m.i]), cy: m => c.y(m.v), r: 2.2, fill: col})  // lint-ok data-color-js
2007    })
2008
2009    if (!pi) p.families.forEach(f => {
2010      var L = LABEL[f.key]; if (!L) return
2011      var li = d.layers.indexOf(L.at)
2012      if (li < 0) li = d3.bisectLeft(d.layers, L.at)
2013      util.directLabel(c.svg, {
2014        x: c.x(d.layers[li]), y: c.y(d.data[f.key][li]),
2015        dx: L.dx, dy: L.dy, anchor: L.anchor,
2016        text: f.label, color: COLOR[f.key], key: f.key,
2017      })
2018    })
2019    else {
2020      var leg = c.svg.append('g.mg-leg').translate([c.width + 10, 8])
2021      leg.append('text.leg-title').text('J-lens kurtosis').at({y: -2})
2022      leg.append('text.leg-title').text('percentile').at({y: 10})
2023      p.families.forEach((f, i) => {
2024        var g = leg.append('g').translate([0, 26 + i * 15])
2025        g.append('line').at({x1: 0, x2: 14, stroke: COLOR[f.key], strokeWidth: 2.5})
2026        g.append('text').text(f.label).at({x: 19, dy: '.32em'})
2027          .st({fill: util.gray[700]})
2028      })
2029    }
2030  })
2031  util.bindSeriesHover(sel)
2032}
2033
2034window.init?.()
2035
2036// ─── public/lens-similarity/init-lens-similarity.js ───
2037window.initLensSimilarity = async function(opts){
2038  var sel = d3.select('.lens-similarity').html('')
2039  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2040  var d = await util.getFile(base + 'lens_similarity.json')
2041
2042  // Per-pair line colour. Jacobian-involving pairs take the *other* lens's
2043  // util.lensColors hue (matching the per-method palette in line-panels);
2044  // the no-jacobian logit↔tuned pair is neutral gray.
2045  var COLOR = {
2046    'jacobian-logit': util.lensColors.logit,
2047    'jacobian-tuned': util.lensColors.tuned,
2048    'logit-tuned':    util.gray[600],
2049  }
2050  d.pairs = d.pairs.map(p => ({...p, color: COLOR[p.key]}))  // lint-ok data-color-js: COLOR override above
2051
2052  // Direct labels — anchored on panel 0 (cosine) where the three lines have
2053  // the most vertical separation in the mid layers.
2054  var LABEL = {
2055    'jacobian-logit': {at: 14, dy: -10, dx:  -4, anchor: 'end'},
2056    'jacobian-tuned': {at: 17, dy:  14, dx:   2, anchor: 'start'},
2057    'logit-tuned':    {at:  6, dy: -10, dx:   0, anchor: 'middle'},
2058  }
2059
2060  var [wlo, whi] = d.workspace_band
2061  var xMax = d.layers[d.layers.length - 1]
2062  // cos/agree are bounded [0,1] — no negative axis space, no headroom past 100%;
2063  // dots at 0/1 sit on the boundary
2064  var yDom = {
2065    cos:   [0, 1],
2066    kl:    [0, d3.max(d.pairs.flatMap(p => d.data.kl[p.key])) * 1.08],
2067    agree: [0, 1],
2068  }
2069
2070  var prow = sel.append('div.panel-row')
2071  d.panels.forEach((p, pi) => {
2072    var cell = prow.append('div.ls-panel')
2073    var c = d3.conventions({
2074      sel: cell.append('div'),
2075      width: 290, height: 230,
2076      margin: {left: pi ? 42 : 48, right: 6, top: 24, bottom: 32},
2077    })
2078    c.x.domain([0, xMax])
2079    c.y.domain(yDom[p.key])
2080    // cos/agree are bounded [0,1] — nice() would expand the small pad to [-0.1, 1.1]
2081    if (p.key === 'kl') c.y.nice()
2082    c.xAxis.scale(c.x).tickValues(util.layerTicks(xMax + 1, 6)).tickFormat(util.layerLabel)
2083    c.yAxis.scale(c.y).ticks(5)
2084    c.drawAxis(); util.ggPlot(c)
2085    if (pi) c.svg.select('.x .tick text').remove()
2086    util.addAxisLabel(c, 'Layer (reindexed) →', p.ylabel + ' →', p.title)
2087
2088    util.addWsBand(c, wlo, whi, {label: pi === d.panels.length - 1 && 'Workspace layers'})
2089
2090    var line = d3.line().defined(v => v != null)
2091      .x((v,i) => c.x(d.layers[i])).y(v => c.y(v))
2092    d.pairs.forEach(s => {
2093      var ys = d.data[p.key][s.key]
2094      c.svg.append('path.line-series').at({
2095        d: line(ys), fill: 'none', 'data-series': s.key,
2096        stroke: s.color, strokeWidth: 1.9,
2097      })
2098      c.svg.appendMany('circle', ys.map((v,i) => ({v, i})).filter(m => m.v != null))
2099        .at({cx: m => c.x(d.layers[m.i]), cy: m => c.y(m.v), r: 2.2, fill: s.color})  // lint-ok data-color-js
2100    })
2101
2102    if (pi) return
2103    d.pairs.forEach(s => {
2104      var ys = d.data[p.key][s.key], L = LABEL[s.key]
2105      var li = d.layers.indexOf(L.at)
2106      if (li < 0) return
2107      // ↔ falls back to a font whose glyph sits on the baseline — render an en dash instead
2108      util.directLabel(c.svg, {
2109        x: c.x(L.at), y: c.y(ys[li]),
2110        dx: L.dx, dy: L.dy, anchor: L.anchor,
2111        text: s.label.replace(' ↔ ', ' – '), color: s.color, key: s.key,
2112      })
2113    })
2114  })
2115  util.bindSeriesHover(sel)
2116}
2117
2118window.init?.()
2119
2120// ─── public/transcoder-arith/init-transcoder-arith.js ───
2121window.initTranscoderArith = async function(opts){
2122  var sel = d3.select('.transcoder-arith')
2123  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2124  var d = await util.getFile(base + 'arith.json')
2125
2126  var ML0 = 72, MLn = 22, MRz = 22
2127
2128  function render(){
2129    sel.html('')
2130    var row = sel.append('div.panel-row')
2131    // stacked (mobile) mode: panel-row goes flex-direction:column via the container query
2132    var stacked = getComputedStyle(row.node()).flexDirection === 'column'
2133    var cw = sel.node().offsetWidth || 910
2134    // stacked panels fill the container width; desktop keeps the fixed 240px panels
2135    var s = stacked ? Math.max(1.6, Math.min(3.4, (cw - ML0 - MRz) / 100)) : 2.4
2136    var W = 100 * s
2137    var orange = d3.scaleSequential(d3.interpolateOranges).domain([0, 1.4]).clamp(true)
2138    var prgn = d3.scaleDiverging(d3.interpolatePRGn).domain([-1, 0, 1]).clamp(true)
2139    var ticks20 = d3.range(0, 100, 20)
2140
2141    function vgrid(c){
2142      d3.range(0, 101, 20).forEach(t => c.svg.append('line')
2143        .at({x1: c.x(t), x2: c.x(t), y1: 0, y2: c.height, stroke: '#fff', strokeWidth: 0.7}))
2144    }
2145    function frame(c){
2146      c.svg.append('rect').at({width: c.width, height: c.height, fill: 'none', stroke: util.gray[400], strokeWidth: 0.6})
2147    }
2148    function barLabel(svg, name, h){
2149      var t = svg.append('text').at({x: -28, y: h/2 - 3, textAnchor: 'end', fill: util.gray[700]})
2150      t.append('tspan.ta-barname').text(name)
2151      var f = t.append('tspan.ta-formula').at({x: -28, dy: 11, fill: util.gray[500]})
2152      f.append('tspan').text('· J · W')
2153      f.append('tspan').text('U').at({baselineShift: 'sub', fontSize: '70%'})  // lint-ok inline-font-js: subscript
2154    }
2155    function sectionLabel(svg, txt, w, h){
2156      svg.append('text.mlabel').text(txt)
2157        .at({x: w + 16, y: h/2, textAnchor: 'middle', fill: util.gray[500],
2158             transform: `rotate(90 ${w + 16} ${h/2})`})
2159    }
2160
2161    d.features.forEach((f, fi) => {
2162      // when stacked, every panel is redrawn with its own y-axis labels, bar labels,
2163      // and section labels so each fills the width and they all share the same left edge
2164      var first = stacked || fi === 0
2165      var lastp = stacked || fi === d.features.length - 1
2166      var ML = stacked ? ML0 : (first ? ML0 : MLn)
2167      var MR = lastp ? MRz : 4
2168      var cell = row.append('div.ta-panel')
2169      cell.append('div.fig-title').text(f.title).st({marginLeft: ML, marginRight: MR})
2170
2171      // operand activation: 100×100, row=a, col=b
2172      var c = d3.conventions({
2173        sel: cell.append('div'),
2174        width: W, height: W, layers: 'sc',
2175        margin: {left: ML, right: MR, top: 2, bottom: 20},
2176      })
2177      c.x.domain([0, 100]); c.y.domain([0, 100])
2178      c.xAxis.tickValues(ticks20).tickFormat(d => d).tickSize(3)
2179      c.yAxis.tickValues(ticks20).tickFormat(d => d).tickSize(3)
2180      c.drawAxis()
2181      c.svg.insert('rect', ':first-child').at({width: c.width, height: c.height, fill: util.gray[100]})
2182      util.addAxisLabel(c, 'Operand b →', first ? 'Operand a →' : '')
2183      var ctx = c.layers[1]
2184      for (var i = 0; i < 100; i++) for (var j = 0; j < 100; j++){
2185        var v = f.heatmap[i][j]
2186        if (v <= 0) continue
2187        ctx.fillStyle = orange(v)
2188        ctx.fillRect(j*s, (99-i)*s, s, s)
2189      }
2190      d3.range(0, 101, 20).forEach(t => c.svg.append('line')
2191        .at({x1: 0, x2: c.width, y1: c.y(t), y2: c.y(t), stroke: '#fff', strokeWidth: 0.7}))
2192      vgrid(c); frame(c)
2193      if (lastp) sectionLabel(c.svg, 'avg activation', c.width, c.height)
2194
2195      cell.append('div.ta-gap')
2196
2197      // lensed bars: 10×100 each, row=hundreds digit, col=last two digits of sum
2198      d.bars.forEach((b, bi) => {
2199        var last = bi === d.bars.length - 1
2200        var cb = d3.conventions({
2201          sel: cell.append('div'),
2202          width: W, height: 10*s, layers: 'sc',
2203          margin: {left: ML, right: MR, top: 0, bottom: last ? 30 : 12},
2204        })
2205        cb.x.domain([0, 100]); cb.y.domain([0, 10])
2206        cb.xAxis.tickValues(ticks20)
2207          .tickFormat(last ? d => '_' + String(d).padStart(2, '0') : () => '').tickSize(3)
2208        cb.yAxis.tickValues([0.5, 9.5])
2209          .tickFormat(d => Math.floor(d) + '__').tickSize(0)
2210        cb.drawAxis()
2211        cb.svg.selectAll('.y .tick text').at({fontSize: 'var(--fs-label)', fill: util.gray[500], x: -3})
2212        if (last) util.addAxisLabel(cb, 'Predicted sum (last two digits) →', '')
2213        if (first) barLabel(cb.svg, b.name || b.label, cb.height)
2214        var g = f[b.key].grid, cx = cb.layers[1]
2215        for (var h = 0; h < 10; h++) for (var j = 0; j < 100; j++){
2216          cx.fillStyle = prgn(g[h][j])
2217          cx.fillRect(j*s, (9-h)*s, s, s)
2218        }
2219        vgrid(cb); frame(cb)
2220        if (lastp && bi === 0) sectionLabel(cb.svg, 'J-lens effect', cb.width, d.bars.length * (10*s + 3))
2221      })
2222    })
2223    util.colorCaption(sel, {promotes: prgn(0.7), suppresses: prgn(-0.7)})
2224    return {stacked, w: cw}
2225  }
2226
2227  var cur = render()
2228  var mount = sel.node()
2229  mount.__taRO?.disconnect()
2230  mount.__taRO = new ResizeObserver(util.throttleDebounce(() => {
2231    var w = mount.offsetWidth
2232    var stacked = getComputedStyle(sel.select('.panel-row').node()).flexDirection === 'column'
2233    if (stacked !== cur.stacked || (stacked && Math.abs(w - cur.w) > 4)) cur = render()
2234  }, 120))
2235  mount.__taRO.observe(mount.closest('figure') || mount)
2236}
2237
2238window.init?.()
2239
2240// ─── public/transcoder-translation/init-transcoder-translation.js ───
2241window.initTranscoderTranslation = async function(opts){
2242  var sel = d3.select('.transcoder-translation').html('')
2243  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2244  var d = await util.getFile(base + 'translation.json')
2245
2246  // Orange is reserved for activation strength in this paper — categorical series
2247  // use Tol vibrant from util.tol. Override the bake-supplied colors so a future
2248  // change to util.tol propagates without re-baking.
2249  var catOverride = {0: util.tol.blue, 1: util.tol.magenta, 2: util.tol.teal}
2250  var catColor = c => {
2251    var idx = Object.keys(d.cats).indexOf(c)
2252    return catOverride[idx] ?? d.cats[c]?.color ?? util.gray[500]
2253  }
2254
2255  var row = sel.append('div.panel-row')
2256  row.append('div.tt-ylabel').text('Rank →')
2257  d.features.forEach(f => {
2258    var cell = row.append('div.tt-panel')
2259    var t = cell.append('div.fig-title')
2260    // panel titles get sentence case; f.en stays lowercase in the data (it's the literal word)
2261    t.append('span').text(f.en.charAt(0).toUpperCase() + f.en.slice(1))
2262    t.append('span.arrow').text('→')
2263    t.append('span').text(f.fr)
2264
2265    var cols = cell.append('div.tt-cols')
2266    ;[['Encoder', f.encoder], ['Decoder', f.decoder]].forEach(([name, toks]) => {
2267      var box = cols.append('div.tt-box')
2268      var hdr = box.append('div.tt-hdr')
2269      hdr.append('span.tt-name').text(name)
2270      var fm = hdr.append('span.tt-formula')
2271      fm.append('span').text(' · J · W')
2272      fm.append('sub').text('U')
2273      box.append('div.tt-list').appendMany('div.tt-row', toks).each(function(t){
2274        var s = d3.select(this)
2275        s.append('span.tt-rank').text(t.rank)
2276        var tx = util.ppToken(t.tok)
2277        s.append('span.tt-tok').text(tx).at({title: tx})
2278          .st({color: catColor(t.cat)})
2279      })
2280    })
2281  })
2282
2283  var leg = sel.append('div.tt-legend')
2284  Object.entries(d.cats).forEach(([key, c], idx) => {
2285    var item = leg.append('div.legend-item')
2286    item.append('span.legend-sq').st({background: catOverride[idx] ?? c.color})  // lint-ok data-color-js: c.color overridden by catOverride
2287    item.append('span').text(c.label)
2288  })
2289
2290  util.colorCaption(sel, {
2291    'multilingual concept': util.tol.blue,
2292    'French context': util.tol.magenta,
2293    'French word': util.tol.teal,
2294  })
2295}
2296
2297window.init?.()
2298
2299// ─── public/attn-panel/init-attn-panel.js ───
2300!function(){
2301  var WEIGHT_TYPES = ['Q', 'K', 'V', 'O']
2302  var QKVO_HDR = 'top jlens tokens'
2303  var PATTERN_HDR = '<span>attention pattern from query token</span><span>↓</span>'
2304
2305  // white-at-zero tint so unattended tokens stay neutral; full weight = tol.orange
2306  function attnOrange(w){ return util.tint(util.tol.orange, 1 - Math.max(0, Math.min(1, w))) }
2307
2308  function renderHead(sel, h, ramp, isMain){
2309    var ahp = sel.append('div.ahp.card')
2310    ahp.append('div.fig-title').text(h.label + ' Attention Head')
2311    var body = ahp.append('div.body')
2312
2313    var left = body.append('div')
2314    left.append('div.mlabel.pattern-right').html(PATTERN_HDR)
2315    left.append('div.snips').appendMany('div.snip', h.snippets).each(function(s){
2316      d3.select(this).append('span.row').appendMany('span.tok', s.tokens)
2317        .text(d => d)
2318        .st({background: (_, i) => ramp(s.w[i])})
2319        .classed('tok-hit', (_, i) => !isMain && i === s.q)
2320    })
2321
2322    var right = body.append('div')
2323    right.append('div.mlabel').html(QKVO_HDR)
2324    right.append('div.qkvo').appendMany('div.col', WEIGHT_TYPES).each(function(wt){
2325      var col = d3.select(this)
2326      col.append('div.wt').html(`W<sub>${wt}</sub>`)
2327      col.appendMany('div.t', h.qkvo[wt]).text(d => d).at({title: d => d})
2328    })
2329  }
2330
2331  async function render(sel, opts, keys, ramp, isMain){
2332    sel.html('')
2333    var base = (opts?.datapath || './').replace(/\/?$/, '/')
2334      .replace(/attn-panel-appendix\/$/, 'attn-panel/')
2335    var d = await util.getFile(base + 'heads.json')
2336    keys.forEach(k => renderHead(sel, d.heads[k], ramp, isMain))
2337  }
2338
2339  window.initAttnPanel = opts => render(d3.select('.attn-panel'), opts, ['british'], attnOrange, true)
2340  window.initAttnPanelAppendix = opts =>
2341    render(d3.select('.attn-panel-appendix'), opts, ['async', 'temperature', 'honorific'], attnOrange)
2342}()
2343
2344window.init?.()
2345
2346// ─── public/jlens-circuit-graph/init-jlens-circuit-graph.js ───
2347window.initJlensCircuitGraph = async function({datapath}){
2348  var sel = d3.select('.jlens-circuit-graph').html('')
2349  var d = await util.getFile(datapath + 'data.json')
2350
2351  var row = sel.append('div.panel-row')
2352
2353  // ── left panel: curated attribution graph ──
2354  var gp = row.append('div.graph-panel')
2355  gp.append('div.fig-title').text('J-lens attribution graph')
2356  gp.append('div.prompt-box').text(d.prompt)
2357
2358  var BW = 128, BH = 48, STACK = 3
2359  var X = col => 70 + col*185, Y = r => 28 + r*88
2360  var W = X(d3.max(d.graph.nodes, n => n.col)) + BW/2 + STACK*2.5 + 8
2361  var H = Y(d3.max(d.graph.nodes, n => n.row)) + BH/2 + STACK*2.5 + 6
2362  var svg = gp.append('svg').at({viewBox: `0 0 ${W} ${H}`})
2363
2364  var byId = {}
2365  d.graph.nodes.forEach(n => byId[n.id] = {...n, x: X(n.col), y: Y(n.row)})
2366
2367  svg.append('defs').append('marker')
2368    .at({id: 'jlens-cg-arrow', viewBox: '0 0 10 10', refX: 8.5, refY: 5,
2369         markerWidth: 12, markerHeight: 12, orient: 'auto', markerUnits: 'userSpaceOnUse'})
2370    .append('path.arrowhead').at({d: 'M0,0L10,5L0,10z'})
2371
2372  // trunk-style edges: every input to a box merges below it and enters
2373  // through a single arrowhead; tributaries join with rounded breaks
2374  var bySrc = d3.group(d.graph.edges, e => e.source)
2375  bySrc.forEach(es => {
2376    es.sort((a, b) => byId[a.target].x - byId[b.target].x)
2377    es.forEach((e, i) => e._sOff = (i - (es.length - 1)/2)*22)
2378  })
2379  d3.group(d.graph.edges, e => e.target).forEach((es, tgt) => {
2380    var t = byId[tgt], r = 10
2381    var yStub = t.y + BH/2 + STACK*2, yMerge = yStub + 20
2382    svg.append('path.edge').at({d: `M${t.x},${yMerge - r} L${t.x},${yStub}`, markerEnd: 'url(#jlens-cg-arrow)'})
2383    es.forEach(e => {
2384      var s = byId[e.source]
2385      var x0 = s.x + (e._sOff || 0), x1 = t.x, sx = x1 > x0 ? 1 : -1
2386      // exit from the source's top when it sits below the merge level,
2387      // from its bottom otherwise, so the line never crosses a box
2388      var fromTop = s.y - BH/2 > yMerge + 12
2389      var y0 = fromTop ? s.y - BH/2 : s.y + BH/2 + STACK*2
2390      var path = Math.abs(x1 - x0) < 6
2391        ? `M${x0},${y0} L${x1},${yMerge - r}`
2392        : `M${x0},${y0} L${x0},${yMerge + (fromTop ? r : -r)} Q${x0},${yMerge} ${x0 + sx*r},${yMerge} ` +
2393          `L${x1 - sx*r},${yMerge} Q${x1},${yMerge} ${x1},${yMerge - r}`
2394      svg.append('path.edge').at({d: path})
2395    })
2396  })
2397
2398  // supernode boxes — stacked-card look, atom tokens in small gray underneath
2399  var boxFill = n => n.kind == 'reminder' ? util.tint(util.brand.manilla, 0.62) : util.brand.manilla
2400  var node = svg.appendMany('g.node', d.graph.nodes.map(n => byId[n.id]))
2401    .translate(n => [n.x - BW/2, n.y - BH/2])
2402    .classed('reminder', n => n.kind == 'reminder')
2403  node.each(function(n){
2404    var g = d3.select(this)
2405    d3.range(STACK, 0, -1).forEach(i =>
2406      g.append('rect.stack').at({x: i*2.5, y: i*2.5, width: BW, height: BH, rx: 8})
2407        .st({fill: boxFill(n), stroke: util.gray[500]}))
2408    g.append('rect.stack').at({width: BW, height: BH, rx: 8})
2409      .st({fill: boxFill(n), stroke: util.gray[500]})
2410  })
2411  node.append('text.blabel').text(n => n.label)
2412    .at({x: BW/2, y: n => n.atoms.length ? BH/2 + 1 : BH/2 + 5, textAnchor: 'middle'})
2413  node.filter(n => n.atoms.length).append('text.alabel')
2414    .text(n => n.atoms.join(' · ')).at({x: BW/2, y: BH/2 + 15, textAnchor: 'middle'})
2415
2416  var leg = gp.append('div.legend-row')
2417  ;[['J-space supernode', util.brand.manilla],
2418    ['non-J-space remainder term', util.tint(util.brand.manilla, 0.62)]].forEach(([t, c]) => {
2419    var li = leg.append('div.legend-item')
2420    li.append('div.legend-sq').st({background: c, border: '1px solid ' + util.gray[500]})
2421    li.append('span').text(t)
2422  })
2423  var li = leg.append('div.legend-item')
2424  li.append('svg').at({width: 22, height: 10})
2425    .append('path.edge').at({d: 'M0,5 L14,5', markerEnd: 'url(#jlens-cg-arrow)'})
2426  li.append('span').text('attribution edge')
2427
2428  // ── right panel: the original matplotlib swap plot, shipped as swaps.svg ──
2429  // title rendered by the page (same .fig-title as the left panel) so the two
2430  // panel headers read as siblings; the svg itself carries no title
2431  var bp = row.append('div.bars-panel')
2432  bp.append('div.fig-title').text('Coordinate swaps: probability of the implied answer')
2433  bp.append('img.swaps-img').at({
2434    src: datapath + 'swaps.svg',
2435    alt: 'Probability of the implied counterfactual answer before vs after each coordinate swap',
2436  })
2437}
2438
2439window.init?.()
2440
2441// ─── public/attn-broadcast-reselect/init-attn-broadcast-reselect.js ───
2442window.initAttnBroadcastReselect = async function(opts){
2443  var sel = d3.select('.attn-broadcast-reselect').html('')
2444  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2445  var d = await util.getFile(base + 'data.json')
2446
2447  // axis extent from all x/y values (plus a small pad so labels fit).
2448  // Gain ratio can't fall below 1's left pad and MRR delta can't be negative
2449  // in any meaningful way — domains start at exactly 1 and 0; whiskers that
2450  // dip past those bounds are clamped to the boundary.
2451  var xExt = d3.extent(d.populations.flatMap(p => p.x))
2452  var yExt = d3.extent(d.populations.flatMap(p => p.y))
2453  var pad = 0.1
2454  var clampX = v => Math.max(1, v)
2455  var clampY = v => Math.max(0, v)
2456
2457  sel.append('div.fig-title').text('Broadcast-head gain and label preservation by population')
2458
2459  var c = d3.conventions({
2460    sel: sel.append('div'),
2461    width: 460, height: 380,
2462    margin: {left: 56, right: 12, top: 18, bottom: 44},
2463  })
2464  c.x.domain([1, xExt[1] + pad])
2465  c.y.domain([0, yExt[1] + 0.04])
2466  c.xAxis.ticks(6); c.yAxis.ticks(6)
2467  c.drawAxis(); util.ggPlot(c)
2468  var yLabOffset = -5
2469  util.addAxisLabel(c, 'Gain[P] / gain[random] →', 'MRR[P] − MRR[random] →', '', 0, yLabOffset)
2470
2471  // ?tune=1 on the standalone page makes the y-axis label and title draggable (util.dragTune)
2472  var yLabG = d3.select(c.svg.select('.y .axis-label').node().parentNode)
2473  var titleSel = sel.select('.fig-title').st({position: 'relative'})
2474  util.dragTune([
2475    {sel: yLabG, key: 'y-label',
2476      get: () => ({x: yLabOffset - 30, y: c.height / 2}),
2477      set: p => yLabG.translate([p.x, p.y])},
2478    {sel: titleSel, key: 'title',
2479      get: () => ({x: 0, y: 0}),
2480      set: p => titleSel.st({left: p.x + 'px', top: p.y + 'px'})},
2481  ], 'attn-broadcast-reselect')
2482
2483  // baseline lines: x=1 (random gain), y=0 (random mrr)
2484  c.svg.append('line.ref-line')
2485    .at({x1: c.x(1), x2: c.x(1), y1: 0, y2: c.height})
2486  c.svg.append('line.ref-line')
2487    .at({x1: 0, x2: c.width, y1: c.y(0), y2: c.y(0)})
2488
2489  var relabel = {'Lens vectors (J)': 'J-lens vectors', 'J, rotated': 'J-lens rotated'}
2490  d.populations.forEach(p => {
2491    var hasIQR = p.x.length === 3
2492    var x50 = hasIQR ? p.x[1] : p.x[0], y50 = hasIQR ? p.y[1] : p.y[0]
2493    var mx = c.x(x50), my = c.y(y50)
2494    if (hasIQR) {
2495      c.svg.append('line.abr-whisker')
2496        .at({x1: c.x(clampX(p.x[0])), x2: c.x(clampX(p.x[2])), y1: my, y2: my, stroke: p.color})
2497      c.svg.append('line.abr-whisker')
2498        .at({x1: mx, x2: mx, y1: c.y(clampY(p.y[0])), y2: c.y(clampY(p.y[2])), stroke: p.color})
2499    }
2500    c.svg.append('circle.abr-mean')
2501      .at({cx: mx, cy: my, r: 6, fill: p.color})
2502    var a = p.anchor
2503    c.svg.append('text.abr-label')
2504      .text(relabel[p.label] || p.label)
2505      .translate([mx + a.dx, my + a.dy])
2506      .st({textAnchor: a.ta, fill: p.color})
2507  })
2508
2509}
2510
2511window.init?.()
2512
2513// ─── public/ablation-bars/init-ablation-bars.js ───
2514window.initAblationBars = async function({datapath}){
2515  var sel = d3.select('.ablation-bars').html('')
2516  var d = await util.getFile(datapath + 'bars.json')
2517  var mm = s => s.replace(/\bsonnet\b/gi, 'Sonnet 4.5').replace(/\bhaiku\b/gi, 'Haiku 4.5')
2518  var trColor = tr => tr.hatch ? util.gray[400]
2519    : util.ablationColors[tr.name] ?? tr.color  // lint-ok data-color-js: fallback
2520
2521  // title + legend in HTML so they stay visible while the wide SVG h-scrolls on mobile
2522  sel.append('div.fig-title').text(mm(d.title))
2523  sel.append('div.legend-row').appendMany('div.legend-item', d.traces).each(function(tr){
2524    d3.select(this).append('span.legend-sq').st({background: trColor(tr)})
2525    d3.select(this).append('span').text(mm(tr.name))
2526  })
2527
2528  var nT = d.tasks.length
2529  var c = d3.conventions({
2530    sel: sel.append('div.ab-scroll').append('div'),
2531    width: 1020,
2532    height: 320,
2533    margin: {left: 52, right: 8, top: 22, bottom: 88},
2534    layers: 's',
2535  })
2536
2537  var x0 = d3.scaleBand().domain(d3.range(nT)).range([0, c.width])
2538    .paddingInner(0.16).paddingOuter(0.05)
2539  var x1 = d3.scaleBand().domain(d3.range(d.traces.length))
2540    .range([0, x0.bandwidth()]).paddingInner(0.12)
2541  c.y.domain([0, 1])
2542
2543  // axes — gray panel + white gridlines from the shared ggPlot helper
2544  c.x.domain([0, 1])
2545  c.yAxis.ticks(6).tickFormat(d3.format('.0%'))
2546  c.drawAxis()
2547  c.svg.select('.x').remove()
2548  util.ggPlot(c)
2549  util.addAxisLabel(c, '', mm(d.ylabel), '', 0, -10)
2550
2551  // 2-line x-tick labels
2552  c.svg.append('g.x-ticks').translate([0, c.height + 6])
2553    .appendMany('text.tick-label', d3.range(nT))
2554    .translate(i => [x0(i) + x0.bandwidth()/2, 0])
2555    .at({textAnchor: 'middle'})
2556    .each(function(i){
2557      var parts = d.display[i].split(/ (.+)/)
2558      d3.select(this).append('tspan').text(parts[0]).at({x: 0, dy: '0.9em'})
2559      if (parts[1]) d3.select(this).append('tspan').text(parts[1]).at({x: 0, dy: '1.15em'})
2560    })
2561
2562  // bars + asymmetric error bars
2563  var g = c.svg.appendMany('g.task', d3.range(nT)).translate(i => [x0(i), 0])
2564  d.traces.forEach((tr, j) => {
2565    var fill = trColor(tr)
2566    g.append('rect.bar').at({
2567      x: x1(j), width: x1.bandwidth(), fill,
2568      y: i => c.y(tr.y[i]), height: i => c.height - c.y(tr.y[i]),
2569      
2570    })
2571    var ex = x1(j) + x1.bandwidth()/2, cap = Math.min(2, x1.bandwidth()*0.35)
2572    g.append('path.err').at({d: i =>
2573      `M${ex},${c.y(tr.lo[i])} V${c.y(tr.hi[i])}` +
2574      ` M${ex-cap},${c.y(tr.lo[i])} h${2*cap}` +
2575      ` M${ex-cap},${c.y(tr.hi[i])} h${2*cap}`,
2576      stroke: util.gray[700],
2577    })
2578  })
2579
2580  // group brackets + 2-line captions
2581  var br = c.svg.append('g.brackets').translate([0, c.height])
2582  var yTip = 38, yBar = 46, yTxt = 52
2583  d.groups.forEach(gr => {
2584    var x0p = x0(gr.x0) - x0.step()*0.03
2585    var x1p = x0(gr.x1) + x0.bandwidth() + x0.step()*0.03
2586    br.append('path').at({d: `M${x0p},${yTip} V${yBar} H${x1p} V${yTip}`})
2587    var t = br.append('text').translate([(x0p+x1p)/2, yTxt]).at({textAnchor: 'middle'})
2588    gr.caption.split('<br>').forEach((line, k) =>
2589      t.append('tspan').text(line).at({x: 0, dy: k ? '1.15em' : '0.9em'}))
2590  })
2591}
2592
2593window.init?.()
2594
2595// ─── public/ablation-examples/init-ablation-examples.js ───
2596window.initAblationExamples = async function({datapath}){
2597  var pctfirst = new URLSearchParams(location.search).has('pctfirst')
2598  var sel = d3.select('.ablation-examples').html('').classed('pctfirst', pctfirst)
2599  var chips = await util.getFile(datapath + 'chips.json')
2600
2601  // Linear KL→Oranges; the colormap's 0-end is a warm cream (#fff5eb), so for
2602  // t<0.05 lerp from true white into Oranges(0.0465) — continuous at the join,
2603  // and t≥0.05 follows the Oranges hue path unchanged.
2604  var orng = d3.interpolateOranges, EPS = 0.05
2605  var ramp = t => t < EPS
2606    ? d3.interpolateRgb('#fff', orng(0.93*EPS))(t/EPS)
2607    : orng(0.93*t)
2608  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
2609  var allow = s => esc(s)
2610    .replaceAll('&lt;b&gt;','<b>').replaceAll('&lt;/b&gt;','</b>')
2611    .replaceAll('&lt;i&gt;','<i>').replaceAll('&lt;/i&gt;','</i>')
2612    .replaceAll('&lt;br&gt;','<br>')
2613  // marker-only tokens (↑ ⍽ ⇪) strip to '' — show the raw marker, not ∅
2614  var lbl = s => { var p = util.ppCell(s); return p === '∅' ? s : p }
2615
2616  // Drop prediction rows whose token renders as the replacement glyph (U+FFFD)
2617  // or decodes to empty after marker stripping — they're partial/invalid byte
2618  // sequences from the tokenizer, not real predictions.
2619  var isJunk = t => /\uFFFD/.test(t) || util.ppToken(t).trim() === ''
2620  function pred(rows, n){
2621    rows = (rows || []).filter(([t]) => !isJunk(t))
2622    return d3.range(n).map(i => {
2623      var [t, p] = rows[i] || [' ', 0]
2624      var tx = esc(util.ppCell(t)), pct = rows[i] ? (p*100).toFixed(0)+'%' : ''
2625      return `<div class="row" title="${tx}"><div class="bar" style="width:${(p*100).toFixed(1)}%"></div>` +
2626        (pctfirst ? `<span class="p">${pct}</span><span class="t">${tx}</span>`
2627                  : `<span class="t">${tx}</span><span class="p">${pct}</span>
2627`) + `</div>`
2628    }).join('')
2629  }
2630
2631  sel.append('div.chip-grid').appendMany('div.abl-chip', chips).each(function(D){
2632    var chip = d3.select(this)
2633    var head = chip.append('div.ch-head')
2634    head.append('span.ch-title').text(D.title)
2635      .append('span.ch-tag').text(' ' + (D.tag || ''))
2636    if (D.analysis) head.append('span.ch-help').html(
2637      `Analysis <span class="q">?</span><span class="tip">${allow(D.analysis)}</span>`)
2638    if (D.anno) chip.append('div.ch-anno').html(allow(D.anno))
2639
2640    var strip = chip.append('div.ch-strip.prompt-block')
2641    var elide = D.lo > 1
2642    if (elide) strip.append('span.ellipsis').text('[...]')
2643    var prevNl = false
2644    var spans = strip.appendMany('span.ct', d3.range(D.toks.length))
2645      .each(function(i){
2646        var t = D.toks[i], x = D.kl[i] / D.vmax
2647        if (i === 0 && elide) t = t.replace(/^\s+/, '')
2648        var disp = util.ppToken(t, true)  // strip case/space markers, keep \n
2649        disp = disp.replace(/\n+/g, m => '⏎'.repeat(m.length) + '\n')
2650        if (prevNl) disp = disp.replace(/^(⏎+)\n/, '$1')
2651        prevNl = t.endsWith('\n')
2652        // Marker-only tokens (↑ cap, ⍽ space) strip to '' — keep the marker
2653        // glyph so the span has width and stays clickable.
2654        if (!disp.trim()) disp = t || '∅'
2655        var bg = ramp(x)
2656        d3.select(this).text(disp)
2657          .st({background: bg, color: d3.hsl(bg).l < 0.6 ? '#fff' : null})
2658      })
2659
2660    var sub = chip.append('div.ch-sub')
2661    var nSel = d3.max(D.sel, s => s.length), nPred = d3.max(D.base, b => b.length)
2662    var cSel = sub.append('div.ch-col')
2663    var hSel = cSel.append('h6.mlabel').html('Top Ablated <span class="pos"></span>')
2664    var dSel = cSel.append('div.c-sel')
2665    var cBase = sub.append('div.ch-col')
2666    cBase.append('h6.mlabel').html('Before <span class="unit">(prob)</span>')
2667    var dBase = cBase.append('div.c-base')
2668    var cAbl = sub.append('div.ch-col')
2669    var hAbl = cAbl.append('h6.mlabel').html('After <span class="kl"></span>')
2670    var dAbl = cAbl.append('div.c-abl')
2671
2672    var posLbl = hSel.select('.pos'), klLbl = hAbl.select('.kl')
2673    var cur = -1
2674    function show(i){
2675      if (i === cur) return
2676      spans.classed('tok-hit', j => j === i)
2677      cur = i
2678      posLbl.text(`"${lbl(D.raw[i])}"`)
2679      var k = D.kl[i]
2680      klLbl.text('(KL=' + (k >= 10 ? k.toFixed(1) : k.toFixed(2)) + ')')
2681      var half = Math.ceil(nSel / 2)
2682      var sel_filtered = (D.sel[i] || []).filter(t => t == null || !isJunk(t))
2683      dSel.html(d3.range(nSel).map(r => {
2684        var t = sel_filtered[r], col = r < half ? 1 : 2, row = (r % half) + 1
2685        var tx = t == null ? '' : esc(util.ppCell(t))
2686        return `<div class="sel-row" title="${tx}" style="grid-column:${col};grid-row:${row}">` +
2687          `<span class="rk">${r+1}</span>${tx}</div>`
2688      }).join(''))
2689      dBase.html(pred(D.base[i], nPred))
2690      dAbl.html(pred(D.abl[i], nPred))
2691    }
2692    spans.on('mouseenter', (e, i) => show(i))
2693    strip.on('mouseleave', () => show(D.default))
2694    show(D.default)
2695  })
2696
2697  var leg = sel.append('div.abl-legend')
2698  leg.append('span.leg-label').text('Token shading = KL after ablating that position')
2699  leg.append('span.leg-end').text('max')
2700  leg.append('svg').at({width: 120, height: 10})
2701    .appendMany('rect', d3.range(40))
2702    .at({x: i => i*3, width: 4, height: 10, fill: i => ramp(1 - i/39)})
2703  leg.append('span.leg-end').text('0')
2704}
2705
2706window.init?.()
2707
2708// ─── public/ablation-strength/init-ablation-strength.js ───
2709window.initAblationStrength = async ({datapath}) => {
2710  const sel = d3.select('.ablation-strength').html('')
2711  const d = await util.getFile(datapath + 'table.json')
2712  const pct = v => (v * 100).toFixed(0) + '%'
2713
2714  sel.append('div.fig-title').html(
2715    `Ablation strength <span class='sub'>multihop n=${d.n_items}, ` +
2716    `clean ${pct(d.clean_acc)} · PT n=${d.n_pos.toLocaleString()}</span>`
2717  )
2718  const hasLayers = d.rows[0]?.layers != null
2719  const tbl = sel.append('table')
2720  tbl.append('thead').append('tr').html(
2721    '<th class="mlabel">ablation</th>' + (hasLayers ? '<th class="mlabel">layers ablated</th>' : '') +
2722    '<th class="mlabel">multihop acc</th><th class="mlabel">PT top-1 match</th>'
2723  )
2724  tbl.append('tbody').appendMany('tr', d.rows).each(function (r) {
2725    const tr = d3.select(this).classed('control', r.name === 'random')
2726    var rc = util.ablationColors[r.name] ?? r.color  // lint-ok data-color-js: fallback
2727    tr.append('td').html(
2728      `<span class='swatch' style='background:${rc}'></span>${r.name}` +
2729      (r.name === 'random' ? `<span class='control-note'>control</span>` : '')
2730    )
2731    if (hasLayers) tr.append('td.layers').text(r.layers)
2732    tr.append('td').text(pct(r.mh_acc))
2733    tr.append('td').text(pct(r.pt_acc))
2734  })
2735}
2736
2737window.init?.()
2738
2739// ─── public/blackmail-clamp/init-blackmail-clamp.js ───
2740!function(){
2741
2742async function loadData(opts){
2743  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2744  return await util.getFile(base + 'data.json')
2745}
2746
2747var CONDS = [
2748  {key: 'clean', label: 'No ablation', long: 'Baseline'},
2749  {key: 'clamped_active', label: 'Ablated', long: 'After ablating eval awareness\nJ-lens vectors'},
2750]
2751
2752var METRICS = [
2753  {key: 'ea', lines: ['Verbalized', 'eval awareness'], label: 'Verbalized eval awareness', color: util.series[0],
2754   exLabel: 'Verbalized Eval Awareness'},
2755  {key: 'bl', lines: ['Blackmail', 'rate'],  label: 'Blackmail rate',  color: util.series[5],
2756   exLabel: 'Attempted Blackmail'},
2757]
2758
2759function drawPanel(sel, cond, d){
2760  var prompts = [...new Set(d.per_prompt.map(r => r.prompt))].sort()
2761  var c = d3.conventions({
2762    sel,
2763    width: 240, height: 230,
2764    margin: {left: 56, top: 38, right: 10, bottom: 32},
2765  })
2766  var x = d3.scaleBand().domain(METRICS.map(d => d.key)).range([0, c.width]).padding(0.32)
2767  c.y.domain([0, 1])
2768  c.yAxis.ticks(4).tickFormat(d3.format('.0%'))
2769  c.drawAxis()
2770  c.svg.select('.x').remove()
2771  util.ggPlot(c)
2772  util.addAxisLabel(c, '', '')
2773  var lines = cond.long.split('\n')
2774  var tt = c.svg.append('g.axis').translate([c.width/2, -10 - (lines.length - 1)*14])
2775    .append('text.axis-label.axis-title').at({textAnchor: 'middle'})
2776  lines.forEach((ln, i) => tt.append('tspan').text(ln).at({x: 0, dy: i ? 14 : 0}))
2777
2778  METRICS.forEach(metric => {
2779    var a = d.agg[cond.key]
2780    var p = a[metric.key] / a.n
2781    var ci = a[metric.key + '_ci']
2782    var bx = x(metric.key), bw = x.bandwidth()
2783    var cx = bx + bw/2
2784    c.svg.append('rect')
2785      .at({x: bx, width: bw, y: c.y(p), height: c.height - c.y(p), fill: metric.color})
2786    util.barWhisker(c.svg, {x: cx, lo: c.y(ci[0]), hi: c.y(ci[1]), stroke: util.gray[900]})
2787    prompts.forEach((pr, j) => {
2788      var row = d.per_prompt.find(r => r.prompt === pr && r.condition === cond.key)
2789      if (!row) return
2790      var v = row[metric.key]
2791      var jitter = 0.45 * bw * ((j / Math.max(prompts.length - 1, 1)) - 0.5)
2792      c.svg.append('circle.jpt').datum({row, v, metric}).at({
2793        cx: cx + jitter, cy: c.y(v / row.n),
2794        r: 2.2, fillOpacity: 0, stroke: '#000', strokeWidth: 1,
2795      })
2796    })
2797    var lab = c.svg.append('text')
2798      .at({x: cx, y: c.height + 14, textAnchor: 'middle'})
2799      .st({fontSize: 'var(--fs-body)'})
2800    metric.lines.forEach((ln, i) =>
2801      lab.append('tspan').text(ln).at({x: cx, dy: i ? 12 : 0}))
2802  })
2803}
2804
2805function drawCard(sel, ex, metric){
2806  sel.append('div.ex-label').text(metric.exLabel)
2807  var body = sel.append('div.excerpt')
2808  var h = ex.hilite
2809  if (h && h[0] >= 0){
2810    body.append('span').text(ex.text.slice(0, h[0]))
2811    body.append('mark').text(ex.text.slice(h[0], h[1]))
2812      .st({background: util.tint(metric.color, 0.85)})
2813    body.append('span').text(ex.text.slice(h[1]))
2814  } else {
2815    body.text(ex.text)
2816  }
2817}
2818
2819window.initBlackmailClamp = async function(opts){
2820  var sel = d3.select('.blackmail-clamp').html('')
2821  var d = await loadData(opts)
2822  // 2×2 grid: each row = (panel, excerpt card). Grid row height is driven
2823  // by the panel SVG; the card stretches to match, so the two columns stay
2824  // vertically aligned regardless of margin tweaks.
2825  var grid = sel.append('div.bc-row')
2826  CONDS.forEach((cond, i) => {
2827    drawPanel(grid.append('div.panel'), cond, d)
2828    drawCard(grid.append('div.card'), d.excerpts[i], METRICS[i])
2829  })
2830}
2831
2832}()
2833window.init?.()
2834
2835// ─── public/lensapp-panel/init-lensapp-panel.js ───
2836window.initLensappPanel = async function(opts){
2837  var slug = opts.slug
2838  var sel = d3.select(`.lensapp-panel[data-slug="${slug}"]`).html('')
2839  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2840  var d = await util.getFile(base + slug + '.json')
2841
2842  // Same orange ramp as feature-examples; text flips to white once bg is dark.
2843  var orange = t => d3.interpolateOranges(0.1 + 0.55*t)
2844  function exRow(host, ex, gmax, cls){
2845    var row = host.append('div.exrow.prompt-block' + (cls ? '.' + cls : ''))
2846      .at({title: ex.chunks.map(([s]) => s).join('')})
2847    if (ex.lo > 0) row.append('span.ell').text('…')
2848    ex.chunks.forEach(([s, a]) => {
2849      var sp = row.append('span.t').text(s)
2850      if (a > 0){
2851        var t = Math.max(0, Math.min(1, a / (gmax || 1)))
2852        var bg = orange(t)
2853        sp.st({background: bg}).classed('dark', d3.hsl(bg).l < 0.6)
2854      }
2855    })
2856    if (ex.hi < ex.n) row.append('span.ell').text('…')
2857  }
2858
2859  var titleCase = s => s.replace(/\w+/g, (w, i) =>
2860    i && /^(a|an|and|or|the|in|of|on|to|for)$/i.test(w) ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1))
2861
2862  var card = sel.append('div.card')
2863  card.append('div.fig-title').text(titleCase(d.label))
2864  var cols = card.append('div.cols')
2865
2866  var left = cols.append('div')
2867  left.append('div.mlabel').text('J-lens top tokens')
2868  left.append('div.toks').appendMany('span', d.top_tokens).text(t => t)
2869  left.append('div.mlabel.gap').text('Activating examples')
2870  d.examples.forEach(ex => exRow(left, ex, d.gmax))
2871  if (d.prompt_acts){
2872    left.append('div.mlabel.gap').text('…on an agentic misalignment eval (right)')
2873    exRow(left, d.prompt_acts, d.prompt_acts.gmax, 'prompt-row')
2874  }
2875
2876  var right = cols.append('div')
2877  right.append('div.mlabel').text('Steering prompt')
2878  right.append('div.prompt').text(d.prompt)
2879  right.append('div.mlabel.gap').text('Steering completions')
2880  var roll = right.append('div.roll')
2881  d.rollouts.forEach(r => {
2882    var lab = roll.append('div.rlab')
2883    lab.append('div').text(r.label)
2884    if (r.ann) lab.append('div.ann').text(r.ann)
2885    roll.append('div.rbracket')
2886    var rtxt = roll.append('div.rtxt').text(r.text)
2887    if (r.trunc !== false) rtxt.append('span.trunc').text(' […]')
2888  })
2889}
2890
2891window.init?.()
2892
2893// ─── public/reflection-training/init-reflection-training.js ───
2894window.initReflectionTraining = async function(opts){
2895  var sel = d3.select(opts?.sel || '.reflection-training').html('')
2896  var base = (opts?.datapath || './').replace(/\/?$/, '/')
2897  var d = await util.getFile(base + 'reflection.json')
2898  // Per-eval wording lives in the baked JSON (reflection-fabrication vs
2899  // reflection-deception reuse this init); fall back to the fabrication text.
2900  var L = d.labels || {}
2901  // 'no substantive report' is baked as a light gray that washes into the
2902  // ggPlot panel bg — darken render-side so the sliver and legend stay visible
2903  d.split.classes.forEach(cl => { if (cl.cls == 'no substantive report') cl.color = util.gray[700] })
2904
2905  // haiku → util.modelColors.haiku per the shared model→color map; orange is
2906  // otherwise reserved for activation shading. reflection-finetune is a new
2907  // experiment family — use the jlens series color (blue).
2908  var modelColor = {haiku: util.modelColors.haiku, reflection: util.tol.blue}
2909  var modelLabel = {haiku: ['Claude', 'Haiku 4.5'], reflection: ['reflection', 'fine-tune']}
2910  var H = 310
2911
2912  var figTitle = opts?.figTitle ?? L.figTitle
2913  if (figTitle) sel.append('div.fig-title').text(figTitle)
2914  var row = sel.append('div.panel-row')
2915
2916  // ── A: dishonesty headline ──
2917  var pA = row.append('div.panel')
2918  pA.append('div.fig-title').text('A: ' + (L.titleA ?? 'Reflection training reduces dishonesty'))
2919  pA.append('div.psub').text(L.subA ??
2920    `Dishonesty score on the eval (27 cases × 5 reps; n=${d.eval.haiku.n}, n=${d.eval.reflection.n}).`)
2921  var ms = ['haiku', 'reflection']
2922  var ymax = d3.max(ms, m => d.eval[m].hi) + 0.04
2923  var cA = d3.conventions({
2924    sel: pA.append('div'), width: 244, height: H,
2925    margin: {left: 52, top: 18, right: 6, bottom: 38},
2926  })
2927  var xb = d3.scaleBand().domain(ms).range([0, cA.width]).padding(0.3)
2928  cA.y.domain([0, ymax])
2929  cA.xAxis.tickFormat(m => '').tickSize(0)
2930  cA.yAxis.ticks(5)
2931  cA.drawAxis(); util.ggPlot(cA)
2932  cA.svg.selectAll('.x .tick path').remove()
2933  util.addAxisLabel(cA, '', L.yA ?? 'Dishonesty score →', '', 0, -8)
2934  ms.forEach(m => {
2935    var e = d.eval[m], cx = xb(m) + xb.bandwidth()/2
2936    cA.svg.append('rect').at({x: xb(m), width: xb.bandwidth(), y: cA.y(e.dishonesty),
2937      height: cA.height - cA.y(e.dishonesty), fill: modelColor[m]})  // lint-ok data-color-js: shared model→color map
2938    util.barWhisker(cA.svg, {x: cx, lo: cA.y(e.lo), hi: cA.y(e.hi)})
2939    cA.svg.append('text.num').text(e.dishonesty.toFixed(2))
2940      .at({x: cx, y: cA.y(e.hi) - 6, textAnchor: 'middle', fontSize: 'var(--fs-small)', fill: util.gray[700]})
2941    modelLabel[m].forEach((s, i) => cA.svg.append('text').text(s)
2942      .at({x: cx, y: cA.height + 14 + i*12, textAnchor: 'middle', fontSize: 'var(--fs-small)', fill: util.gray[600]}))
2943  })
2944
2945  // ── B: spike-rate token table ──
2946  var nTok = d.tokens.n_show ?? 18
2947  var rows = d.tokens.rows.slice(0, nTok)
2948  var pB = row.append('div.panel')
2949  pB.append('div.fig-title').text('B: ' + (L.titleB ?? 'Workspace content shifts with finetuning'))
2950  pB.append('div.psub').text(
2951    (L.subB ??
2952      `Top {n} tokens by increase in J-lens top-${d.tokens.topk} ` +
2953      `spike rate over the last ${d.tokens.n_tail} prompt tokens.`
2954    ).replace('{n}', nTok))
2955  var tab = pB.append('table.rktab')
2956  var hr = tab.append('thead')
2957  hr.append('tr').html(
2958    '<th rowspan=2>token</th><th colspan=3>% prompts</th><th colspan=3>% positions</th>')
2959  hr.append('tr').html(
2960    '<th>haiku</th><th></th><th>refl.</th><th>haiku</th><th></th><th>refl.</th>')
2961  tab.append('tbody').appendMany('tr', rows).html(r =>
2962    `<td class='tk'>${util.ppToken?.(r.tok) ?? r.tok}</td>` +
2963    `<td>${(r.base_pcase*100).toFixed(0)}</td><td class='arr'>→</td>` +
2964    `<td>${(r.refl_pcase*100).toFixed(0)}</td>` +
2965    `<td>${(r.base_ppos*100).toFixed(1)}</td><td class='arr'>→</td>` +
2966    `<td>${(r.refl_ppos*100).toFixed(1)}</td>`)
2967
2968  // ── C: grader split ──
2969  var pC = row.append('div.panel')
2970  pC.append('div.fig-title').text('C: ' + (L.titleC ?? 'Workspace ablation reverts behavior'))
2971  pC.append('div.psub').text(L.subC ??
2972    `Ablate the top-10 ethics directions per position at workspace layers.`)
2973  var split = pC.append('div.split-wrap')
2974  var n = d.split.conds.length
2975  var cC = d3.conventions({
2976    sel: split.append('div'), width: 290, height: H - 58,
2977    margin: {left: 52, top: 30, right: 6, bottom: 38},
2978  })
2979  var xs = d3.scaleBand().domain(d3.range(n)).range([0, cC.width]).padding(0.18)
2980  cC.y.domain([0, 1])
2981  cC.xAxis.tickValues(d3.range(n)).tickFormat(() => '').tickSize(0)
2982  cC.yAxis.ticks(5).tickFormat(d3.format('.0%'))
2983  cC.drawAxis(); util.ggPlot(cC)
2984  cC.svg.selectAll('.x .tick path').remove()
2985  util.addAxisLabel(cC, '', 'Fraction →', L.axisC ?? 'Mean dishonesty', 0, -8, -8)
2986  var cum = new Array(n).fill(0)
2987  d.split.classes.forEach(cl => {
2988    cl.frac.forEach((f, i) => {
2989      cC.svg.append('rect').at({x: xs(i), width: xs.bandwidth(),
2990        y: cC.y(cum[i] + f), height: cC.y(cum[i]) - cC.y(cum[i] + f), fill: cl.color})  // lint-ok data-color-js: ordinal good→bad ramp
2991      cum[i] += f
2992    })
2993  })
2994  d.split.conds.forEach((c, i) => {
2995    var cx = xs(i) + xs.bandwidth()/2
2996    cC.svg.append('text.num').text(d.split.dishonesty[i].toFixed(2))
2997      .at({x: cx, y: -6, textAnchor: 'middle', fontSize: 'var(--fs-small)', fill: util.gray[700]})
2998    ;[c.model, c.cond].forEach((s, j) => cC.svg.append('text').text(s)
2999      .at({x: cx, y: cC.height + 14 + j*12, textAnchor: 'middle', fontSize: 'var(--fs-small)', fill: util.gray[600]}))
3000  })
3001  var leg = split.append('div.split-legend')
3002  // cl.color: bake-side semantic green→red ordinal ramp; lint-ok data-color-js
3003  leg.appendMany('div.lrow', d.split.classes).html(cl =>
3004    `<span class='legend-sq' style='background:${cl.color}'></span>` +  // lint-ok data-color-js
3005    `${cl.cls} <span class='rub'>(${cl.dishonesty == null ? '–' : cl.dishonesty.toFixed(1)})</span>`)
3006}
3007
3008window.init?.()
3009
3010// ─── public/dn-tracecond/init-dn-tracecond.js ───
3011window.initDnTracecond = async function(opts){
3012  var sel = d3.select('.dn-tracecond').html('')
3013  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3014  var d = await util.getFile(base + 'data.json')
3015
3016  // Same condition palette as the selfreport/ablation figures: gray
3017  // baseline, red J-space ablation (nofinal = darker red), blue-tint
3018  // controls. Any condition not in the data is dropped.
3019  var allConds = [
3020    {key: 'clean',   label: 'baseline',                            color: util.gray[400]},
3021    {key: 'ablate',  label: 'J-space ablated',                     color: util.tol.red},
3022    {key: 'nofinal', label: 'J-space ablated (final pos. spared)', color: '#7a1f1f'},
3023    {key: 'ortho',   label: 'non-J-space control',                 color: util.tint(util.tol.blue, 0.25)},
3024    {key: 'random',  label: 'random control',                      color: util.tint(util.tol.blue, 0.55)},
3025  ]
3026  var params = new URLSearchParams(location.search)
3027  var hide = (params.get('hide') || '').split(',')
3028  var CONDS = allConds.filter(c =>
3029    d.curves[d.trials[0]][c.key]?.length && !hide.includes(c.key))
3030
3031  // Shared y across the three panels so the eye can compare.
3032  var lo = d3.min(d.trials, tt => d3.min(CONDS,
3033    cn => d3.min(d.curves[tt][cn.key], p => p.mean - p.se)))
3034  var xi = Object.fromEntries(d.gaps.map((g, j) => [g, j]))
3035
3036  var row = sel.append('div.tc-row')
3037  drawSetup(row.append('div.tc-setup'), d.example)
3038  d.trials.forEach((t, i) => drawPanel(row.append('div.tc-panel'), t, i))
3039  drawLegend(sel)
3040
3041  function drawSetup(sel, ex){
3042    sel.append('div.fig-title').text('A')
3043    sel.append('div.mlabel').text('Demonstrations')
3044    var lines = sel.append('div.tc-lines.demo')
3045    ex.demos.forEach(([cue, fill, out]) => addLine(lines, cue, fill, out, false))
3046    lines.append('div.tc-ellip').text('…')
3047    sel.append('div.mlabel').text('Test')
3048    var test = sel.append('div.tc-lines')
3049    addLine(test, ex.test[0], ex.test[1], ex.test[2], true)
3050    var brace = sel.append('div.tc-brace')
3051    brace.append('span.bspan').text('cue')
3052    brace.append('span.bspan.fill').text(`k filler words`)
3053    brace.append('span.bspan').text('outcome')
3054
3055    function addLine(lines, cue, fill, out, isTest){
3056      var ln = lines.append('div.tc-line')
3057      ln.append('span.tc-cue').text(cue)
3058      fill.forEach(w => ln.append('span.tc-fill').text(w))
3059      ln.append('span.tc-out').classed('test', isTest).text(isTest ? '?' : out)
3060    }
3061  }
3062
3063  function drawPanel(sel, t, i){
3064    sel.append('div.fig-title')
3065      .text(String.fromCharCode(66 + i) + '   ' + t + ' demos / pair')
3066    var pw = d.trials.length > 3 ? 168 : 220
3067    var c = d3.conventions({
3068      sel: sel.append('div'), width: pw, height: 240,
3069      margin: {left: i ? 14 : 50, right: 8, top: 6, bottom: 36},
3070    })
3071    c.x.domain([0, d.gaps.length - 1])
3072    c.y.domain([lo * 1.05, 0]).clamp(true)
3073    c.xAxis.tickValues(d3.range(d.gaps.length)).tickFormat(j => d.gaps[j])
3074    c.yAxis.ticks(5)
3075    c.drawAxis()
3076    if (i) c.svg.select('.y').selectAll('text').remove()
3077    util.ggPlot(c)
3078    util.addAxisLabel(c, 'gap k (filler words)', i ? '' : 'log P(correct outcome)')
3079
3080    var px = p => c.x(xi[p.k])
3081    var line = d3.line().x(px).y(p => c.y(p.mean))
3082    var area = d3.area().x(px)
3083      .y0(p => c.y(p.mean - p.se)).y1(p => c.y(p.mean + p.se))
3084    CONDS.forEach(cn => {
3085      var pts = d.curves[t][cn.key]
3086      c.svg.append('path').at({d: area(pts), fill: cn.color, opacity: 0.18})
3087      c.svg.append('path')
3088        .at({d: line(pts), fill: 'none', stroke: cn.color, strokeWidth: 2.2})
3089      c.svg.appendMany('circle', pts)
3090        .at({r: 2.6, fill: cn.color})
3091        .translate(p => [px(p), c.y(p.mean)])
3092    })
3093  }
3094
3095  function drawLegend(sel){
3096    var leg = sel.append('div.legend-row').st({justifyContent: 'center'})
3097    CONDS.forEach(cn => {
3098      var item = leg.append('div.legend-item')
3099      item.append('span.swatch').st({background: cn.color})
3100      item.append('span').text(cn.label)
3101    })
3102  }
3103}
3104
3105window.init?.()
3106
3107// ─── public/dn-exclusion/init-dn-exclusion.js ───
3108window.initDnExclusion = async function(opts){
3109  var sel = d3.select('.dn-exclusion').html('')
3110  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3111  var d = await util.getFile(base + 'data.json')
3112
3113  // Bar palette: gray baseline, red primed-concept early, lighter red
3114  // primed-concept late, blue other-concept matched-norm control.
3115  var COLORS = {
3116    'clean': util.gray[400],
3117    'target_p913': util.tol.red,
3118    'target_p1822': '#d99494',
3119    'sibling_p913': util.tint(util.tol.blue, 0.45),
3120  }
3121
3122  var row = sel.append('div.ex-row')
3123  drawSetup(row.append('div.ex-setup'), d.example)
3124  ;[['incl', 'B', 'Name the primed answer'],
3125    ['excl', 'C', 'Avoid the primed answer']].forEach(([task, letter, title], i) => {
3126    var bars = d.tasks[task]
3127    var bw = 104
3128    var psel = row.append('div.ex-panel')
3129    psel.append('div.fig-title').text(letter + '   ' + title)
3130    var c = d3.conventions({
3131      sel: psel.append('div'),
3132      width: bw * bars.length, height: 278,
3133      margin: {left: 46, right: 8, top: 6, bottom: 38},
3134    })
3135    c.x.domain([-0.6, bars.length - 0.4])
3136    c.y.domain([0, 1])
3137    c.xAxis.tickValues(d3.range(bars.length)).tickFormat(j => '')
3138    c.yAxis.ticks(5)
3139    c.drawAxis()
3140    util.ggPlot(c)
3141    util.addAxisLabel(c, '', 'P(primed answer)')
3142    // multi-line tick labels
3143    c.svg.select('.x').selectAll('.tick').append('foreignObject')
3144      .at({x: -bw/2 + 3, y: 4, width: bw - 6, height: 36})
3145      .append('xhtml:div').at({class: 'ex-xtick'}).text((_, j) => bars[j].label)
3146
3147    var bf = 0.45
3148    bars.forEach((b, j) => {
3149      c.svg.append('rect').at({
3150        x: c.x(j - bf/2), width: c.x(j + bf/2) - c.x(j - bf/2),
3151        y: c.y(b.mean), height: c.y(0) - c.y(b.mean),
3152        fill: COLORS[b.key],
3153      })
3154      // 95% CI
3155      c.svg.append('path').at({
3156        d: `M${c.x(j)},${c.y(b.mean - b.ci)} V${c.y(b.mean + b.ci)} ` +
3157           `M${c.x(j) - 4},${c.y(b.mean - b.ci)} h8 ` +
3158           `M${c.x(j) - 4},${c.y(b.mean + b.ci)} h8`,
3159        stroke: '#333', strokeWidth: 1.2, fill: 'none',
3160      })
3161      // per-item dots
3162      var n = b.vals.length
3163      c.svg.appendMany('circle', b.vals).at({
3164        r: 1.8, fill: 'none', stroke: '#444', strokeWidth: 0.5,
3165      }).translate((v, k) => [
3166        c.x(j - bf*0.4 + bf*0.8 * k/(n-1)), c.y(v),
3167      ])
3168    })
3169  })
3170
3171  function drawSetup(sel, ex){
3172    sel.append('div.fig-title').text('A')
3173    sel.append('div.mlabel').text('Priming sentence')
3174    sel.append('div.ex-box.prime-box').text(`Here is a sentence: "${ex.sentence}"`)
3175    var imp = sel.append('div.ex-implies')
3176    imp.append('div.ex-arrow').text('↓')
3177    imp.append('div.ex-implies-label').text('implies')
3178    var pr = imp.append('div.ex-prime-row')
3179    pr.append('div.ex-prime').text(ex.prime)
3180    pr.append('span.ex-anno').text('← Ablate this J-lens vector')
3181    var qrow = sel.append('div.ex-qrow')
3182    ;[['Name', ex.name_q], ['Avoid', ex.avoid_q]].forEach(([h, q]) => {
3183      var col = qrow.append('div.ex-qcol')
3184      col.append('div.mlabel').text(h)
3185      col.append('div.ex-box').text(q)
3186    })
3187    sel.append('div.mlabel').text('Prefilled response')
3188    var pf = sel.append('div.ex-box.ex-prefill')
3189    pf.append('span').text(ex.prefill)
3190    pf.append('span.ex-meas').text('[sample next token]')
3191  }
3192}
3193
3194window.init?.()
3195
3196// ─── public/refl-training-examples/init-refl-training-examples.js ───
3197window.initReflTrainingExamples = async function(opts){
3198  var sel = d3.select('.refl-training-examples').html('')
3199  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3200  var d = await util.getFile(base + 'data.json')
3201
3202  var panels = sel.append('div.rte-panels')
3203    .appendMany('div.rte-panel', d.examples)
3204
3205  var titles = panels.append('div.fig-title')
3206  titles.append('span.rte-chip').text((e, i) => String.fromCharCode(65 + i))
3207  titles.append('span').text(e => e.label.replace(/^\([a-z]\)\s*/i, ''))
3208
3209  panels.append('div.rte-ctx').text(e => e.context)
3210
3211  // Preceding transcript: one role-boxed turn per snippet entry. Bracketed
3212  // tool-call summaries and thinking-tag lines render as muted italic; the
3213  // remaining lines are the literal code/output in mono.
3214  panels.append('div.rte-snippet')
3215    .appendMany('div.rte-turn', e => e.snippet || [])
3216    .each(function(t){
3217      var turn = d3.select(this).classed('rte-turn-' + t.role.toLowerCase(), true)
3218      turn.append('div.rte-role').text(t.role === 'tool' ? 'Tool result' : t.role)
3219      turn.append('pre.rte-text').selectAll('span')
3220        .data(t.text.split('\n')).enter().append('span')
3221        .attr('class', s => (s.trimStart().startsWith('⟨') || /thinking>/.test(s)) ? 'rte-summary' : null)
3222        .text((s, i) => (i ? '\n' : '') + s)
3223    })
3224
3225  // Reflection probe = the Human turn that elicits the reflection.
3226  var probe = panels.append('div.rte-turn.rte-turn-human')
3227  probe.append('div.rte-role').text('Human')
3228  probe.append('div.rte-probe').text(e => e.probe)
3229
3230  // Reflection = the Assistant's response, shown as prose (sans), not mono.
3231  var refl = panels.append('div.rte-turn.rte-turn-assistant.rte-reflbox')
3232  refl.append('div.rte-role').text('Assistant')
3233  refl.append('div.rte-refl')
3234    .appendMany('span', e => e.reflection)
3235    .attr('class', s => s === '…' ? 'rte-ellip' : null)
3236    .text(s => s === '…' ? ' […] ' : s + ' ')
3237}
3238
3239window.init?.()
3240
3241// ─── public/modulation-readout/init-modulation-readout.js ───
3242window.initModulationReadout = async (opts) => {
3243  var sel = d3.select('.modulation-readout').html('')
3244  var d = await util.getFile(opts.datapath + 'modulation.json')
3245  var n = d.panels.length
3246  var pp = t => util.ppCell?.(t) ?? (util.ppToken(t) || t)
3247  // raw byte-fallback tokens ("\xe2\x80"…) and zero-width chars aren't readable — skip them
3248  var isJunk = t => /\\x[0-9a-fA-F]{2}/.test(t) || !util.ppToken(t).replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim()
3249  var cleanTopk = lys => lys.map(l => {
3250    var topk = l.topk.filter(k => !isJunk(k.t))
3251    return Object.assign({}, l, {topk, top1: topk[0]?.t ?? l.top1})
3252  })
3253
3254  var esc = s => String(s).replace(/[&<>"']/g, c =>
3255    ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
3256
3257  // back-compat: old data ships only p.layers at one position
3258  d.panels.forEach(p => {
3259    if (p.positions) return
3260    p.positions = [p.layers]; p.tokens = [p.key_tok_str]; p.key_pos = 0
3261    var i = p.display_prompt.lastIndexOf(p.key_tok_str)
3262    p.display_prefix = p.display_prompt.slice(0, i)
3263    p.display_suffix = p.display_prompt.slice(i + p.key_tok_str.length)
3264  })
3265
3266  // clip the linebreak panel's long prompt after "…on this continent," so the
3267  // three prompt blocks end up about the same height; the trailing newline is
3268  // kept (it's the lens-read position). Handles both bake shapes — marker
3269  // display_parts and the all-tokens layout.
3270  d.panels.forEach(p => {
3271    var cut
3272    if (p.display_parts) {
3273      cut = p.display_parts.findIndex(s => s.includes('on this continent,'))
3274      if (cut < 0) return
3275      p.display_parts = p.display_parts.slice(0, cut + 1)
3276    } else {
3277      cut = p.tokens.findIndex(t => /continent/.test(t))
3278      if (cut < 0) return
3279      while (cut < p.tokens.length - 1 && !/\n/.test(p.tokens[cut])) cut++
3280      p.display_suffix = ''
3281    }
3282    p.tokens = p.tokens.slice(0, cut + 1)
3283    p.positions = p.positions.slice(0, cut + 1)
3284    if (p.hl_range) p.hl_range = [Math.min(p.hl_range[0], cut), Math.min(p.hl_range[1], cut + 1)]
3285    p.key_pos = Math.min(p.key_pos, cut)
3286  })
3287
3288  d.panels.forEach(p => { p.positions = p.positions.map(cleanTopk) })
3289  // drop the leading turn-marker newlines so the prompt box doesn't open with
3290  // blank lines above "Human:". Token 0 stays in place so position indexing holds.
3291  d.panels.forEach(p => {
3292    if (p.display_prefix) p.display_prefix = p.display_prefix.replace(/^\n+/, '')
3293    else if (p.tokens?.[0]) p.tokens[0] = p.tokens[0].replace(/^\n+/, '')
3294  })
3295
3296  function spanify(s, p) {
3297    var out = esc(s)
3298    if (p.focus_clause) {
3299      var fc = esc(p.focus_clause)
3300      out = out.replace(fc, `<span class="hl">${fc}</span>`)
3301    }
3302    return out.replace(/(⟨[^⟩]*⟩|…)/g, m => `<span class="elide">${m}</span>`)
3303  }
3304
3305  // Per-panel default layer (display-label → pub index via DISPLAY_LAYERS).
3306  var DEFAULT_DISPLAY = [79, 88, 71]
3307  d.panels.forEach((p, pi) => {
3308    var disp = DEFAULT_DISPLAY[pi]
3309    if (disp == null) return
3310    var pub = util.DISPLAY_LAYERS.indexOf(disp)
3311    if (pub >= 0) p.default_layer = pub
3312  })
3313
3314  var row = sel.append('div.mod-row').st({'--ncol': n})
3315  d.panels.forEach(p => row.append('div.fig-title').text(p.title))
3316
3317  // panels 0/1 share a teacher-forced response — sync token clicks end-aligned,
3318  // but only where the two prompts actually overlap (same token at the aligned
3319  // slot). Clicks outside the overlap, or on any other panel, just select locally.
3320  var SYNC = [0, 1]
3321  var states = [], renders = [], tokSels = [], aliasSels = []
3322
3323  function syncTok(from, i){
3324    if (!SYNC.includes(from)) return
3325    var off = d.panels[from].tokens.length - 1 - i
3326    SYNC.forEach(ti => {
3327      if (ti === from) return
3328      var j = d.panels[ti].tokens.length - 1 - off
3329      if (j < 0 || !d.panels[ti].positions[j]) return
3330      if (d.panels[ti].tokens[j] !== d.panels[from].tokens[i]) return
3331      states[ti].pos = j; states[ti].src = null; renders[ti]()
3332    })
3333  }
3334  function pickTok(pi, i, src){
3335    states[pi].pos = i; states[pi].src = src; renders[pi](); syncTok(pi, i)
3336  }
3337
3338  d.panels.forEach((p, pi) => {
3339    var prompt = row.append('div.display-prompt.prompt-block')
3340    aliasSels[pi] = null
3341
3342    if (p.display_parts){
3343      // scattered scan tokens (linebreak ↵ markers): text parts interleaved
3344      // with one clickable marker per lens position
3345      var nodes = []
3346      p.display_parts.forEach((part, i) => {
3347        prompt.append('span.nodata').html(spanify(part, p))
3348        if (i < p.tokens.length) nodes.push(
3349          prompt.append('span.ptok').text(p.tokens[i])
3350            .on('click', () => pickTok(pi, i, null)).node())
3351      })
3352      tokSels[pi] = d3.selectAll(nodes)
3353      return
3354    }
3355
3356    // human-turn copy of the response sentence (inside the Write "..." quote):
3357    // clickable aliases that select the matching response position
3358    var sent = p.tokens.slice(2).join('')
3359    var qi = sent.length > 1 ? p.display_prefix.indexOf(sent.trim()) : -1
3360    if (qi >= 0){
3361      prompt.append('span.nodata').html(spanify(p.display_prefix.slice(0, qi), p))
3362      var cursor = qi, anodes = []
3363      p.tokens.forEach((t, ti) => {
3364        if (ti < 2) return
3365        var len = (ti === 2 ? t.replace(/^ /, '') : t).length
3366        anodes.push(prompt.append('span.ptok-alias')
3367          .text(p.display_prefix.slice(cursor, cursor + len))
3368          .datum(ti)
3369          .on('click', (e, ti2) => pickTok(pi, ti2, 'human')).node())
3370        cursor += len
3371      })
3372      aliasSels[pi] = d3.selectAll(anodes)
3373      prompt.append('span.nodata').html(spanify(p.display_prefix.slice(cursor), p))
3374    } else {
3375      prompt.append('span.nodata').html(spanify(p.display_prefix, p))
3376    }
3377
3378    var hr = p.hl_range
3379    tokSels[pi] = prompt.appendMany('span.ptok', d3.range(p.tokens.length))
3380      .text(i => p.tokens[i])
3381      .classed('hl', i => hr && i >= hr[0] && i < hr[1])
3382      .classed('nodata', i => !p.positions[i]?.length)
3383      .on('click', (e, i) => { if (p.positions[i]?.length) pickTok(pi, i, null) })
3384    prompt.append('span.nodata').html(spanify(p.display_suffix, p))
3385  })
3386
3387  d.panels.forEach((p, pi) => {
3388    var pair = row.append('div.lenspair')
3389    var swoop = pair.append('svg.swoop')
3390    var tbl = pair.append('table.lens')
3391    tbl.append('tr').html('<th>Layer</th><th>J-lens top-1</th>')
3392    var keyRows = p.positions[p.key_pos]
3393    var trs = tbl.appendMany('tr.ly', keyRows)
3394    trs.append('th').text(l => util.layerLabel(l.layer))
3395    var tds = trs.append('td')
3396
3397    // English gloss for the (rare) non-Latin top-1; shown inline so the panel
3398    // tables stay aligned across all three families.
3399    var GLOSS = {'дум': 'think'}
3400    var glossOf = t => { var k = pp(t).trim(); return /[A-Za-z]/.test(k) ? null : GLOSS[k] }
3401
3402    var detail = pair.append('div.detail')
3403    var dlyr = detail.append('div.dlyr')
3404    var dtoks = detail.append('div')
3405
3406    var st = states[pi] = {pos: p.key_pos, layer: p.default_layer, src: null}
3407
3408    function render(){
3409      var lys = p.positions[st.pos]
3410      tokSels[pi].classed('tok-hit', (_, i) => i === st.pos)
3411      // outline the human-turn copy only when it was the click origin
3412      if (aliasSels[pi]) aliasSels[pi].classed('on', ti => st.src === 'human' && ti === st.pos)
3413      trs.data(lys)
3414      tds.data(lys).each(function(l){
3415        var s = d3.select(this).text(pp(l.top1))
3416        var g = glossOf(l.top1)
3417        if (g) s.append('span.gloss').text(` (${g})`)
3418      })
3419      var l = lys.find(x => x.layer === st.layer) || lys[0]
3420      st.layer = l.layer
3421      trs.classed('on', x => x.layer === st.layer)
3422      dlyr.text(`Layer ${util.layerLabel(st.layer)}`)
3423      // show as many top-k rows as there are layer rows so the detail box and
3424      // the lens table come out the same height (no dead white space)
3425      dtoks.html('').appendMany('div.tok', l.topk.slice(0, lys.length))
3426        .text(k => pp(k.t))
3427        .st({'--p': k => k.p + '%'})
3428      requestAnimationFrame(drawSwoop)
3429    }
3430    renders[pi] = render
3431
3432    function drawSwoop(){
3433      var pr = pair.node().getBoundingClientRect()
3434      var tr = trs.filter('.on').node().getBoundingClientRect()
3435      var dr = dlyr.node().getBoundingClientRect()
3436      // start 1px inside the selected row's outline so the connector reads as
3437      // flush (the swoop now paints above the table — see style.css z-index)
3438      var x0 = Math.round(tr.right - pr.left) - 1
3439      var y0 = Math.round(tr.top + tr.height/2 - pr.top)
3440      var x1 = Math.round(dr.left - pr.left) - 1
3441      var y1 = Math.round(dr.top + dr.height/2 - pr.top)
3442      var mx = Math.round((x0 + x1) / 2)
3443      swoop.html('').append('path').at({
3444        d: `M${x0},${y0} C${mx},${y0} ${mx},${y1} ${x1},${y1}`,
3445        fill: 'none', stroke: 'var(--text)', strokeWidth: 1})
3446    }
3447
3448    // Layer-row hover is per-panel only (token clicks still sync via SYNC) s
3448o
3449    // each panel can sit at its own DEFAULT_DISPLAY layer.
3450    trs.on('mouseenter', (e, l) => { st.layer = l.layer; render() })
3451    render()
3452  })
3453}
3454
3455window.init?.()
3456
3457// ─── public/modulation-lines/init-modulation-lines.js ───
3458window.initModulationLines = async ({datapath}) => {
3459  const sel = d3.select('.modulation-lines').html('')
3460  const d = await util.getFile(datapath + 'lines.json')
3461  d.panels.forEach(p => p.series.forEach((s, i) => s.color = util.series[i % util.series.length]))
3462
3463  sel.append('div.head').append('div.fig-title').text(d.title)
3464
3465  const innerW = 300, H = 320
3466  const row = sel.append('div.panels')
3467  // when the panels stack (narrow container), give every panel the wide margin
3468  // + y-axis title so the stacked plot areas line up vertically
3469  const stacked = getComputedStyle(row.node()).flexDirection === 'column'
3470
3471  // labelPos: [x, y, lines] — px in panel-0 plot coords, hand-placed via ?tune=1 drag (copy panel offsets back here)
3472  var labelPos = {
3473    'Category instance': [11, 32, ['Category', 'instance']],
3474    'Math expression':   [143, 39, ['Math', 'expression']],
3475    'Line width':        [142, 174, ['Line', 'width']],
3476  }
3477
3478  // ?tune=1 on the standalone page makes these labels draggable (util.dragTune)
3479  var tuneKeys = {'Category instance': 'cat-instance', 'Math expression': 'math-expr', 'Line width': 'line-width'}
3480  var tuneItems = []
3481
3482  d.panels.forEach((p, pi) => {
3483    // The first panel carries the rotated y label; its left margin has to
3484    // cover the label's text height plus the addAxisLabel offset so the
3485    // figure's overflow-x: clip doesn't shave it.
3486    const M = pi === 0 || stacked
3487      ? {left: 56, right: 16, top: 28, bottom: 36}
3488      : {left: 42, right: 16, top: 28, bottom: 36}
3489    const c = d3.conventions({
3490      sel: row.append('div'), width: innerW,
3491      height: H - M.top - M.bottom, margin: M,
3492    })
3493    c.x = d3.scalePoint().domain(d.models).range([0, c.width]).padding(0.28)
3494    c.xAxis.scale(c.x)
3495    c.y.domain([0, 1])
3496    c.yAxis.tickFormat(d3.format('.0%')).tickValues(d3.range(0, 1.01, 0.2))
3497
3498    c.drawAxis()
3499    util.ggPlot(c)
3500    c.svg.selectAll('.x .tick path').remove()
3501    util.addAxisLabel(c, '', pi === 0 || stacked ? d.ylabel : '', p.label, 0, -14)
3502
3503    const line = d3.line().x((_, i) => c.x(d.models[i])).y(v => c.y(v))
3504    p.series.forEach(s => {
3505      c.svg.append('path.series.line-series').at({d: line(s.y), stroke: s.color, 'data-series': s.label})
3506      c.svg.appendMany('circle.pt', s.y)
3507        .at({r: 3.5, fill: s.color})
3508        .translate((v, i) => [c.x(d.models[i]), c.y(v)])
3509    })
3510
3511    if (pi === 0) p.series.forEach(s => {
3512      var [x, y, lines] = labelPos[s.label]
3513      var t = c.svg.append('text.dlabel')
3514        .translate([x, y])
3515        .at({textAnchor: 'start', 'data-series': s.label})
3516        .st({fill: s.color, paintOrder: 'stroke', stroke: '#EAECED', strokeWidth: 1, strokeLinejoin: 'round'})  // lint-ok hex-js: ggPlot bg halo
3517      lines.forEach((ln, li) => t.append('tspan').text(ln).at({x: 0, dy: li ? '1.15em' : '.32em'}))
3518      tuneItems.push({sel: t, key: tuneKeys[s.label],
3519        get: () => ({x, y}),
3520        set: q => t.translate([q.x, q.y])})
3521    })
3522  })
3523  util.bindSeriesHover(sel)
3524  util.dragTune(tuneItems, 'modulation-lines')
3525}
3526
3527window.init?.()
3528
3529// ─── public/modulation-prompts/init-modulation-prompts.js ───
3530window.initModulationPrompts = async ({datapath}) => {
3531  const sel = d3.select('.modulation-prompts').html('')
3532  const d = await util.getFile(datapath + 'data.json')
3533  if (!d3.select('.tooltip').size()) d3.select('body').append('div.tooltip.tooltip-hidden')
3534  const tt = d3.select('.tooltip')
3535
3536  sel.append('div.head').append('div.fig-title').text(d.title)
3537
3538  const leg = sel.append('div.legend-row')
3539  d.groups.forEach(g => {
3540    const it = leg.append('div.legend-item')
3541    it.append('span.legend-sq').st({background: g.color})
3542    it.append('span').text(g.label)
3543  })
3544
3545  const innerW = 264, innerH = 180
3546  const grid = sel.append('div.mp-scroll').append('div.grid')
3547  // deterministic per-dot offset so the figure is stable across reloads
3548  const jitter = (i, n) => n <= 1 ? 0 : (i / (n - 1) - 0.5) * 0.62
3549
3550  d.panels.forEach(p => {
3551    const isLeft = p.mi === 0, isTop = p.fi === 0
3552    const fam = d.families[p.fi]
3553    const M = {
3554      left: isLeft ? 60 : 28,
3555      right: 8,
3556      top: isTop ? 22 : 6,
3557      bottom: p.fi === d.families.length - 1 ? 30 : 18,
3558    }
3559    const c = d3.conventions({
3560      sel: grid.append('div.cell'),
3561      width: innerW, height: innerH, margin: M, layers: 's',
3562    })
3563    const x = d3.scaleBand().domain(d3.range(d.groups.length))
3564      .range([0, c.width]).paddingInner(0.28).paddingOuter(0.14)
3565    c.y.domain([0, 1])
3566    c.yAxis.ticks(5).tickFormat(d3.format('.0%'))
3567    c.x.domain([0, 1])
3568    c.drawAxis()
3569    c.svg.select('.x').remove()
3570    util.ggPlot(c)
3571
3572    // column header (model) on top row; row header (family + metric) on left col
3573    if (isTop) c.svg.append('text.col-head')
3574      .at({x: c.width/2, y: -8, textAnchor: 'middle'}).text(d.models[p.mi])
3575    if (isLeft) util.addAxisLabel(c, '', `${fam.label}  (${fam.metric})`, '', 0, -14)
3576
3577    // bars (group means) — focus/dismissal heights are the modulation-lines values
3578    p.groups.forEach((g, gi) => {
3579      if (g.mean == null) return
3580      c.svg.append('rect.bar').at({
3581        x: x(gi), width: x.bandwidth(),
3582        y: c.y(g.mean), height: c.height - c.y(g.mean),
3583        fill: d.groups[gi].color, fillOpacity: 0.45,
3584      })
3585    })
3586
3587    // dots (per-phrasing) with hover tooltip
3588    p.groups.forEach((g, gi) => {
3589      const cx = x(gi) + x.bandwidth()/2
3590      c.svg.appendMany('circle.dot', g.dots)
3591        .at({r: 3.2, fill: d.groups[gi].color, stroke: 'white', strokeWidth: 0.8})
3592        .translate((dot, i) => [cx + jitter(i, g.dots.length) * x.bandwidth(), c.y(dot.y)])
3593        .on('mouseover', (e, dot) => {
3594          tt.classed('tooltip-hidden', false).html('')
3595          tt.append('div.mp-tt-var').text(dot.v)
3596          tt.append('div.mp-tt-text').text(`"${dot.t || ''}"`)
3597          tt.append('div.mp-tt-val').text(d3.format('.1%')(dot.y))
3598        })
3599        .on('mousemove', e => {
3600          var bb = tt.node().getBoundingClientRect()
3601          tt.st({
3602            left: d3.clamp(8, e.clientX - bb.width/2, innerWidth - bb.width - 8) + 'px',
3603            top: (e.clientY + 14) + 'px',
3604          })
3605        })
3606        .on('mouseleave', () => tt.classed('tooltip-hidden', true))
3607    })
3608
3609    // none floor
3610    if (p.none != null) c.svg.append('line.floor').at({
3611      x1: 0, x2: c.width, y1: c.y(p.none), y2: c.y(p.none),
3612    })
3613
3614    // x ticks (group labels) on bottom row only
3615    if (p.fi === d.families.length - 1)
3616      c.svg.append('g.x-ticks').translate([0, c.height + 6])
3617        .appendMany('text.tick-label', d.groups)
3618        .at({textAnchor: 'middle', x: (g, i) => x(i) + x.bandwidth()/2, dy: '0.9em'})
3619        .text(g => g.label)
3620  })
3621}
3622
3623window.init?.()
3624
3625// ─── public/latent-patching/init-latent-patching.js ───
3626window.initLatentPatching = async function(opts){
3627  var sel = d3.select(opts?.sel || '.latent-patching').html('')
3628  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3629  var d = await util.getFile(base + 'data.json')
3630
3631  // Match flex-generalization-example: orange = the swapped/patched concept.
3632  var CLEAN = util.gray[500], PATCHED = util.tol.orange
3633  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
3634  var allow = s => esc(s)
3635    .replaceAll('&lt;b&gt;','<b>').replaceAll('&lt;/b&gt;','</b>')
3636    .replaceAll('&lt;i&gt;','<i>').replaceAll('&lt;/i&gt;','</i>')
3637    .replaceAll('&lt;br&gt;','<br>')
3638
3639  var panels = sel.appendMany('div.lp-panel', d.panels)
3640
3641  // ── left: title + prompt + swap ──
3642  var left = panels.append('div.lp-left')
3643  left.append('div.fig-title').text(p => p.title)
3644  var prompt = left.append('div.lp-prompt')
3645  // Mark the read position (last prompt token) — that's where the top-5
3646  // log-probs in the tables are measured. Last word, or last char for the
3647  // no-space Chinese prompt.
3648  var splitHit = p => p.prompt.match(/^([\s\S]*?)([A-Za-z0-9]+|.)$/).slice(1)
3649  prompt.append('span').text(p => splitHit(p)[0])
3650  prompt.append('span.tok-hit').text(p => splitHit(p)[1])
3651  left.filter(p => p.gloss).append('div.lp-gloss').text(p => p.gloss)
3652  left.append('div.lp-swaps').html(p =>
3653    'J-lens coordinate swap: ' + p.swap.map(s =>
3654      `${esc(s[0])} → <span style="color:${PATCHED}">${esc(s[1])}</span>`).join(', '))
3655
3656  // ── right: clean / patched bar tables + annotation ──
3657  var right = panels.append('div.lp-right')
3658  function col(side, label, color, barBg){
3659    var c = right.append('div.lp-col.bar-table').classed(side, true)
3660    c.append('h6.mlabel').html((p, i) =>
3661      `<span class="swatch" style="background:${color}"></span> ${esc(label)}` +
3662      (i == 0 && side == 'clean' ? ` <span class="hdr-sub">(log prob)</span>` : ''))
3663    c.appendMany('div.bar-row', p => {
3664      var rows = p[side], maxP = d3.max([...p.clean, ...p.patched], e => Math.exp(e.lp))
3665      return rows.map(e => ({...e, w: Math.exp(e.lp) / maxP * 100}))
3666    }).at({title: e => util.ppCell(e.t).trim()})
3667      .html(e => `<div class="bar" style="width:${e.w.toFixed(1)}%;background:${barBg}"></div>` +
3668                 `<span class="t">${esc(util.ppCell(e.t).trim())}</span><span class="val">${e.lp.toFixed(2)}</span>`)
3669  }
3670  col('clean', 'Clean', CLEAN, util.gray[200])
3671  col('patched', 'Swapped', PATCHED, util.tint(PATCHED, 0.65))
3672  right.append('div.lp-anno').html(p => allow(p.anno))
3673
3674  if (sel.classed('latent-patching')) util.colorCaption(sel, {clean: util.gray[600], swapped: PATCHED})
3675}
3676
3677window.init?.()
3678
3679// ─── public/multihop-swap-success/init-multihop-swap-success.js ───
3680window.initMultihopSwapSuccess = async function(opts){
3681  var sel = d3.select('.multihop-swap-success').html('')
3682  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3683  var d = await util.getFile(base + 'data.json')
3684
3685  // when the panel-row stacks (container < 768px), each panel redraws to fill
3686  // the available width so both align and keep full-size labels
3687  var fig = sel.node().closest('figure')
3688  var cs = fig && getComputedStyle(fig)
3689  var avail = fig ? fig.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0) : 0
3690  var stackW = avail && avail < 768 ? avail : 0
3691
3692  // Side-by-side at native size fits inside class='wide'; .panel-row's
3693  // generic 768px @container threshold would otherwise stack the panels and
3694  // trigger a fitMount downscale (which is what made everything tiny).
3695  var row = sel.append('div.panel-row').st({flexDirection: 'row'})
3696  drawBars(row.append('div.bars'), d)
3697  drawOnset(row.append('div.onset'), d.onset)
3698
3699  function drawBars(sel, d){
3700    var c = d3.conventions({
3701      sel, width: stackW ? stackW - 72 : 280, height: 280,
3702      margin: {left: 58, right: 14, top: 32, bottom: 32},
3703    })
3704    var x = d3.scaleBand().domain(d.models.map(m => m.label))
3705      .range([0, c.width]).paddingInner(0.35).paddingOuter(0.18)
3706    c.y.domain([0, 1])
3707    c.yAxis.ticks(5).tickFormat(d3.format('.0%'))
3708    c.drawAxis()
3709    c.svg.select('.x').remove()
3710    util.ggPlot(c)
3711    util.addAxisLabel(c, '', 'Swap success rate →', 'How often the swap works', 0, -8)
3712
3713    c.svg.appendMany('text.xlab', d.models)
3714      .text(m => m.label).translate(m => [x(m.label) + x.bandwidth()/2, c.height + 16])
3715      .at({textAnchor: 'middle'})
3716
3717    var g = c.svg.appendMany('g.mbar', d.models)
3718      .translate(m => [x(m.label), 0])
3719    g.append('rect').at({
3720      width: x.bandwidth(), y: m => c.y(m.p), height: m => c.height - c.y(m.p),
3721      fill: util.gray[400],
3722    })
3723    g.each(function(m){
3724      util.barWhisker(d3.select(this), {x: x.bandwidth()/2, lo: c.y(m.p - m.se), hi: c.y(m.p + m.se)})
3725    })
3726    g.append('text.val').text(m => d3.format('.0%')(m.p))
3727      .translate(m => [x.bandwidth()/2, c.y(m.p + m.se) - 5])
3728      .at({textAnchor: 'middle'})
3729  }
3730
3731  function drawOnset(sel, on){
3732    var kinds = [
3733      {key: 'intermediate', label: 'Intermediate swap', color: util.tol.blue},
3734      {key: 'target',       label: 'Answer swap',       color: util.tol.red},
3735    ]
3736    var c = d3.conventions({
3737      sel, width: stackW ? stackW - 66 : 420, height: 280,
3738      margin: {left: 58, right: 8, top: 32, bottom: 32},
3739    })
3740    var lo = d3.min(kinds, k => d3.min(on.l, (_, i) => on.mean[k.key][i] - on.se[k.key][i]))
3741    var hi = d3.max(kinds, k => d3.max(on.l, (_, i) => on.mean[k.key][i] + on.se[k.key][i]))
3742    c.x.domain(d3.extent(on.l))
3743    c.y.domain([lo, hi]).nice()
3744    c.xAxis.ticks(Math.ceil(on.l.length/2)).tickFormat(util.layerLabel)
3745    c.yAxis.ticks(5)
3746    c.drawAxis()
3747    util.ggPlot(c)
3748    util.addAxisLabel(c, 'Swap window upper layer (reindexed) →', 'Δ log-prob (expected − original) →', 'When the swap takes effect', 0, -8)
3749
3750    var line = d3.line().x((_, i) => c.x(on.l[i])).y(v => c.y(v))
3751    var area = d3.area().x((_, i) => c.x(on.l[i]))
3752      .y0((d, i) => c.y(d.m - d.s)).y1((d, i) => c.y(d.m + d.s))
3753    kinds.forEach(k => {
3754      var band = on.l.map((_, i) => ({m: on.mean[k.key][i], s: on.se[k.key][i]}))
3755      c.svg.append('path.se')
3756        .at({d: area(band), fill: k.color, stroke: 'none'})
3757        .st({opacity: 0.18})
3758    })
3759    kinds.forEach(k => {
3760      c.svg.append('path.mean')
3761        .at({d: line(on.mean[k.key]), fill: 'none', stroke: k.color, strokeWidth: 3})
3762    })
3763
3764    var labelPos = {  // [anchorIdx, dx, dy, textAnchor] — hand-placed
3765      intermediate: [6, -8, -34, 'end'],
3766      target:       [8,  0,  36, 'middle'],
3767    }
3768    kinds.forEach(k => {
3769      var [ai, dx, dy, anchor] = labelPos[k.key]
3770      c.svg.append('text.dlabel').text(k.label)
3771        .translate([c.x(on.l[ai]) + dx, c.y(on.mean[k.key][ai]) + dy])
3772        .at({dy: '.32em', textAnchor: anchor})
3773        .st({fill: k.color, paintOrder: 'stroke', stroke: '#EAECED', strokeWidth: 2, strokeLinejoin: 'round'})  // lint-ok hex-js: ggPlot bg halo
3774    })
3775  }
3776}
3777
3778window.init?.()
3779
3780// ─── public/repeat-switch/init-repeat-switch.js ───
3781window.initRepeatSwitch = async function(opts){
3782  var sel = d3.select('.repeat-switch').html('')
3783  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3784  var d = await util.getFile(base + 'data.json')
3785
3786  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
3787  var bandLbl = `L${util.layerLabel(d.layers[0])}–${util.layerLabel(d.layers[1])}`
3788
3789  sel.append('div.fig-title').text('Swapping the decoded strategy between prompts flips the model’s choice')
3790
3791  // val = log-prob. Probability bars only on Model output rows (sh
3791owBars) — the
3792  // lens-median probs are mostly <1% and render as slivers.
3793  function barTable(host, entries, mark, cls, showBars){
3794    var t = host.append('div.rs-bars.bar-table' + (cls || ''))
3795    t.appendMany('div.bar-row', entries).at({title: e => util.ppCell(e.t).trim()}).html(e => {
3796      var c = mark ? (e.abl ? ' abl' : e.don ? ' don' : '') : ''
3797      var bar = showBars ? `<div class="bar" style="width:${(Math.exp(e.lp)*100).toFixed(1)}%"></div>` : ''
3798      return bar +
3799             `<span class="t${c}">${esc(util.ppCell(e.t).trim())}</span>` +
3800             `<span class="val">${e.lp.toFixed(2)}</span>`
3801    })
3802  }
3803
3804  var grid = sel.append('div.rs-grid')
3805  d.panels.forEach((p, i) => {
3806    var col = grid.append('div.rs-panel')
3807    col.append('div.mlabel.rs-panel-label').text(`${p.label} prompt`)
3808    var body = esc(p.promptBody)
3809    var dot = body.lastIndexOf('.')
3810    body = body.slice(0, dot) + '<span class="tok-hit">.</span>' + body.slice(dot + 1)
3811    col.append('div.rs-prompt.prompt-block').html(
3812      `<span class="prompt-role">Human:</span> ${body}\n\n` +
3813      `<span class="prompt-role">Assistant:</span> <span class="prompt-gen">${esc(p.cleanChoice)}</span>`)
3814
3815    var tbl = col.append('div.rs-table')
3816    tbl.append('div.rs-corner')
3817    tbl.append('div.rs-colhead').text('Original')
3818    tbl.append('div.rs-colhead').text('After plan swap')
3819
3820    tbl.append('div.rs-rowlab').html(`<span class="rs-lab1">J-lens decode at "."</span><br>${bandLbl} median`)
3821    barTable(tbl, p.lensBefore, true)
3822    barTable(tbl, p.lensAfter, true)
3823
3824    tbl.append('div.rs-rowlab.rs-sep').text('Model output')
3825    barTable(tbl, p.outBefore, false, '.rs-sep', true)
3826    barTable(tbl, p.outAfter, false, '.rs-sep', true)
3827  })
3828
3829  // Legend lives in the gdoc figcaption — color the cue words to match the
3830  // .abl (red strikethrough) / .don (teal) token styling in the tables.
3831  util.colorCaption(sel, {removed: 'var(--tol-red)', installed: 'var(--tol-teal)'})
3832}
3833
3834window.init?.()
3835
3836// ─── public/verbal-report/init-verbal-report.js ───
3837window.initVerbalReport = async function(opts){
3838  var sel = d3.select('.verbal-report').html('')
3839  var base = (opts?.datapath || './').replace(/\/?$/, '/')
3840  var d = await util.getFile(base + 'data.json')
3841
3842  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
3843  var ttSel = d3.select('.tooltip')
3844
3845  var main = sel.append('div.vr-main')
3846
3847  // ── example block (left) ──
3848  var ex = d.example
3849  var top = main.append('div.vr-example')
3850  top.append('div.fig-title').text(`"Think of a ${ex.category}"`)
3851  top.append('div.vr-prompt.prompt-block').html(
3852    `<span class="prompt-role">Human:</span> ${esc(ex.promptBody)}\n\n` +
3853    `<span class="prompt-role">Assistant</span><span class="tok-hit">:</span>`)
3854
3855  function barTable(host, label, entries, isLens){
3856    var t = host.append('div.vr-bars.bar-table').classed('vr-lens', isLens)
3857    var h = t.append('h6')
3858    h.append('span.swatch').st({background: isLens ? util.tol.orange : util.gray[400], marginRight: 5})
3859    h.append('span').text(label)
3860    var max = d3.max(entries, e => Math.exp(e.lp))
3861    var bg = isLens ? util.tint(util.tol.orange, 0.7) : 'var(--gray-200)'
3862    t.appendMany('div.bar-row', entries).at({title: e => util.ppCell(e.t).trim()}).html(e =>
3863      `<div class="bar" style="width:${(Math.exp(e.lp)/max*100).toFixed(1)}%;background:${bg}"></div>` +
3864      `<span class="t">${esc(util.ppCell(e.t).trim())}</span><span class="val">${e.lp.toFixed(2)}</span>`)
3865  }
3866  top.append('div.mlabel.vr-section').text('Original')
3867  var c1 = top.append('div.vr-cols')
3868  barTable(c1, `J-lens ${ex.rhoLayer}`, ex.lensTop, true)
3869  barTable(c1, "Model's next-token logits", ex.outTop)
3870  top.append('div.mlabel.vr-section').text(`Swapping "${ex.swapAnswer}" → "${ex.swapTarget}"`)
3871  var c2 = top.append('div.vr-cols')
3872  barTable(c2, `J-lens ${ex.rhoLayer}`, ex.lensTopAfter, true)
3873  barTable(c2, "Model's next-token logits", ex.outTopAfter)
3874
3875  // ── quant block (right column) ──
3876  var q = d.quant
3877  var bot = main.append('div.vr-quant')
3878  bot.append('div.fig-title').text(`Across ${q.categories?.length || 14} categories`)
3879
3880  // ρ at three layers
3881  var cL = d3.conventions({
3882    sel: bot.append('div'), width: 200, height: 150,
3883    margin: {left: 50, right: 8, top: 2, bottom: 28},
3884  })
3885  var xL = d3.scaleBand().domain(q.rhos.map(r =>
3885 r.L)).range([0, cL.width])
3886    .paddingInner(0.35).paddingOuter(0.18)
3887  cL.y.domain([0, 1]).clamp(true)
3888  cL.yAxis.ticks(5)
3889  cL.drawAxis()
3890  cL.svg.select('.x').remove()
3891  util.ggPlot(cL)
3892  util.addAxisLabel(cL, 'Layer →', '')
3893  var yLab = cL.svg.select('.y').append('g').translate([-38, cL.height/2])
3894    .append('text.axis-label').at({textAnchor: 'middle', transform: 'rotate(-90)'})
3895  yLab.append('tspan').text('Lens vs output rank').at({x: 0})
3896  yLab.append('tspan').text('Spearman ρ →').at({x: 0, dy: '1.1em'})
3897  cL.svg.appendMany('text.xlab', q.rhos)
3898    .text(r => r.L).translate(r => [xL(r.L) + xL.bandwidth()/2, cL.height + 14])
3899    .at({textAnchor: 'middle'})
3900  cL.svg.appendMany('rect.vr-med', q.rhos)
3901    .at({x: r => xL(r.L), width: xL.bandwidth(), y: r => cL.y(d3.median(r.rhos)),
3902      height: r => cL.height - cL.y(d3.median(r.rhos)), fill: util.gray[400]})
3903  var rng = d3.randomUniform.source(d3.randomLcg(0))(-0.18, 0.18)
3904  var trunc = s => s && s.length > 200 ? s.slice(0, 200) + '…' : (s || '')
3905  function appendTopk(host, lt){
3906    if (!lt?.tokens?.length) return
3907    var toks = host.append('div.toks')
3908    lt.tokens.forEach((t, ti) => {
3909      var pp = util.ppToken?.(t) ?? t
3910      toks.append('span.tok').text(pp).classed('hl', lt.hit_idx?.includes(ti))
3911    })
3912  }
3913  function placeTT(e){
3914    var bb = ttSel.node().getBoundingClientRect()
3915    ttSel.st({left: d3.clamp(20, e.clientX - bb.width/2, innerWidth - bb.width - 20) + 'px',
3916              top: (innerHeight > e.clientY + 20 + bb.height ? e.clientY + 20 : e.clientY - bb.height - 20) + 'px'})
3917  }
3918  q.rhos.forEach(r => {
3919    var x0 = xL(r.L), w = xL.bandwidth()
3920    var cats = r.cats || [], prompts = r.prompts || [], topks = r.lens_topk || []
3921    cL.svg.appendMany('circle.jpt', r.rhos.map((v, i) => ({v, L: r.L, cat: cats[i], pr: prompts[i], lt: topks[i]})))
3922      .at({cx: () => x0 + w/2 + rng()*w, cy: o => cL.y(o.v), r: 2,
3923        fillOpacity: 0, stroke: '#000', strokeWidth: 1})
3924  })
3925  var lastL
3926  util.nearestHover(cL.svg, cL.svg.selectAll('circle.jpt'), {
3927    radius: 40,
3928    onHover: (o, el, e) => {
3929      if (el !== lastL){
3930        lastL = el
3931        ttSel.classed('tooltip-hidden', false).html('')
3932        ttSel.append('div').html(`<b>${o.L}</b> · ${o.cat || ''} · ρ = ${o.v}`)
3933        if (o.pr) ttSel.append('div.prompt-block').text(trunc(o.pr))
3934        appendTopk(ttSel, o.lt)
3935      }
3936      placeTT(e)
3937    },
3938    onLeave: () => { lastL = null; ttSel.classed('tooltip-hidden', true) },
3939  })
3940
3941  // before→after slope (log y, rank=1 at top)
3942  var cR = d3.conventions({
3943    sel: bot.append('div'), width: 200, height: 150,
3944    margin: {left: 44, right: 12, top: 2, bottom: 28},
3945  })
3946  var xR = d3.scalePoint().domain(['before', 'after']).range([0, cR.width]).padding(0.3)
3947  cR.y = d3.scaleLog().domain([1, 1000]).range([0, cR.height]).clamp(true)
3948  cR.yAxis.scale(cR.y).tickValues([1, 10, 100, 1000]).tickFormat(d => d >= 1000 ? '1k+' : d)
3949  cR.drawAxis()
3950  cR.svg.select('.x').remove()
3951  util.ggPlot(cR)
3952  util.addAxisLabel(cR, '', 'Candidate output rank →')
3953  cR.svg.appendMany('text.xlab', ['No swap', 'After swap'])
3954    .text(s => s).translate((s,i) => [xR(i ? 'after' : 'before'), cR.height + 14])
3955    .at({textAnchor: 'middle'})
3956  var rngR = d3.randomUniform.source(d3.randomLcg(1))(-0.5, 0.5)
3957  var jw = xR.step() * 0.14
3958  q.pairs.forEach(p => { p.j0 = rngR()*jw; p.j1 = rngR()*jw })
3959  var slopes = cR.svg.appendMany('path.slope', q.pairs)
3960    .at({d: p => `M${xR('before') + p.j0},${cR.y(p.before)} L${xR('after') + p.j1},${cR.y(p.after)}`,
3961      stroke: util.gray[600], strokeWidth: 0.6, opacity: 0.35})
3962    .st({mixBlendMode: 'multiply'}).nodes()
3963  var cB = cR.svg.appendMany('circle.jpt', q.pairs.map((p, i) => ({p, i, side: 'before'})))
3964    .at({cx: o => xR('before') + o.p.j0, cy: o => cR.y(o.p.before), r: 2,
3965      fillOpacity: 0, stroke: '#000', strokeWidth: 1}).nodes()
3966  var cA = cR.svg.appendMany('circle.jpt', q.pairs.map((p, i) => ({p, i, side: 'after'})))
3967    .at({cx: o => xR('after') + o.p.j1, cy: o => cR.y(o.p.after), r: 2,
3968      fillOpacity: 0, stroke: '#000', strokeWidth: 1}).nodes()
3969  var lastR, hovEls = []
3970  var clearHov = () =>
3970 { hovEls.forEach(n => n.classList.remove('hov')); hovEls = [] }
3971  util.nearestHover(cR.svg, cR.svg.selectAll('circle.jpt'), {
3972    radius: 40,
3973    onHover: (o, el, e) => {
3974      if (el !== lastR){
3975        lastR = el
3976        clearHov()
3977        hovEls = [slopes[o.i], cB[o.i], cA[o.i]]
3978        hovEls.forEach(n => n.classList.add('hov'))
3979        var p = o.p
3980        ttSel.classed('tooltip-hidden', false).html('')
3981        ttSel.append('div').html(`<b>${p.cat}</b>${p.word ? ' · ' + esc(p.word) : ''}`)
3982        ttSel.append('table.tt-pair').html(
3983          `<tr><td class=k>no swap</td><td>rank <b>${p.before}</b></td></tr>` +
3984          `<tr><td class=k>after swap</td><td>rank <b>${p.after}</b></td></tr>`)
3985        if (p.prompt) ttSel.append('div.prompt-block').text(trunc(p.prompt))
3986        appendTopk(ttSel, p.lens_topk)
3987      }
3988      placeTT(e)
3989    },
3990    onLeave: () => { lastR = null; clearHov(); ttSel.classed('tooltip-hidden', true) },
3991  })
3992}
3993
3994window.init?.()
3995
3996// ─── public/verbal-introspection/init-verbal-introspection.js ───
3997window.initVerbalIntrospection = async function(opts){
3998  var sel = d3.select('.verbal-introspection').html('')
3999  var base = (opts?.datapath || './').replace(/\/?$/, '/')
4000  var d = await util.getFile(base + 'data.json')
4001
4002  var SLOT = util.tol.blue, GREY = util.gray[500], HL = 'var(--hl-yellow)'
4003  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
4004
4005  var main = sel.append('div.vi-main')
4006  var left = main.append('div.vi-left')
4007  var right = main.append('div.vi-right')
4008
4009  // ── prompt box ──
4010  left.append('div.fig-title').text('Verbal introspection of injected workspace content')
4011  var box = left.append('div.vi-prompt.prompt-block')
4012  d.turns.forEach((t, i) => {
4013    var p = box.append('div.vi-turn')
4014    p.append('span.prompt-role').text(t.role + ': ')
4015    if (i === d.spanTurnIdx){
4016      var s = p.append('span.hl')
4017      s.append('span').text(d.spanPre)
4018      s.append('span.tok-hit').text(',')
4019      s.append('span').text(d.spanPost)
4020    } else if (i === d.turns.length - 1){
4021      p.append('span').text(d.lastBody)
4022      p.append('span.hit-c.tok-hit').text('"')
4023    } else {
4024      p.append('span').text(t.body)
4025    }
4026  })
4027
4028  // ── 3 bar tables ──
4029  var cols = left.append('div.vi-cols')
4030  function table(l1, l2, rows, swatch, barColor, isLens){
4031    var c = cols.append('div.vi-col.bar-table').classed('vi-lens', !!isLens)
4032    var h = c.append('h6')
4033    var l = h.append('div')
4034    l.append('span.swatch').st({background: swatch, marginRight: 5})
4035    l.append('span').html(l1)
4036    h.append('div.cond').html(l2)
4037    var maxP = d3.max(rows, e => Math.exp(e.lp))
4038    c.appendMany('div.bar-row', rows).at({title: e => util.ppCell(e.t).trim()}).html(e =>
4039      `<div class="bar" style="width:${(Math.exp(e.lp)/maxP*100).toFixed(1)}%;background:${barColor}"></div>` +
4040      `<span class="t">${esc(util.ppCell(e.t).trim())}</span><span class="val">${e.lp.toFixed(2)}</span>`)
4041  }
4042  table(`J-lens ${esc(d.lensLabel)} at <span class="tok-hit">,</span>`, 'Original', d.tables.lensTgt, util.tol.orange, util.tint(util.tol.orange, 0.7), true)
4043  table('Next-token logits at <span class="hit-c tok-hit">&quot;</span>', 'Original', d.tables.outClean, util.gray[400], 'var(--gray-200)')
4044  table('Next-token logits at <span class="hit-c tok-hit">&quot;</span>', `After <span class="hl">injecting</span> "${esc(d.exampleConcept)}"`, d.tables.outTgt, util.tol.red, util.tint(util.tol.red, 0.7))
4045
4046  // ── right: median RR ± IQR vs strength ──
4047  right.append('div.fig-title').text(`Injection across ${d.n} concepts`)
4048  var m = {left: 50, right: 14, top: 8, bottom: 36}
4049  var ch = d3.clamp(220, left.node().offsetHeight - right.node().offsetHeight - m.top - m.bottom, 520)
4050  var c = d3.conventions({
4051    sel: right.append('div'), width: 280, height: ch,
4052    margin: m,
4053  })
4054  c.x.domain([0, d3.max(d.curve.s)])
4055  c.y = d3.scaleLog().domain([1e-4, 1]).range([c.height, 0]).clamp(true)
4056  c.xAxis.tickValues([0, 0.01, 0.02]).tickFormat(v => v.toFixed(2))
4057  c.yAxis.scale(c.y).tickValues([1e-4, 1e-3, 1e-2, 1e-1, 1])
4058    .tickFormat(v => v >= 1 ? '1' : v.toFixed(-Math.log10(v)))
4059  c.drawAxis()
4060  util.ggPlot(c)
4061  util.addAxisLabel(c, 'Steering strength s →', 'Median reciprocal rank of injected concept →')
4062  d3.select(c.svg.select('.y .axis-label').node().parentNode).translate([-38, c.height/2 - 15])
4063
4064  var area = d3.area().x((_, i) => c.x(d.curve.s[i])).y0(v => c.y(v[0])).y1(v => c.y(v[1]))
4065  var line = d3.line().x((_, i) => c.x(d.curve.s[i])).y(v => c.y(v))
4066  // labelPos: [anchorIdx, dx, dy, textAnchor] — hand-placed
4067  ;[
4068    ['other', GREY, 'Other positions', [5, -6, -16, 'end']],
4069    ['slot',  SLOT, 'At the open quote', [4, -26, 16, 'end']],
4070  ].forEach(([k, col, lab, [ai, dx, dy, anchor]]) => {
4071    var q = d.curve[k]
4072    c.svg.append('path').at({d: area(q.q25.map((lo, i) => [lo, q.q75[i]])), fill: col, opacity: 0.18})
4073    c.svg.append('path.line-series').at({d: line(q.q50), fill: 'none', stroke: col, strokeWidth: 1.8, 'data-series': k})
4074    // filled median anchors — the curve's "key statistic" marker per style guide
4075    c.svg.appendMany('circle', q.q50).at({cx: (_, i) => c.x(d.curve.s[i]), cy: v =>
4075 c.y(v), r: 2.4, fill: col})
4076    c.svg.append('text.dlabel').text(lab)
4077      .at({x: c.x(d.curve.s[ai]) + dx, y: c.y(q.q50[ai]) + dy, dy: '.32em', textAnchor: anchor, fontSize: 'var(--fs-small)', 'data-series': k})
4078      .st({fill: col, paintOrder: 'stroke', stroke: '#fff', strokeWidth: 1, strokeLinejoin: 'round'})
4079  })
4080  util.bindSeriesHover(right)
4081  util.dragTuneLabels(sel, 'verbal-introspection')
4082  util.colorCaption(sel, {
4083    'user turn': {bg: HL},
4084    'open quotation mark': {bg: util.tint(SLOT, 0.85)},
4085    'every other position': {bg: util.tint(GREY, 0.85)},
4086  })
4087}
4088
4089window.init?.()
4090
4091// ─── public/top-down-summoning/init-top-down-summoning.js ───
4092window.initTopDownSummoning = async function(opts){
4093  var sel = d3.select(opts?.sel || '.top-down-summoning').html('')
4094  var base = (opts?.datapath || './').replace(/\/?$/, '/')
4095  var spec = opts?.spec || 'main'
4096  var d = await util.getFile(base + spec + '.json')
4097
4098  var ORANGE = util.tol.orange
4099  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
4100  var strength = r => r == null ? 0 : r <= 2 ? 1.0 : r <= 5 ? 0.6 : 0.3
4101  var DARK = d3.color(ORANGE).darker(0.6).formatHex()
4102  var bg = (r, hex) => r == null ? 'transparent'
4103    : strength(r) >= 1 ? DARK : util.tint(hex, 1 - strength(r))
4104  // flip to white text when the activation fill gets dark enough that black text
4105  // muddies (matches the activation-ramp rule in CLAUDE-FIGURE-STYLE-GUIDE.md)
4106  var hot = r => r != null && strength(r) > 0.6
4107
4108  // older bake shape: per-panel q1/q2 instead of a conds list
4109  d.panels.forEach(p => {
4110    if (p.conds) return
4111    p.conds = ['q1', 'q2'].map(c => ({kind: c,
4112      label: c == 'q1' ? 'Next-word question' : 'Property question',
4113      q: p[c].q, gen: p[c].gen, stim: p[c].stim,
4114      counts: [{targets: p.targets, nHit: p[c].nHit, nTot: p[c].nTot}]}))
4115  })
4116
4117  var nRows = d3.max(d.panels, p => 1 + d3.sum(p.conds, c => (c.label ? 1 : 0) + 2))
4118  var grid = sel.append('div.tds-grid').classed('tds-3col', d.panels.length == 3)
4119  var panels = grid.appendMany('div.tds-panel', d.panels).st({gridRow: 'span ' + nRows})
4120  panels.append('div.mlabel.tds-title').text(p => p.title)
4121
4122  panels.each(function(p){
4123    var pn = d3.select(this)
4124    p.conds.forEach(c => {
4125      if (c.label) pn.append('div.mlabel.tds-cond').text(c.label)
4126      var tx = pn.append('div.tds-tx.prompt-block')
4127      tx.append('span.prompt-role').text('Human: ')
4128      tx.append('span.q-diff').text(c.q)
4129      tx.append('span').text(' "')
4130      tx.appendMany('span.t', c.stim)
4131        .text(s => s.t)
4132        .at({title: s => s.r == null ? null : 'rank ' + s.r})
4133        .classed('hot', s => hot(s.r))
4134        .st({background: s => bg(s.r, ORANGE)})
4135      tx.append('span').text('\n\n')
4136      tx.append('span.prompt-role').text('Assistant: ')
4137      tx.append('span').text(c.gen)
4138      // one short line: merge target sets when their hit counts match
4139      var counts = c.counts.every(ct => ct.nHit == c.counts[0].nHit)
4140        ? [{targets: c.counts.flatMap(ct => ct.targets), nHit: c.counts[0].nHit}]
4141        : c.counts
4142      pn.append('div.tds-count').html(counts.map(ct =>
4143        `${ct.targets.map(t => `"${esc(t)}"`).join(' / ')} in lens on ` +
4144        `${ct.nHit} token${ct.nHit == 1 ? '' : 's'}`).join('<br>'))
4145    })
4146  })
4147
4148  util.colorCaption(sel, {
4149    ...(spec == 'main' ? {'different question': {bg: 'var(--hl-yellow)'}} : {}),
4150    'J-lens top-10': {bg: DARK, color: '#fff'},
4151  })
4152}
4153
4154window.init?.()
4155
4156// ─── public/flex-gen-systematic/init-flex-gen-systematic.js ───
4157window.initFlexGenSystematic = async function(opts){
4158  var spec = opts?.spec || 'systematic'
4159  var sel = d3.select(opts?.sel || '.flex-gen-systematic').html('')
4160  var base = (opts?.datapath || './').replace(/\/?$/, '/')
4161  var d = await util.getFile(base + spec + '.json')
4162  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
4163
4164  if (spec === 'appendix') return renderAppendix(sel, d, esc)
4165
4166  // ═══ systematic: dot-stem left, scatter right ═══
4167  var row = sel.append('div.fgs-row')
4168  // Categorical palette from the visual vocab (avoid orange + red — see style guide).
4169  var catPalette = [util.tol.blue, util.tol.teal, util.tol.magenta, util.gray[500]]
4170  var col = Object.fromEntries(d.catOrder.map((c, i) =>
4170 [c, catPalette[i % catPalette.length]]))
4171
4172  // ── left: per-function HIT, ●=α=1 / ×=α=2 ──
4173  var rows = []
4174  d.catOrder.forEach(c => {
4175    var sub = d.pts.filter(p => p.cat === c).sort((a, b) => b.hit2 - a.hit2)
4176    rows.push(...sub)
4177  })
4178  var H = 320, W = 280, M = {left: 78, right: 8, top: 6, bottom: 30}
4179  var cL = d3.conventions({sel: row.append('div'), width: W, height: H, margin: M})
4180  // Top-1 rate is a proportion — no negative axis space, no headroom past 100%;
4181  // markers at 0/1 sit on the boundary
4182  cL.x.domain([0, 1])
4183  var y = d3.scalePoint().domain(d3.range(rows.length)).range([0, cL.height]).padding(0.5)
4184  cL.xAxis.tickValues([0, 0.5, 1.0]).tickFormat(d3.format('.0%'))
4185  cL.drawAxis(); cL.svg.select('.y').remove()
4186  util.ggPlot(cL)
4187  util.addAxisLabel(cL, 'Top-1 rate →', '')
4188  // category divider lines
4189  for (var i = 1; i < d.catOrder.length; i++)
4190    cL.svg.append('line').at({x1: 0, x2: cL.width, y1: (y(i*4-1)+y(i*4))/2, y2: (y(i*4-1)+y(i*4))/2,
4191      stroke: util.gray[300], strokeWidth: 0.5})
4192  var g = cL.svg.appendMany('g.frow', rows).translate((p, i) => [0, y(i)])
4193  g.append('line').at({x2: p => cL.x(p.hit1/d.nPer), stroke: p => col[p.cat], strokeWidth: 1.2})
4194  g.append('line').at({x2: p => cL.x(p.hit2/d.nPer), stroke: p => col[p.cat], strokeWidth: 1.0,
4195    strokeDasharray: '2,2'})
4196  // α=1 filled "key statistic" marker (3.5 ~ small-side of the 1.8-2.2 stroked
4197  // range; filled so it reads as the anchor against the × at α=2)
4198  g.append('circle').at({cx: p => cL.x(p.hit1/d.nPer), r: 3.5, fill: p => col[p.cat]})
4199  g.append('path').at({d: 'M-4,-4L4,4M-4,4L4,-4', transform: p => `translate(${cL.x(p.hit2/d.nPer)},0)`,
4200    stroke: p => col[p.cat], strokeWidth: 1.5, fill: 'none'})
4201  g.append('text').text(p => p.func).at({x: -6, dy: '.32em', textAnchor: 'end',
4202    fontSize: 'var(--fs-label)', fill: util.gray[700]})
4203  // legend — categorical dots (mirror the data markers' filled circles)
4204  var leg = cL.svg.append('g').translate([cL.width - 60, cL.height - 86])
4205  d.catOrder.forEach((c, i) => {
4206    var lg = leg.append('g').translate([0, i*12])
4207    lg.append('circle').at({r: 3.5, fill: col[c]})
4208    lg.append('text').text(c).at({x: 8, dy: '.32em', fontSize: 'var(--fs-label)', fill: util.gray[700]})
4209  })
4210  ;[['α=1', 'circle'], ['α=2', 'x']].forEach(([lab, mk], i) => {
4211    var lg = leg.append('g').translate([0, 52 + i*12])
4212    if (mk === 'circle') lg.append('circle').at({r: 3.5, fill: '#fff', stroke: util.gray[700], strokeWidth: 1})
4213    else lg.append('path').at({d: 'M-3,-3L3,3M-3,3L3,-3', stroke: util.gray[700], strokeWidth: 1.4})
4214    lg.append('text').text(lab).at({x: 8, dy: '.32em', fontSize: 'var(--fs-label)', fill: util.gray[700]})
4215  })
4216
4217  // ── right: csum vs Δlp scatter ──
4218  var W2 = 360, M2 = {left: 50, right: 14, top: 6, bottom: 36}
4219  var cR = d3.conventions({sel: row.append('div'), width: W2, height: H, margin: M2})
4220  var xExt = d3.extent(d.pts, p => p.csum), xSpan = xExt[1] - xExt[0]
4221  var yExt = d3.extent(d.pts, p => p.dlpc), ySpan = yExt[1] - yExt[0]
4222  cR.x.domain([xExt[0] - 0.06*xSpan, xExt[1] + 0.18*xSpan])
4223  cR.y.domain([Math.min(yExt[0], 0) - 0.06*ySpan, yExt[1] + 0.08*ySpan])
4224  cR.xAxis.ticks(5); cR.yAxis.ticks(5)
4225  cR.drawAxis(); util.ggPlot(cR)
4226  util.addAxisLabel(cR, 'Clean workspace loading (cos at arg + cos at readout) →',
4227    'Swap effect at α=1 (Δlp_target − Δlp_spon) →', '', 6, -6)
4228  // y=0 ref + fit line
4229  cR.svg.append('line').at({x1: 0, x2: cR.width, y1: cR.y(0), y2: cR.y(0), stroke: util.gray[300], strokeWidth: 0.7})
4230  var xs = cR.x.domain()
4231  cR.svg.append('line').at({x1: cR.x(xs[0]), x2: cR.x(xs[1]),
4232    y1: cR.y(d.fit.m*xs[0]+d.fit.b), y2: cR.y(d.fit.m*xs[1]+d.fit.b),
4233    stroke: util.gray[400], strokeWidth: 1.1, strokeDasharray: '4,3'})
4234  // error bars + dots + labels — whiskers in series color, faded so dots dominate
4235  var pg = cR.svg.appendMany('g.pt', d.pts).translate(p => [cR.x(p.csum), cR.y(p.dlpc)])
4236  pg.append('line').at({x1: p => cR.x(p.csum-p.csumStd)-cR.x(p.csum), x2: p => cR.x(p.csum+p.csumStd)-cR.x(p.csum),
4237    stroke: p => util.tint(col[p.cat], 0.6), strokeWidth: 1})
4238  pg.append('line').at({y1: p => cR.y(p.dlpc-p.dlpcSem)-cR.y(p.dlpc), y2: p => cR.y(p.dlpc+p.dlpcSem)-cR.y(p.dlpc),
4239    stroke: p => util.tint(col[p.cat], 0.6), strokeWidth: 1})
4240  // circles on cR.svg (not inside translated g.pt) so nearestHover reads cx/cy
4241  cR.svg.appendMany('circle.jpt', d.pts)
4242    .at({cx: p => cR.x(p.csum), cy: p => cR.y(p.dlpc), r: 2.2,
4243      fillOpacity: 0, stroke: p => col[p.cat], strokeWidth: 1})
4244  // label placement: try 4 diagonals, greedily pick the one with most clearance
4245  // from other points + already-placed label boxes. Diagonal keeps text off the
4246  // CI crosshair (which runs horiz+vert through the point).
4247  var px = d.pts.map(p => [cR.x(p.csum), cR.y(p.dlpc)])
4248  var diag = [[6, -4, 'start'], [-6, -4, 'end'], [6, 11, 'start'], [-6, 11, 'end']]
4249  var charW = 6, lineH = 10, pad = 4, placed = []
4250  function labelBox(i, [dx, dy, anc]){
4251    var w = d.pts[i].func.length * charW
4252    var x0 = anc === 'start' ? px[i][0] + dx : px[i][0] + dx - w
4253    return [x0, px[i][1] + dy - lineH, x0 + w, px[i][1] + dy]
4254  }
4255  function boxDist(b, x, y){
4256    var dx = Math.max(b[0] - x, 0, x - b[2]), dy = Math.max(b[1] - y, 0, y - b[3])
4257    return Math.hypot(dx, dy)
4258  }
4259  function overlap(a, b){ return a[0] < b[2]+pad && a[2]+pad > b[0] && a[1] < b[3]+pad && a[3]+pad > b[1] }
4260  function score(i, cand){
4261    var bx = labelBox(i, cand), s = 1e9
4262    px.forEach(([x, y], j) => { if (j !== i) s = Math.min(s, boxDist(bx, x, y)) })
4263    placed.forEach(b => { if (overlap(bx, b)) s -= 1e3 })
4264    if (bx[2] > cR.width || bx[0] < 0) s -= 200
4265    return s
4266  }
4267  var nudge = {}
4268  d3.range(d.pts.length).sort((a, b) => px[a][0] - px[b][0]).forEach(i => {
4269    var best = d3.greatest(diag, c => score(i, c))
4270    nudge[d.pts[i].func] = best
4271    placed.push(labelBox(i, best))
4272  })
4273  pg.append('text').text(p => p.func)
4274    .at({x: p => nudge[p.func][0], dy: p => nudge[p.func][1],
4275         textAnchor: p => nudge[p.func][2],
4276         fontSize: 'var(--fs-label)', fill: util.gray[600]})
4277    .st({paintOrder: 'stroke', stroke: '#EAECED', strokeWidth: 1, strokeLinejoin: 'round'})  // lint-ok hex-js: ggPlot bg halo
4278  cR.svg.append('text.num').text(`r = ${d.r >= 0 ? '+' : ''}${d.r.toFixed(2)}`)
4279    .at({x: 8, y: 14, fontSize: 'var(--fs-body)', fill: util.gray[700]})
4280    .st({fontVariantNumeric: 'tabular-nums'})
4281
4282  var ttSel = d3.select('.tooltip'), last
4283  util.nearestHover(cR.svg, cR.svg.selectAll('circle.jpt'), {
4284    radius: 40,
4285    onHover: (p, el, e) => {
4286      if (el !== last){
4287        last = el
4288        ttSel.classed('tooltip-hidden', false).html('')
4289        ttSel.append('div').html(`<b style="color:${col[p.cat]}">${esc(p.func)}</b> · ${p.cat}`)
4290        ttSel.append('div').html(
4291          `top-1: <b>${p.hit1}/${d.nPer}</b> (α=1), ${p.hit2}/${d.nPer} (α=2)<br>` +
4292          `loading: ${p.csum.toFixed(2)} · Δlp: ${p.dlpc.toFixed(2)}`)
4293      }
4294      var bb = ttSel.node().getBoundingClientRect()
4295      ttSel.st({left: d3.clamp(20, e.clientX - bb.width/2, innerWidth - bb.width - 20) + 'px',
4296                top: (innerHeight > e.clientY + 20 + bb.height ? e.clientY + 20 : e.clientY - bb.height - 20) + 'px'})
4297    },
4298    onLeave: () => { last = null; ttSel.classed('tooltip-hidden', true) },
4299  })
4300}
4301
4302function renderAppendix(sel, d, esc){
4303  // sentence-case the baked lowercase category/function names (matches flex-gen-example's .capitalize())
4304  var cap = s => s.charAt(0).toUpperCase() + s.slice(1)
4305  d.cats.forEach(cat => {
4306    var cs = sel.append('div.fga-cat')
4307    cs.append('div.fga-cat-h').text(cap(cat.name))
4308    var row = cs.append('div.fga-row')
4309    cat.funcs.forEach(F => {
4310      var grd = row.append('div.fga-grid')
4311      grd.append('div.fga-grid-h').html(`${esc(cap(F.name))} <span class="n">${F.nHit}/12 top-1</span>`)
4312      var tbl = grd.append('table.g')
4313      var thr = tbl.append('tr')
4314      thr.append('th.r')
4315      cat.args.forEach(a => thr.append('th')
4316        .html(`<span class="o mlabel">${esc(a)}</span><span class="a">${esc(F.answers[a])}</span>`).at({title: a}))
4317      cat.args.forEach((sp, ri) => {
4318        var tr = tbl.append('tr')
4319        tr.append('th.r').text(sp).at({title: sp})
4320        F.cells[ri].forEach(c => {
4321          var td = tr.append('td').text(c.t).at({title: c.t + (c.rk ? ` (rank ${c.rk})` : '')})
4322          if (c.diag) td.classed('diag', true)
4323          else {
4324            var cls = c.rk === 1 ? 'hit' : c.rk <= 5 ? 'near' : 'miss'
4325            td.classed(cls, true)
4326            if (c.rk > 1) td.append('span.rk').text(c.rk)
4327          }
4328        })
4329      })
4330    })
4331  })
4332  var leg = sel.append('div.fga-legend')
4333  leg.append('span').text('Rank of correct answer:')
4334  ;[['hit', '1'], ['near', '2–5'], ['miss', '>5'], ['diag', 'No swap']].forEach(([cls, lab]) => {
4335    var it = leg.append('span.item')
4336    it.append('span.sw').classed(cls, true)
4337    it.append('span').text(lab)
4338  })
4339}
4340
4341window.init?.()
4342
4343// ─── public/selectivity-linecount/init-selectivity-linecount.js ───
4344window.initSelectivityLinecount = async function(opts){
4345  var sel = d3.select(opts?.sel || '.selectivity-linecount').html('')
4346  var base = (opts?.datapath || './').replace(/\/?$/, '/')
4347  var d = await util.getFile(base + 'data.json')
4348  sel.st({'--ncol': d.conds.length})
4349
4350  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
4351  var orange = t => d3.interpolateOranges(0.1 + 0.55*t)
4352  var condFill = co => /^Automatic/.test(d.condLabel[co]) ? util.gray[300] : util.tol.blue
4353
4354  // ── 3-column example ──
4355  sel.append('div.fva-row3.titles').appendMany('div.fig-title', d.example)
4356    .text(e => e.title)
4357  sel.append('div.fva-note').text('Model output in bold')
4358
4359  var tops = sel.append('div.fva-row3').appendMany('div', d.example)
4360  var pr = tops.append('div.fva-prompt.prompt-block')
4361  pr.each(function(e){
4362    var vmax = Math.max(1, d3.max(e.tokens, t => t.d))
4363    d3.select(this).appendMany('span', e.tokens).each(function(t){
4364      var s = d3.select(this).attr('class', t.cls || null)
4365      var txt = t.nl ? esc(t.s).replaceAll('\n', "<span class='nl'>↵</span>\n") : esc(t.s)
4366      if (t.d > 0) s.classed('ramp', true)
4367        .st({background: orange(t.d / vmax)})
4368      s.html(txt)
4369    })
4370  })
4371  tops.append('div.fva-legend').text(e => `${e.density} ${d.densityNoun || 'number-tokens in lens'}`)
4372
4373  // The continue column reports the model's output token (a newline) rather
4374  // than the column it wraps at — apply here so the figure is correct even
4375  // against a data.json baked before the corresponding bake-script change.
4376  d.example.filter(e => e.cond == 'continue').forEach(e => {
4377    e.ansLabel = 'output:'; e.ans = '↵'
4378  })
4379  // swapLabel is now the section heading (was previously repeated under each
4380  // column). Baked data.json before this change has the short form
4381  // "Swap the lens count 40s→60s:"; rephrase it here so the figure reads
4382  // correctly without waiting on a re-bake.
4383  var m = /(\d)0s.*?(\d)0s/.exec(d.swapLabel || '')
4384  var swapHeading = m
4385    ? `Swap J-lens vectors for numbers in the ${m[1]}0s for numbers in the ${m[2]}0s`
4386    : d.swapLabel
4387  sel.append('div.fig-title.mid').text(swapHeading)
4388  var bots = sel.append('div.fva-row3').appendMany('div.fva-swp', d.example)
4389  bots.append('div.row2').html(e =>
4390    `<span class='lab'>${esc(e.ansLabel)}</span>` +
4391    `<span><b class='out'>${esc(e.ans)}</b>` +
4392    `<span class='vd'>before swap</span></span>`)
4393  bots.append('div.row2').html(e =>
4394    `<span class='lab'>after swap:</span>` +
4395    `<span><b class='out'>${esc(e.swap)}</b>` +
4396    `<span class='vd'>${esc(e.verdict)}</span></span>`)
4397
4398  // ── 3 bar panels ──
4399  sel.append('div.fva-sep').text(`Across all n=${d.n} snippets`)
4400  // match Fig 20's selectivity-language barChart: same y-label wording, %
4401  // ticks on every panel, multi-line rotated label (not addAxisLabel arrow)
4402  var YLAB = {
4403    'P(correct answer) →': 'Task success rate',
4404    'Count tokens in lens →': 'Rate of count appearing in\nJ-lens on prompt tokens',
4405    'P(answer follows swap) →': 'Rate of answer following swap',
4406  }
4407  var br = sel.append('div.fva-bars').appendMany('div', d.bars)
4408  var colW = br.node().offsetWidth
4409  var M = {left: 58, right: 8, top: 22, bottom: 50}
4410  br.each(function(b){
4411    var c = d3.conventions({
4412      sel: d3.select(this), width: colW - M.left - M.right, height: 200, margin: M,
4413    })
4414    var x = d3.scaleBand().domain(d.conds).range([0, c.width])
4415      .paddingInner(0.3).paddingOuter(0.14)
4416    var ylim = b.ylim || [0, d3.max(b.vals, v => (v.mean||0) + (v.sem||0)) * 1.12]
4417    c.y.domain(ylim)
4418    c.yAxis.ticks(5).tickFormat(d3.format('.0%')).tickSize(4)
4419    c.drawAxis(); c.svg.select('.x').remove()
4420    util.ggPlot(c)
4421    var ylines = (YLAB[b.ylabel] || b.ylabel.replace(/\s*→\s*$/, '')).split('\n')
4422    var yfs = ylines.length > 1 ? 12 : 14
4423    var yx = -44 - (ylines.length-1) * (yfs+1)
4424    var ytxt = c.svg.append('text').at({x: yx, y: c.height/2})
4425      .attr('transform', `rotate(-90 ${yx} ${c.height/2})`)
4426      .st({fontSize: yfs, fill: util.gray[700], textAnchor: 'middle'})
4427    ylines.forEach((l, i) => ytxt.append('tspan').at({x: yx, dy: i ? yfs+1 : 0}).text(l))
4428    c.svg.append('text.panel-title').text(b.title)
4429      .at({x: 0, y: -8, fontSize: 'var(--fs-small)', fontWeight: 600})  // lint-ok inline-font-js: SVG axis-title
4430    c.svg.appendMany('text.xlab', d.conds).at({textAnchor: 'middle'})
4431      .translate(co => [x(co)+x.bandwidth()/2, c.height+12])
4432      .each(function(co){
4433        var ls = d.condLabel[co].split('\n')
4434        d3.select(this).appendMany('tspan', ls).text(s => s)
4435          .at({x: 0, dy: (_, i) => i ? '1.15em' : 0})
4436      })
4437    var g = c.svg.appendMany('g', b.vals.filter(v => v.mean != null))
4438      .translate(v => [x(v.cond), 0])
4439    g.append('rect').at({
4440      width: x.bandwidth(), y: v => c.y(v.mean), height: v => c.height - c.y(v.mean),
4441      fill: v => condFill(v.cond),
4442    })
4443    g.each(function(v){
4444      util.barWhisker(d3.select(this), {x: x.bandwidth()/2, lo: c.y(v.mean - v.sem), hi: c.y(v.mean + v.sem)})
4445    })
4446  })
4447}
4448
4449window.init?.()
4450
4451// ─── public/selectivity-language/init-selectivity-language.js ───
4452window.initSelectivityLanguage = async function(opts){
4453  var base = (opts?.datapath || './').replace(/\/?$/, '/')
4454  var swap = await util.getFile(base + 'swap.json').catch(() => null)
4455  var rd = null, ex = null
4456
4457  var orange = t => d3.interpolateOranges(0.1 + 0.55*t)
4458  var WS_LABELS = {'Explicit report': 1, 'Flexible computation': 1}
4459  var catFill = lab => WS_LABELS[lab] ? util.tol.blue : util.gray[400]
4460
4461  function barChart(sel, rows, meta, ylab, ymax){
4462    sel.html('')
4463    var groups = []
4464    meta.tasks.forEach(t => meta.models.forEach(m => groups.push([t, m])))
4465    var nC = meta.cats.length, nG = groups.length
4466    var avail = Math.max(120, sel.node().clientWidth
4467      || sel.node().parentNode?.clientWidth || 380)
4468    var nM = meta.models.length, nT = meta.tasks.length
4469    var bottomM = 42 + (nM > 1 ? 16 : 0)
4470                     + (nT > 1 ? 16 : 0)
4471    var c = d3.conventions({
4472      sel, totalWidth: Math.min(avail, 420), height: 200,
4473      margin: {left: 58, top: 22, right: 4,
4474               bottom: bottomM},
4475    })
4476    var gx = d3.scaleBand().domain(d3.range(nG))
4477      .range([0, c.width]).paddingInner(0.18)
4478    var cx = d3.scaleBand().domain(meta.cats)
4479      .range([0, gx.bandwidth()]).paddingInner(0.22)
4480    c.y.domain([0, ymax])
4481    c.yAxis.tickFormat(d3.format('.0%')).ticks(5).tickSize(4)
4482    c.drawAxis()
4483    c.svg.select('.x').remove()
4484    util.ggPlot(c)
4485    var ylines = String(ylab).split('\n')
4486    var yfs = ylines.length > 1 ? 12 : 14
4487    var yx = -44 - (ylines.length-1) * (yfs+1)
4488    var ytxt = c.svg.append('text')
4489      .at({x: yx, y: c.height/2})
4490      .attr('transform', `rotate(-90 ${yx} ${c.height/2})`)
4491      .st({fontSize: yfs, fill: util.gray[700],
4492           textAnchor: 'middle'})
4493    ylines.forEach((l, i) => ytxt.append('tspan')
4494      .at({x: yx, dy: i ? yfs+1 : 0}).text(l))
4495
4496    var idx = {}
4497    rows.forEach(r => { idx[[r.task, r.model, r.cat]] = r })
4498
4499    groups.forEach(([t, m], gi) => {
4500      var g = c.svg.append('g').translate([gx(gi), 0])
4501      meta.cats.forEach(cat => {
4502        var r = idx[[t, m, cat]]
4503        if (!r || r.mean == null) return
4504        var bx = cx(cat), bw = cx.bandwidth()
4505        var n = r.pts.length
4506        var sd = n > 1 ? Math.sqrt(
4507          r.pts.reduce((a,p)=>a+(p-r.mean)**2,0)/(n-1)) : 0
4508        var se = n > 1 ? sd/Math.sqrt(n) : 0
4509        var lo = Math.max(0, r.mean - 1.96*se)
4510        var hi = Math.min(1, r.mean + 1.96*se)
4511        g.append('rect')
4512          .at({x: bx, y: c.y(r.mean), width: bw,
4513               height: c.height - c.y(r.mean)})
4514          .st({fill: catFill(meta.labels[cat])})
4515          .append('title')
4516          .text(`${t} / ${meta.model_labels[m]}\n`
4517              + `${meta.labels[cat]}: `
4518              + `${(r.mean*100).toFixed(0)}% ± `
4519              + `${(1.96*se*100).toFixed(0)}% (n=${n})`)
4520        if (n > 1) util.barWhisker(g, {x: bx + bw/2, lo: c.y(lo), hi: c.y(hi)})
4521      })
4522      meta.cats.forEach((cat, ci) => {
4523        var bx = cx(cat), bw = cx.bandwidth()
4524        var words = meta.labels[cat].split(' ')
4525        var txt = g.append('text')
4526          .at({x: bx + bw/2, y: c.height + 14})
4527          .st({fontSize: 'var(--fs-label)', textAnchor: 'middle',
4528               fill: util.gray[700]})
4529        words.forEach((w, i) => txt.append('tspan')
4530          .at({x: bx + bw/2, dy: i ? '1.1em' : 0}).text(w))
4531      })
4532      // Model label under bars; task header above models
4533      // when there are multiple tasks.
4534      if (nG > 1){
4535        var mY = c.height + 46
4536        if (meta.models.length > 1){
4537          g.append('text')
4538            .at({x: gx.bandwidth()/2, y: mY})
4539            .st({fontSize: 'var(--fs-label)', textAnchor: 'middle',
4540                 fill: util.gray[500]})
4541            .text(meta.model_labels[m].split(' ')[0])
4542        }
4543        if (m === meta.models[0] && meta.tasks.length > 1){
4544          var tX = gx.bandwidth() * meta.models.length / 2
4545          var tY = meta.models.length > 1 ? mY + 16 : mY
4546          g.append('text')
4547            .at({x: tX, y: tY})
4548            .st({fontSize: 'var(--fs-small)', textAnchor: 'middle',
4549                 fontWeight: 600, fill: util.gray[700]})  // lint-ok inline-font-js: structural sub-axis label
4550            .text(t)
4551        }
4552      }
4553    })
4554  }
4555
4556  if (swap){
4557    d3.selectAll('.flex-auto-swap').each(function(){
4558      var sel = d3.select(this)
4559      var tag = sel.attr('data-tag') || 'v2'
4560      var tasksAttr = sel.attr('data-tasks')
4561      var modelsAttr = sel.attr('data-models')
4562      var tasks = tasksAttr
4563        ? tasksAttr.split(',') : swap.tasks
4564      var models = modelsAttr
4565        ? modelsAttr.split(',') : swap.models
4566      var rows = (swap[tag] || [])
4567        .filter(r => tasks.includes(r.task)
4568                  && models.includes(r.model))
4569      var meta = {cats: swap.cats, tasks: tasks,
4570                  models: models, labels: swap.labels,
4571                  colors: swap.colors,
4572                  model_labels: swap.model_labels}
4573      var ylab = tag === 'ablation'
4574        ? 'P(own answer preserved)'
4575        : 'P(answer follows swap)'
4576      barChart(sel, rows, meta, ylab, 1.0)
4577    })
4578
4579    // Polished example layout: one cell per column, each
4580    // with title, prompt box, lens-count, swap rows.
4581    d3.selectAll('.flex-auto-polished').each(function(){
4582      var sel = d3.select(this).html('')
4583      var pe = swap.polished_ex
4584      if (!pe) return
4585      var grid = sel.append('div').at({class: 'row3'})
4586      function renderPrompt(c){
4587        var smax = d3.max(c.lens_strength || []) || 1
4588        // Fallback when no lens_hits collected for this
4589        // column: render plain prompt text.
4590        if (!c.tokens.length && c.prompt_text){
4591          var t = esc(c.prompt_text)
4592          if (c.anom_str)
4593            t = t.replace(esc(c.anom_str),
4594              `<span class='anom'>${esc(c.anom_str)}</span>`)
4595          var ws = c.out0.split(/\s+/)
4596          var mg = ws.slice(0,5).join(' ') + (ws.length > 5 ? '…' : '')
4597          return `<span class='prompt-role'>Human:</span> ${t}`
4598            + `\n\n<span class='prompt-role'>Assistant:</span>`
4599            + ` <span class='prompt-gen'>${esc(mg)}</span>`
4600        }
4601        var pp = c.tokens.map(prettyToken)
4602        for (var i = 1; i < pp.length; i++)
4603          if (pp[i].startsWith(' ')
4604              && pp[i-1].endsWith('\n'))
4605            pp[i] = pp[i].slice(1)
4606        var html = `<span class='prompt-role'>Human:</span>`
4607        var atok = c.assistant_tok
4608        pp.forEach((s, i) => {
4609          if (atok && i >= atok[0] && i < atok[1]){
4610            if (i === atok[0])
4611              html += `\n\n<span class='prompt-role'>Assistant:`
4612                    + `</span>`
4613            return
4614          }
4615          var isAnom = c.anom_tok && i >= c.anom_tok[0]
4616            && i < c.anom_tok[1]
4617          s = esc(s)
4618          if (c.lens_hit?.[i]){
4619            var t = (c.lens_strength?.[i] || 0) / smax
4620            html += `<span class='ramp${isAnom?' anom':''}' `
4621              + `style='background:${orange(t)}'`
4622              + `>${s}</span>`
4623          } else if (isAnom){
4624            html += `<span class='anom'>${s}</span>`
4625          } else html += s
4626        })
4627        if (!atok)
4628          html += `\n\n<span class='prompt-role'>Assistant:</span>`
4629        var ws = c.out0.split(/\s+/)
4630        var mgen = ws.slice(0,5).join(' ') + (ws.length > 5 ? '…' : '')
4631        html += ` <span class='prompt-gen'>${esc(mgen)}</span>`
4632        return html
4633      }
4634      pe.cols.forEach((c, idx) => {
4635        var cell = grid.append('div')
4636        cell.append('div').at({class: 'fig-title'})
4637          .st({borderBottom: 'none', paddingBottom: 0})
4638          .text(c.title.replace(' — ', ': '))
4639        cell.append('div').at({class: 'prompt prompt-block'})
4640          .html(renderPrompt(c))
4641        var isCont = c.cat === 'auto_cont'
4642        // Legend (bold/orange-swatch key) sits between the prompt block and
4643        // the swap block in the auto_cont column — shared key for the whole
4644        // example row, placed once. Replaces the old per-column "N tokens
4645        // in lens" annotations.
4646        if (isCont) cell.append('div').at({class: 'inlay'})
4647          .html(`<b>bold</b> = model output · `
4648              + `<span class='legend-sq' style='background:${orange(.8)}'></span>`
4649              + `${esc(pe.sig_label)} in lens top-${pe.k}`)
4650        // Word-boundary truncation so the swap-result line never ends
4651        // mid-word (the auto_cont output is a full sentence; a hard char
4652        // cut at 36 sliced "cómo" → "cóm"). 34 keeps "…observando" and
4653        // fits the column at the paper's <figure class='wide'> width.
4654        var short = s => {
4655          var first = s.split(/[\n.]/)[0]
4656          if (first.length <= 34) return first
4657          return first.slice(0, 34).replace(/\s+\S*$/, '') + '…'
4658        }
4659        var swp = cell.append('div').at({class: 'swp'})
4660        swp.append('div').at({class: 'row-lr'})
4661          .html(`Swap J-lens ${esc(pe.own)}→${esc(pe.donor)} `
4662              + `at every position:`)
4663        swp.append('div').at({class: 'row-mid'})
4664          .html(`<span class='lab'>output:</span> `
4665              + `<b class='out'>${esc(short(c.out0))}</b>`)
4666        swp.append('div').at({class: 'row-mid'})
4667          .html(`<span class='lab'>after swap:</span> `
4668              + `<b class='out'>${esc(short(c.out1))}</b>`
4669              + (isCont ? ''
4670                : `<span class='vd'>${esc(c.verdict)}</span>`))
4671        if (isCont) swp.append('div').at({class: 'vd-line'})
4672          .text(c.verdict)
4673      })
4674      sel.append('div').at({class: 'sep'})
4675        .text(`Across all n=${pe.n_stim} snippets`)
4676    })
4677
4678    // 3-panel chart (a/b/c) — sonnet lang prefill.
4679    d3.selectAll('.flex-auto-3panel').each(function(){
4680      var sel = d3.select(this)
4681      var tp = swap.three_panel
4682      if (!tp) return
4683      sel.html('')
4684      tp.panels.forEach(p => {
4685        var cell = sel.append('div')
4686        var chart = cell.append('div')
4687        var meta = {cats: tp.cats, tasks: ['lang'],
4688                    models: ['sonnet'], labels: swap.labels,
4689                    colors: swap.colors,
4690                    model_labels: swap.model_labels}
4691        var ymax = p.key === 'b'
4692          ? Math.max(.05,
4693              ...p.rows.map(r => r.mean || 0)) * 1.15
4694          : 1.0
4695        barChart(chart, p.rows, meta, p.ylab, ymax)
4696        chart.select('svg g').append('text.panel-title')
4697          .text(`${p.key} · ${p.title}`)
4698          .at({x: 0, y: -8, fontSize: 'var(--fs-small)', fontWeight: 600})  // lint-ok inline-font-js: SVG axis-title
4699      })
4700    })
4701
4702    util.colorCaption(d3.select('.selectivity-language'), {
4703      'continuation': {bg: util.gray[400], color: '#fff'},
4704      'anomaly detection': {bg: util.gray[400], color: '#fff'},
4705      'explicit report': {bg: util.tol.blue, color: '#fff'},
4706      'flexible computation': {bg: util.tol.blue, color: '#fff'},
4707    })
4708  }
4709
4710  function esc(s){
4711    return String(s ?? '').replace(/[&<>"']/g, c =>
4712      ({'&':'&amp;','<':'&lt;','>':'&gt;',
4713        '"':'&quot;',"'":'&#39;'}[c]))
4714  }
4715
4716  function prettyToken(t){
4717    // tokenization markers: case marker precedes space
4718    // marker (↑⍽human = " Human"). ppToken consumes the
4719    // space; we need to preserve it.
4720    if (t.startsWith('<') && t.endsWith('>')) return ''
4721    return String(t)
4722      .replace(/([▲↑])([·⍽ ]?)(\p{L})/gu,
4723               (_, _c, sp, l) => (sp ? ' ' : '') + l.toUpperCase())
4724      .replace(/⇪([·⍽ ]?)(\p{L}+)/gu,
4725               (_, sp, w) => (sp ? ' ' : '') + w.toUpperCase())
4726      .replace(/[▲↑⇪]/gu, '')
4727      .replace(/[⍽ ·]/gu, ' ')
4728      .replace(/⏎/gu, '\n')
4729  }
4730
4731  function renderTokenPrompt(c){
4732    // Token-level rendering when lens-hit data is present.
4733    // c.tokens: per-token marked strings;
4734    // c.lens_hit: bool[]; c.anom_tok: [start,end) or null.
4735    var smax = d3.max(c.lens_strength || []) || 1
4736    var ws = c.ws_prefix || ''
4737    var pp = c.tokens.map(prettyToken)
4738    // Drop a leading space on tokens that follow a
4739    // newline so paragraphs aren't indented by one char.
4740    for (var i = 1; i < pp.length; i++)
4741      if (pp[i].startsWith(' ') && pp[i-1].endsWith('\n'))
4742        pp[i] = pp[i].slice(1)
4743    while (pp.length && !pp[pp.length-1].trim()) pp.pop()
4744    var body = pp.map((s, i) => {
4745      // Prefill mode: body tokens include the Assistant
4746      // marker inline; replace those tokens with the
4747      // styled turn label.
4748      if (c.assistant_tok && i >= c.assistant_tok[0]
4749          && i < c.assistant_tok[1]){
4750        return i === c.assistant_tok[0]
4751          ? `\n\n<span class='fa-turn'>Assistant:</span>`
4752          : ''
4753      }
4754      var isAnom = c.anom_tok && i >= c.anom_tok[0]
4755          && i < c.anom_tok[1]
4756      s = esc(s)
4757      if (c.lens_hit?.[i]){
4758        var aCls = isAnom ? ' fa-anom' : ''
4759        var t = (c.lens_strength?.[i] || 0) / smax
4760        return (
4761          `<span class='fa-lens-hit${aCls}' `
4762          + `style='background:${orange(t)}'>`
4763          + s + `</span>`)
4764      }
4765      if (isAnom) return `<span class='fa-anom'>${s}</span>`
4766      return s
4767    }).join('')
4768    var lead = pp[0]?.startsWith(' ') ? '' : ' '
4769    var wsD = ws ? (ws.startsWith(' ') ? ws : ' ' + ws) : ''
4770    var tail = c.assistant_tok
4771      ? ''
4772      : (`\n\n<span class='fa-turn'>Assistant:</span>`
4773         + (wsD ? `<span class='fa-ws'>${esc(wsD)}</span>` : ''))
4774    return (
4775      `<span class='fa-turn'>Human:</span>${lead}${body}`
4776      + tail
4777    )
4778  }
4779
4780  function highlightPrompt(c, mode){
4781    if (c.tokens) return renderTokenPrompt(c)
4782    // mode: 'all' | 'stim' | 'post' | 'surround'
4783    // Returns HTML with swap region wrapped in .fa-swp and
4784    // anomaly (if present) in .fa-anom.
4785    var q = esc(c.question)
4786    var et = esc(c.text)
4787    var stim_start = Math.max(0, q.indexOf(et))
4788    var stim_end = stim_start + et.length
4789    var pre, swp, post
4790    if (mode === 'all' || mode === 'surround'){
4791      pre = ''; swp = q; post = ''
4792    } else if (mode === 'stim'){
4793      pre = q.slice(0, stim_start)
4794      swp = q.slice(stim_start)
4795      post = ''
4796    } else {
4797      var q_start = stim_end
4798      while (q[q_start] === '\n') q_start++
4799      pre = q.slice(0, q_start)
4800      swp = q.slice(q_start)
4801      post = ''
4802    }
4803    if (c.anomaly){
4804      var ea = esc(c.anomaly)
4805      // For 'surround' the anomaly is EXCLUDED from the
4806      // swap — render it as a non-swp island inside the
4807      // highlighted region.
4808      if (mode === 'surround'){
4809        swp = swp.replace(
4810          ea,
4811          `</span><span class='fa-anom'>${ea}</span>`
4812          + `<span class='fa-swp'>`)
4813      } else {
4814        pre = pre.replace(
4815          ea, `<span class='fa-anom'>${ea}</span>`)
4816        swp = swp.replace(
4817          ea, `<span class='fa-anom'>${ea}</span>`)
4818      }
4819    }
4820    // Write-only continuation embeds its own Assistant
4821    // prefix in the question string; detect to avoid
4822    // double-rendering the turn marker.
4823    var hasAssist = /\n\nAssistant:/.test(c.question)
4824    // For full-scope (all/surround), the swap covers the
4825    // Human:/Assistant: markers too — highlight them.
4826    var full = (mode === 'all' || mode === 'surround')
4827    var headCls = full ? 'fa-swp fa-pre' : 'fa-pre'
4828    var tail = hasAssist ? ''
4829      : `<span class='${headCls}'>\n\nAssistant:</span>`
4830    // Warm-start prefix (if provided): show separately
4831    // after Assistant:, styled to distinguish unswapped-
4832    // sampled tokens from swapped generation.
4833    if (c.ws_prefix){
4834      tail += `<span class='fa-ws'>${esc(c.ws_prefix)}</span>`
4835    }
4836    return (`<span class='${headCls}'>Human: </span>${pre}`
4837          + `<span class='fa-swp'>${swp}</span>`
4838          + `${post}${tail}`)
4839  }
4840
4841  var TASK_NOUN = {
4842    lang: 'language', code: 'programming-language',
4843    cuisine: 'cuisine', sport: 'sport',
4844    code2: 'programming-language',
4845  }
4846
4847  function renderExample(sel, d, tag){
4848    sel.html('')
4849    var head = sel.append('div').at({class: 'fa-ex-head'})
4850    var noun = TASK_NOUN[d.task] || d.task
4851    head.append('b').text(d.donor
4852      ? `Swapping ${noun} J-lens vector: ${d.own} → ${d.donor}`
4853      : `Ablating ${noun} J-lens vector: ${d.own} → ∅`)
4854    if (d.cols.some(c => c.lens_hit)){
4855      var lm = d.lens_meta || {}
4856      var lr = lm.display_layers?.length
4857        ? `L${lm.display_layers[0]}–`
4858          + `${lm.display_layers[lm.display_layers.length-1]}`
4859        : 'Workspace layers'
4860      head.append('span').at({class: 'fa-ex-legend'})
4861        .html(
4862          `<span class='fa-sw'></span>`
4863          + `${d.own}-token in lens top-${lm.k||10}, ${lr}`
4864          + (d.cols.some(c => c.anom_tok)
4865             ? ` · <span class='fa-anom'>underlined</span>`
4866               + ` = inserted anomaly` : ''))
4867    }
4868    var nc = d.cols.length
4869    // Group: report+flexible (workspace) come first, then
4870    // continuation+anomaly (automatic). Add a wider gap
4871    // and a dashed divider before the automatic group.
4872    var nAuto = d.cols.filter(c =>
4873      /^Automatic/.test(c.title)).length
4874    var nWork = nc - nAuto
4875    var grid = nc >= 6
4876      ? {gridTemplateColumns:
4877           `repeat(${Math.ceil(nWork/2)}, 1fr) `
4878           + `repeat(${Math.ceil(nAuto/2)}, 1fr)`,
4879         gridTemplateRows: 'auto auto',
4880         gridAutoFlow: 'column',
4881         columnGap: '18px', rowGap: '28px'}
4882      : {gridTemplateColumns: `repeat(${nc}, 1fr)`}
4883    var cols = sel.append('div').at({class: 'fa-ex-cols'})
4884      .st(grid)
4885    // Default highlight mode (for legacy tags with no
4886    // per-column `highlight` field).
4887    var dflt = tag==='v2all' || tag==='sf_final' ? 'all'
4888      : d.question_first ? 'stim' : 'post'
4889    d.cols.forEach((c, i) => {
4890      var isAuto = /^Automatic/.test(c.title)
4891      var firstAuto = isAuto && i === nWork
4892      var col = cols.append('div').at({class: 'fa-ex-col'})
4893        .classed('fa-ex-auto', isAuto)
4894        .classed('fa-ex-divider', firstAuto)
4895      col.append('h4.mlabel').text(c.title).at({title: c.title})
4896      var mode = c.highlight || dflt
4897      col.append('div').at({class: 'fa-ex-prompt prompt-block'})
4898        .html(highlightPrompt(c, mode))
4899      var outs = col.append('div').at({class: 'fa-ex-outs'})
4900      ;['0.0','1.0'].forEach(k => {
4901        outs.append('div').at({class: 'fa-ex-out'})
4902          .html(`<span class='fa-alpha'>`
4903              + `α=${k==='0.0'?'0':'1'}</span>`
4904              + `<span>${esc(c.out[k] || '—')}</span>`)
4905      })
4906      col.append('div').at({class: 'fa-ex-verdict'})
4907        .text(c.verdict)
4908    })
4909    if (d.stim_gloss){
4910      sel.append('div').at({class: 'fa-ex-gloss'})
4911        .text(`[${d.own}] ${d.stim_gloss}`
4912            + (d.anom_gloss
4913               ? `  · anomaly: ${d.anom_gloss}` : ''))
4914    }
4915  }
4916
4917  if (ex){
4918    d3.selectAll('.flex-auto-example').each(function(){
4919      var sel = d3.select(this)
4920      var task = sel.attr('data-task')
4921      var tag = sel.attr('data-tag') || 'v2'
4922      var d = ex[tag]?.[task]
4923      if (d) renderExample(sel, d, tag)
4924    })
4925  }
4926
4927  if (rd){
4928    d3.selectAll('.flex-auto-readout').each(function(){
4929      var sel = d3.select(this)
4930      var meta = {cats: rd.cats, tasks: rd.tasks,
4931                  models: rd.models, labels: rd.labels,
4932                  colors: rd.colors,
4933                  model_labels: rd.model_labels}
4934      var ymax = d3.max(rd.data, r => r.mean) * 1.1 || .4
4935      barChart(sel, rd.data, meta,
4936               'Lens readout hit rate (cell, k=10)', ymax)
4937    })
4938  }
4939}
4940
4941window.init?.()
4942
4943// ─── public/post-training/init-post-training.js ───
4944window.initPostTraining = async function(opts){
4945  var spec = opts?.spec || 'tylenol'
4946  var sel = d3.select(opts?.sel || '.post-training').html('')
4947  var base = (opts?.datapath || './').replace(/\/?$/, '/')
4948  var d = await util.getFile(base + spec + '.json')
4949
4950  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
4951  var PROD = util.tol.blue, BASE = util.gray[600]
4952  var sideLabel = s => s.replace(/^base model$/i, 'Base model').replace(/^base$/, 'Base model').replace('post-trained', 'Sonnet 4.5')
4953
4954  if (spec === 'tylenol') return renderTylenol()
4955  return renderCombined()
4956
4957  // ── tylenol: 2 columns × (static prompt + 2 token bar-tables at the
4958  // single readout position). No per-token selection — readers see exactly
4959  // the keyPos readout the prose discusses; bake ships only that.
4960  function renderTylenol(){
4961    var sideColor = t => /base/i.test(t.label) ? BASE : PROD
4962    var pair = sel.append('div.pt-pair')
4963    var cols = pair.appendMany('div.pt-col', d.cols)
4964    var pr = cols.append('div.pt-prompt.prompt-block')
4965    if (d.sysText){
4966      pr.append('span.sys').text(d.sysText)
4967      pr.append('br'); pr.append('br')
4968    }
4969    pr.append('span.prompt-role').text('Human: ')
4970    pr.append('span').html(c => esc(c.promptBody)
4971      .replace(/(\d+mg)/, '<span class="hl">$1</span>')
4972      .replace('pain is', 'pain <span class="tok-hit">is</span>'))
4973
4974    var rt = cols.append('div.rtables')
4975      .appendMany('div.rtable', c => d3.sort(c.tables, t => !/base/i.test(t.label)))
4976    var hd = rt.append('div.pt-side')
4977    hd.append('span.swatch').st({background: sideColor})
4978    hd.append('span').text(t => /base/i.test(t.label) ? 'Base model' : 'Post-trained model')
4979    var ro = hd.filter(t => t.rollout).append('span.pt-ro').text('ⓘ')
4980    var tip = ro.append('div.pt-ro-tip')
4981    tip.append('div.pt-ro-title').text('Completion:')
4982    tip.append('div.prompt-block').text(t => t.rollout)
4983
4984    rt.append('div.bar-table').each(function(t){
4985      var maxLp = d3.max(t.rows, r => r.lp)
4986      var bg = util.tint(sideColor(t), 0.75)
4987      var rs = d3.select(this).appendMany('div.bar-row', t.rows)
4988      rs.append('div.bar').st({width: r => (Math.exp(r.lp - maxLp) * 100) + '%', background: bg})
4989      rs.append('span.t').text(r => util.ppCell(r.t).trim())
4990      rs.append('span.val').text(r => r.lp.toFixed(2))
4991    })
4992  }
4993
4994  // ── combined: title/sub, 2 stacked transcripts (rank-colored), bar chart ──
4995  function renderCombined(){
4996    sel.append('div.fig-title').text(d.title)
4997    sel.append('div.psub').text(d.sub)
4998    var main = sel.append('div.pt-main')
4999    var left = main.append('div.pt-left')
5000    var right = main.append('div.pt-right')
5001
5002    // rank → color (white→orange, log scale). Same ramp as feature activations.
5003    // capped at 0.85 so dark-end text stays legible — legend uses the same ramp().
5004    var yFloor = d.bars.yFloor
5005    var ramp = t => d3.interpolateOranges(t * 0.85)
5006    var rankT = r => Math.max(0, Math.min(1, 1 - Math.log(Math.min(r+1, yFloor))/Math.log(yFloor)))
5007    var rankColor = r => r >= yFloor ? '' : ramp(rankT(r))
5008    // flip text dark→light when the background is too dark to read
5009    var rankTxt = r => rankT(r) > 0.6 ? '#fff' : ''
5010
5011    var sideColor = t => t.label === 'base' ? BASE : PROD
5012    var tx = left.appendMany('div.pt-tx-wrap', d3.sort(d.transcripts, t => t.label !== 'base'))
5013    var side = tx.append('div.pt-side')
5014    side.append('span.swatch').st({background: sideColor})
5015    side.append('span').text(t => sideLabel(t.label))
5016    side.filter(t => t.answer).append('span.pt-side-ans')
5017      .text(t => 'Answer: ' + t.answer)
5018    var box = tx.append('div.pt-tx.prompt-block')
5019    box.append('span.sys').text(d.sysText)
5020    box.append('br'); box.append('br')
5021    box.append('span.prompt-role').text('Human: ')
5022    box.appendMany('span.tok', t => t.humanTokens)
5023      .text(e => e.s).st({background: e => rankColor(e.r), color: e => rankTxt(e.r)})
5024    box.append('br'); box.append('br')
5025    box.append('span.prompt-role').text('Assistant: ')
5026    box.appendMany('span.tok', t => t.asstTokens)
5027      .text(e => e.s).st({background: e => rankColor(e.r), color: e => rankTxt(e.r)})
5028    box.filter(t => t.ellipsis).append('span.ell').text(' …')
5029
5030    // legend: endpoint labels are HTML spans (not svg text) so they never clip
5031    var leg = left.append('div.pt-legend')
5032    leg.append('span.leg-label').text(`Token shading = J-lens rank of ${d.conceptLabel.replace(/ word$/, ' words')}`)
5033    leg.append('span.leg-end').text('1')
5034    var lw = 140, lh = 9, ln = 50
5035    leg.append('svg.colorbar').at({width: lw, height: lh})
5036      .appendMany('rect', d3.range(ln)).at({
5037        x: i => i*lw/ln, width: lw/ln + 1, height: lh, fill: i =>
5037 ramp(1 - i/(ln-1)),
5038      })
5039    leg.append('span.leg-end').text(yFloor + '+')
5040
5041    // bar panel: inverted log y, geomean bars + per-item dots
5042    // sized so the chart's bottom edge lines up with the bottom of the transcripts
5043    var margin = {left: 50, right: 8, top: 8, bottom: 44}
5044    var txBottom = (tx.nodes().at(-1)?.getBoundingClientRect().bottom - left.node().getBoundingClientRect().top) || 0
5045    var chartH = Math.max(320, txBottom - margin.top - margin.bottom)
5046    var c = d3.conventions({
5047      sel: right.append('div'), width: 220, height: chartH, margin,
5048    })
5049    var spans = d.bars.spans, items = d.bars.items
5050    var xpos = [], xc = 0
5051    spans.forEach((sp, si) => {
5052      if (si) xc += 0.6
5053      ;['base','prod'].forEach(sd => { xpos.push({sp, sd, x: xc}); xc += 1 })
5054    })
5055    var x = d3.scaleLinear().domain([-0.7, xc - 0.3]).range([0, c.width])
5056    c.y = d3.scaleLog().domain([yFloor, 0.85]).range([c.height, 0])
5057    c.yAxis.scale(c.y).tickValues([1,2,5,10,20,50,100]).tickFormat(d3.format('d'))
5058    c.drawAxis()
5059    c.svg.select('.x').remove()
5060    util.ggPlot(c)
5061    util.addAxisLabel(c, '', 'Rank of concept words →', '', 0, -8)
5062
5063    var ttSel = d3.select('.tooltip')
5064    var bw = x(0.85) - x(0)
5065    var rk = (it, sd, sp) => Math.min(it[sd][sp] + 1, yFloor)
5066    var fmtR = v => v >= yFloor ? '≥' + yFloor : v
5067    var circ = {}, slope = {}
5068    xpos.forEach(p => {
5069      var gm = d.bars.geomean[p.sp][p.sd]
5070      var base = p.sd === 'prod' ? PROD : BASE
5071      c.svg.append('rect').at({
5072        x: x(p.x) - bw/2, width: bw,
5073        y: c.y(gm), height: c.y(yFloor) - c.y(gm),
5074        fill: p.sp === 'user' ? base : util.tint(base, 0.5),
5075      })
5076      circ[p.sp + '_' + p.sd] = c.svg.appendMany('circle.jpt',
5077        items.map((it, i) => ({it, i, sp: p.sp, sd: p.sd, v: rk(it, p.sd, p.sp)})))
5078        .at({cx: x(p.x), cy: o => c.y(o.v), r: 1.8,
5079          fillOpacity: 0, stroke: util.gray[700], strokeWidth: 1}).nodes()
5080    })
5081    spans.forEach((sp, si) => {
5082      var x0 = x(xpos[si*2].x), x1 = x(xpos[si*2+1].x)
5083      slope[sp] = c.svg.appendMany('path.slope', items).at({
5084        d: it => `M ${x0} ${c.y(rk(it,'base',sp))} L ${x1} ${c.y(rk(it,'prod',sp))}`,
5085        stroke: util.gray[600], strokeWidth: 0.5, opacity: 0.25, pointerEvents: 'none',
5086      }).nodes()
5087    })
5088
5089    var last, hovEls = []
5090    var clearHov = () => { hovEls.forEach(n => n.classList.remove('hov')); hovEls = [] }
5091    util.nearestHover(c.svg, c.svg.selectAll('circle.jpt'), {
5092      radius: 40,
5093      onHover: (o, el, e) => {
5094        if (el !== last){
5095          last = el
5096          clearHov()
5097          hovEls = [slope[o.sp][o.i], circ[o.sp+'_base'][o.i], circ[o.sp+'_prod'][o.i]]
5098          hovEls.forEach(n => n.classList.add('hov'))
5099          var it = o.it
5100          ttSel.classed('tooltip-hidden', false).html('').st({maxWidth: 420})
5101          ttSel.append('div').html(`${o.sp} turn · ${it.id || ''}`)
5102          ttSel.append('table.tt-pair').html(
5103            `<tr><td class=k>Base model</td><td>rank <b>${fmtR(rk(it,'base',o.sp))}</b></td></tr>` +
5104            `<tr><td class=k>Sonnet 4.5</td><td>rank <b>${fmtR(rk(it,'prod',o.sp))}</b></td></tr>`)
5105          if (it.prompt) ttSel.append('div.prompt-block')
5106            .st({margin: '6px 0', maxHeight: '14em', overflowY: 'auto'}).text(it.prompt)
5107          var lt = it[o.sd]?.lens_topk
5108          if (lt?.tokens?.length){
5109            ttSel.append('div').st({fontSize: 'var(--fs-label)', color: 'var(--text-light)', margin: '6px 0 3px'})
5110              .text(`J-lens top-10 (${o.sd === 'prod' ? 'Sonnet 4.5' : 'base'}):`)
5111            var toks = ttSel.append('div.lens-topk').st({flexWrap: 'wrap'})
5112            lt.tokens.forEach((t, ti) => toks.append('span.lenstk').text(util.ppCell(t))
5113              .st({background: lt.hit_idx?.includes(ti) ? 'var(--hl-yellow)' : 'var(--tok-bg)'}))
5114          }
5115        }
5116        var bb = ttSel.node().getBoundingClientRect()
5117        ttSel.st({left: d3.clamp(20, e.clientX - bb.width/2, innerWidth - bb.width - 20) + 'px',
5118                  top: (innerHeight > e.clientY + 20 + bb.height ? e.clientY + 20 : e.clientY - bb.height - 20) + 'px'})
5119      },
5120      onLeave: () => { last = null; clearHov(); ttSel.classed('tooltip-hidden', true) },
5121    })
5122    c.svg.appendMany('text.xtick', xpos)
5123      .each(function(p){
5124        var [a,b] = (p.sd === 'prod' ? ['Sonnet','4.5'] : ['Base','model'])
5125        d3.select(this).text('').at({textAnchor:'middle'}).translate([x(p.x), c.height+12])
5126          .append('tspan').text(a).at({x:0})
5127        d3.select(this).append('tspan').text(b).at({x:0, dy:'1.1em'})
5128      })
5129    if (spans.length > 1){
5130      c.svg.append('text.xgroup').text('User turn')
5131        .translate([x(0.5), c.height+38]).at({textAnchor:'middle'})
5132      c.svg.append('text.xgroup').text('Assistant turn')
5133        .translate([x(3.1), c.height+38]).at({textAnchor:'middle'})
5134    }
5135  }
5136}
5137
5138window.init?.()
5139
5140// ─── public/lens-inline/init-lens-inline.js ───
5141window.initLensInline = async function(opts){
5142  var sel = d3.select(opts?.sel || '.lens-inline').html('')
5143  var base = (opts?.datapath || './').replace(/\/?$/, '/')
5144  var manifest = await util.getFile(base + 'manifest.json?' + Date.now())
5145  var slices = await Promise.all((manifest.slugs || []).map(s => util.loadLensSlice(base, s.slug)))
5146
5147  var FINAL = util.series[5], WORK = util.series[1], WORK2 = util.series[0]
5148  var colorOf = seg => seg.c ?? (seg.L >= 24 ? FINAL : WORK)
5149
5150  var figIntro = opts?.figIntro || manifest.figIntro
5151  var perSlug = manifest.perSlug || {
5152    avgfp_raw: {
5153      title: 'Protein Recognition',
5154      layers: [24, 20, 14], width: 340, callouts: [8, 10],
5155      cap: ['Five characters into the GFP amino acid sequence, the lens reads ',
5156            {ci:0,L:14,c:WORK2}, ', then ', {ci:1,L:14,c:WORK2}, 'escent and ', {ci:1,L:20}, '.'],
5157    },
5158    mars: {
5159      title: 'Multihop Recall',
5160      layers: [24, 17, 12], width: 350, callouts: [-1],
5161      cap: ['The lens shows ', {ci:0,L:12,c:WORK2}, ' and ', {ci:0,L:17},
5162            ' as intermediate steps before the answer ', {ci:0,L:24}, '.'],
5163    },
5164    calc_ooo: {
5165      title: 'Mental Arithmetic',
5166      layers: [24, 20, 18, 14], width: 210, callouts: [-1],
5167      cap: ['The lens reveals intermediate values ', {ci:0,L:18,c:WORK2}, ' and ', {ci:0,L:20},
5168            ', and the answer of ', {ci:0,L:24}, '.'],
5169    },
5170    bug_code: {
5171      title: 'Bug detection',
5172      layers: [17, 13, 10], width: 260, callouts: [-1], wrap: true,
5173      cap: ['At the empty-list call the lens reads ', {ci:0,L:10,c:WORK2},
5174            ', ', {ci:0,L:13}, ', ', {ci:0,L:17,c:FINAL}, '.'],
5175    },
5176    face: {
5177      title: 'ASCII Face',
5178      layers: [20, 17, 13], width: 230, callouts: [14, 19], wrap: true, sideStack: true,
5179      cap: ['The lens reads ', {ci:0,L:17,c:FINAL}, ' at the "o" and ',
5180            {ci:1,L:17}, ' / ', {ci:1,L:20,c:WORK2}, ' at the "^".'],
5181    },
5182    injection: {
5183      title: 'Prompt injection (Opus 4.5)',
5184      gloss: '[fabricated news search results inside a tool call]',
5185      layers: [21, 14, 10], width: 410, callouts: [17], wrap: true,
5186      cap: ['The lens shows the model has recognised the ',
5187            {ci:0,L:14}, ' ', {ci:0,L:10,c:WORK2}, ' ', {ci:0,L:21,c:FINAL}, '.'],
5188    },
5189  }
5190
5191  // Layout fallback chain: explicit rows → single-row order → preferred Figure 3
5192  // order when all six slugs are present → chunk manifest.slugs into rows of 3
5193  // (lens-inline-reasoning takes this path).
5194  var bySlug = Object.fromEntries(slices.map(d => [d.slug, d]))
5195  var manSlugs = (manifest.slugs || []).map(s => s.slug).filter(s => bySlug[s])
5196  var chunked = manSlugs.length
5197    ? [...Array(Math.ceil(manSlugs.length / 3))].map((_, i) => manSlugs.slice(i * 3, i * 3 + 3))
5198    : null
5199  var INLINE_ROWS = [['mars', 'calc_ooo', 'avgfp_raw'], ['bug_code', 'face', 'injection']]
5200  var rows = manifest.rows
5201    || (manifest.order && [manifest.order])
5202    || (INLINE_ROWS.flat().every(s => bySlug[s]) && INLINE_ROWS)
5203    || chunked
5204
5205  var figTitle = manifest.title ?? "The J-lens reveals the model's internal thoughts across layers"
5206  if (figTitle) sel.append('div.fig-title').text(figTitle)
5207  // "Figure N:" stays on the whole-figure <figcaption>, not panel 1's
5208  // subcaption — prime panelCaption's counter so the first-call prefix
5209  // injection (util.panelCaption n==1) never fires here.
5210  opts._pc = opts._pc || 1
5211  rows.forEach(row => {
5212    var grid = sel.append('div.figgrid')
5213    row.forEach(slug => bySlug[slug] && renderPanel(grid.append('div.cell.panel'), bySlug[slug]))
5214  })
5215
5216  var figcap = sel.node().closest('figure')?.querySelector('figcaption')
5217
5218  // The slice-viewer link is part of the caption text (a linked phrase), not a
5219  // separate floating element. It goes on the figure-level figcaption when one
5220  // exists (article render); standalone pages append it to the first panel caption.
5221  if (opts?.sliceLink !== false){
5222    var stackHref = (opts?.slicePath || (base.startsWith('../../') ? '../slice-stack/' : 'public/slice-stack/')) + 'index.html'
5223    var capHome = figcap ? d3.select(figcap) : sel.select('.panel .cap')
5224    capHome.select('span.slice-note').remove()
5225    capHome.append('span.slice-note')
5226      .html(` Explore these prompts in the <a class=explore href="${stackHref}">slice viewer</a>.`)
5227  }
5228
5229  function renderPanel(panel, d){
5230    var cfg = perSlug[d.slug] || {}
5231    var title = cfg.title ?? d.title
5232    if (title) panel.append('div.mlabel').text(title)
5233    var lspec = (cfg.layers || [24, 17, 12, 6])
5234      .map(l => typeof l === 'number' ? {L: l} : l)
5235      .filter(r => r.L < d.n_layers)
5236    var presets = (cfg.callouts || [d.default_ctx ?? d.nCtx - 1]).map(c => c < 0 ? d.nCtx - 1 : c)
5237    var state = {override: null}
5238
5239    var body = cfg.sideStack ? panel.append('div.body-row') : panel
5240    var paraSel = body.append('div.prompt-para').classed('wrap', cfg.wrap)
5241      .at({title: cfg.wrap ? null : d.ctxTokens.map(t => util.ppToken(t, true)).join('')})
5242    if (cfg.gloss) paraSel.append('span.prompt-gloss').text(cfg.gloss + ' ')
5243    var isTrunc = i => d.ctxTokens[i] === '…'
5244    var ptoks = util.appendPromptPara(paraSel, d.ctxTokens)
5245      .classed('trunc', isTrunc)
5246    if (!cfg.noExplore) ptoks.on('click', (e, i) => {
5247      if (isTrunc(i)) return
5248      var pi = d3.minIndex(presets, p => Math.abs(p - i))
5249      state.override = (presets[pi] === i) ? null : {pi, ctx: i}
5250      render()
5251    })
5252    else ptoks.st({cursor: 'default'})
5253
5254    // Fixed widths only apply ≥768px (style.css); stacked mobile panels fill the card.
5255    // A prompt line that nearly fits the configured width must never wrap — font
5256    // metrics drift a few px across platforms/zoom and the break lands mid-verse
5257    // (",↵" stranded on its own line). Widen to the prompt's natural line width when
5258    // it's close; genuinely-wrapping prompts (long lines, > +80px) keep the configured width.
5259    function panelWidth(){
5260      if (!cfg.wrap) return cfg.width
5261      var n = paraSel.node()
5262      n.style.cssText = 'position:absolute;width:max-content;white-space:pre'
5263      var need = n.offsetWidth + 3
5264      n.style.cssText = ''
5265      return need > cfg.width && need < cfg.width + 80 ? need : cfg.width
5266    }
5267    if (cfg.width){
5268      panel.style('--panel-w', panelWidth() + 'px')
5269      // re-measure once webfonts land — fallback-font metrics differ slightly
5270      document.fonts?.ready.then(() => panel.style('--panel-w', panelWidth() + 'px'))
5271    }
5272
5273    var swoop = panel.append('svg.swoop')
5274    var stacks = body.append('div.stacks').appendMany('div.stack', d3.range(preset
5274s.length))
5275    var capColors = new Map((cfg.cap || []).filter(s => s.L != null)
5276      .map(s => [s.t ?? d.argmax(presets[s.ci], s.L), colorOf(s)]))
5277    // L100 reads as a layer count; cap the display at L99 here (paper-wide
5278    // axis ticks still end at 100 — this is a per-figure label override).
5279    var llabel = i => { var d = util.layerLabel(i); return 'L' + (d == 100 ? 99 : d) }
5280    stacks.each(function(pi){
5281      var rows = d3.select(this).appendMany('div.layer-row', lspec)
5282      rows.append('span.llabel').text(r => llabel(r.L))
5283      rows.append('span.top1')
5284      rows.append('span.rank').text(r => r.rank > 1 ? '(' + util.ppRank(r.rank) + ')' : '')
5285    })
5286
5287    var capSel = util.panelCaption(panel, opts, '').classed('cap', true)
5288    if (figIntro && !opts._fi) { capSel.append('span').text(figIntro + ' '); opts._fi = 1 }
5289    ;(cfg.cap || []).forEach(seg => {
5290      if (typeof seg === 'string') return capSel.append('span').text(seg)
5291      var disp = seg.t ? util.ppCell(seg.t).trim() : tokAt(presets[seg.ci], seg.L).trim()
5292      capSel.append('span').html(`"<span class=ckey style="color:${colorOf(seg)}">${disp}</span>"`)
5293    })
5294    function ctxAt(pi){ return state.override?.pi === pi ? state.override.ctx : presets[pi] }
5295    function tokAt(ctx, L){
5296      var raw = d.argmax(ctx, L)
5297      var s = util.ppCell(raw)
5298      return (d.upwordAt?.[ctx] && raw && !/^[⍽·]/.test(raw)) ? s.toUpperCase() : s
5299    }
5300
5301    function render(){
5302      var shown = d3.range(presets.length).map(ctxAt)
5303      ptoks.classed('sel', i => shown.includes(i))
5304      stacks.each(function(pi){
5305        d3.select(this).selectAll('.top1').each(function(r){
5306          var ctx = r.ctx ?? ctxAt(pi)
5307          var raw = r.t ?? d.argmax(ctx, r.L)
5308          var c = r.c ?? capColors.get(raw)
5309          var disp = util.ppCell(raw)
5310          if (d.upwordAt?.[ctx] && raw && !/^[⍽·]/.test(raw)) disp = disp.toUpperCase()
5311          if (r.cap1) disp = disp.replace(/\S/, c => c.toUpperCase())
5312          d3.select(this).text(disp).at({title: disp}).st({color: c || 'var(--text-light)'})
5313        })
5314      })
5315      requestAnimationFrame(() => layoutStacks(shown))
5316    }
5317
5318    function layoutStacks(shown){
5319      var pn = panel.node(), pr = pn.getBoundingClientRect()
5320      // fitMount may have scaled the whole mount; getBoundingClientRect is
5321      // post-transform but SVG user units are pre-transform, so unscale.
5322      var k = pn.offsetWidth / pr.width || 1
5323      var paraR = paraSel.node().getBoundingClientRect()
5324      var maxTx = Math.round((paraR.right - pr.left) * k) - 8
5325      swoop.at({width: pn.offsetWidth}).html('')
5326      // connector runs UNDER the prompt text (.ptok sits above with a bg-colored
5327      // text stroke, style.css), so no white halo needed on the line itself
5328      var connector = path =>
5329        swoop.append('path').at({d: path, fill: 'none', stroke: util.gray[500], strokeWidth: 1})
5330      stacks.each(function(pi){
5331        var tok = ptoks.filter(i => i === shown[pi]).node()
5332        if (!tok) return
5333        // a wrapped inline token has one rect per line fragment — anchor the
5334        // swoop to the last fragment so it points at the visible word, not the
5335        // empty trailing box on the previous line.
5336        var rects = tok.getClientRects()
5337        var t = rects[rects.length - 1] || tok.getBoundingClientRect()
5338        var tx = Math.min(Math.round((t.left + t.width/2 - pr.left) * k), maxTx)
5339        var s = this.getBoundingClientRect()
5340        if (cfg.sideStack) {
5341          var ty = Math.round((t.top + t.height/2 - pr.top) * k)
5342          var x1 = Math.round((s.left - pr.left) * k) - 1
5343          var y1 = Math.round((s.top + s.height/2 - pr.top) * k)
5344          var mx = Math.round((tx + t.width*k/2 + x1) / 2)
5345          connector(`M${tx + t.width*k/2 + 1},${ty} C${mx},${ty} ${mx},${y1} ${x1},${y1}`)
5346        } else {
5347          var x1 = Math.round((s.left - pr.left) * k) + 18
5348          var y0 = Math.round((t.bottom - pr.top) * k) + 1, y1 = Math.round((s.top - pr.top) * k)
5349          var my = Math.round((y0 + y1) / 2)
5350          connector(`M${tx},${y0} C${tx},${my} ${x1},${my} ${x1},${y1}`)
5351        }
5352      })
5353    }
5354
5355    panel.node().__relayout = () =>
5355 layoutStacks(d3.range(presets.length).map(ctxAt))
5356    render()
5357  }
5358
5359  var mount = sel.node()
5360  mount.__liRO?.disconnect()
5361  mount.__liRO = new ResizeObserver(util.throttleDebounce(() =>
5362    sel.selectAll('.panel').each(function(){ this.__relayout?.() }), 80))
5363  mount.__liRO.observe(mount.closest('figure') || mount)
5364}
5365
5366window.init?.()
5367
5368// ─── public/methods-qualitative/init-methods-qualitative.js ───
5369window.initMethodsQualitative = async ({datapath, state}) => {
5370  const sel = d3.select('.methods-qualitative').html('')
5371  const data = await util.getFile(datapath + 'qualitative.json')
5372  const LABEL = {jacobian: 'J-lens', logit: 'Logit', tuned: 'Tuned'}
5373  const TITLE = {protein: 'Green Fluorescent Protein'}
5374  const ttSel = d3.select('.tooltip')
5375
5376  const FINAL = util.series[0], WORK = util.series[1]
5377  // ppCell leaves \t as a raw tab (renders as blank); show a glyph instead
5378  const ppTok = t => util.ppCell(t).replace(/\t/g, '⇥')
5379  // Per-panel token → category. Matched against ppCell(t).trim().toLowerCase().
5380  // 'final' = the token the model actually outputs at this position;
5381  // 'work' = intermediates related to the computation; everything else is noise.
5382  const CAT = {
5383    multihop: {
5384      final: ['red', 'rust'],
5385      work:  ['mars', 'color', 'colour'],
5386    },
5387    poetry: {
5388      final: ['he'],
5389      work:  ['rabbit', 'rabb'],
5390    },
5391    multilingual: {
5392      final: ['大'],
5393      work:  ['big', 'bigger', 'large', 'larger', 'больш', 'opposite', 'opposites', 'oppos', 'chinese'],
5394    },
5395    protein: {
5396      final: ['g'],
5397      work:  ['green', 'fluor', 'fluorescent', 'lumin', 'color', 'colors', 'light', 'nobel'],
5398    },
5399    face: {
5400      final: ['', '^', '|'],
5401      work:  ['face', 'faces', 'facial', 'nose', 'mouth', 'emoji'],
5402    },
5403    compute: {
5404      final: ['edly'],
5405      work:  ['seven', '7', 'nine', '9', 'math', 'arithmetic', 'square', 'squared', 'calcul'],
5406    },
5407  }
5408  function catColor(panel, t){
5409    // ppCell turns all-whitespace into ␣ glyphs; normalise back to '' so a
5410    // panel can list '' in final to mean "any whitespace token".
5411    const s = util.ppCell(t).replaceAll('␣', ' ').trim().toLowerCase()
5412    const c = CAT[panel] || {}
5413    if (c.final?.includes(s)) return FINAL
5414    if (c.work?.includes(s)) return WORK
5415    return null
5416  }
5417
5418  const pickRows = rows => rows.filter((r, i) => i % 2 == 0)
5419
5420  const panels = sel.append('div.mq-grid')
5421    .appendMany('div.mq-panel', data.panels)
5422
5423  panels.append('div.fig-title').text(d => TITLE[d.name] || d.title)
5424
5425  // one table per panel: rows = layers (deep→shallow), cols = methods.
5426  // Clicking a prompt token swaps the table to that position's readouts.
5427  panels.each(function (panel) {
5428    const psel = d3.select(this)
5429    const positions = panel.positions || {[panel.keyPos]: panel.rows}
5430    let pos = panel.keyPos
5431
5432    const prompt = psel.append('div.prompt-block.mq-prompt')
5433    if (panel.tokens) {
5434      const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
5435      const ptoks = prompt.appendMany('span.ptok', panel.tokens)
5436        .html(d => esc(d.s || '·').replace(/\n/g, '↵<br>'))
5437        .classed('mq-nopos', d => !positions[d.pos])
5438        .on('click', (e, d) => { if (positions[d.pos]) { pos = d.pos; draw() } })
5439      var syncToks = () => ptoks.classed('tok-hit', d => d.pos == pos)
5440    } else {
5441      prompt.html(panel.promptHtml
5442        .replace(/^<div class="promptbox"><b>Prompt:<\/b>\s?/, '')
5443        .replace(/<\/div>$/, ''))
5444        .selectAll('.ktok').classed('tok-hit', true)
5445      var syncToks = () => {}
5446    }
5447
5448    const t = psel.append('table.mq-table')
5449    const hr = t.append('thead').append('tr')
5450    hr.append('th')
5451    hr.appendMany('th.mq-mhdr', data.methods).text(m => LABEL[m] || m)
5452    const tb = t.append('tbody')
5453
5454    function draw(){
5455      syncToks()
5456      tb.html('')
5457      pickRows(positions[pos]).forEach(r => {
5458        const tr = tb.append('tr')
5459        tr.append('td.mq-layer').text('L' + util.layerLabel(r.layer))
5460        data.methods.forEach(m => {
5461          if (!r[m].k?.length) return tr.append('td.mq-cell.mq-noise').text('—')
5462          const c = catColor(panel.name, r[m].t)
5463          const d = {...r[m], layer: r.layer, method: m}
5464          tr.append('td.mq-cell')
5465            .text(ppTok(d.t))
5466            .classed('mq-noise', !c)
5467            .st({color: c})
5468            .datum(d)
5469            .call(d3.attachTooltip, ttSel, [])
5470            .on('mouseover', (e, d) => {
5471              ttSel.html('')
5472              const tt = ttSel.append('div.mq-tt')
5473              tt.append('div.mq-tt-hdr').text(`L${util.layerLabel(d.layer)} · ${LABEL[d.method]} lens`)
5474              const tbl = tt.append('table')
5475              d.k.forEach((x, i) => {
5476                const tr = tbl.append('tr')
5477                tr.append('td.r').text(i + 1)
5478                tr.append('td.t').text(ppTok(x.t)).st({color: catColor(panel.name, x.t)})
5479                tr.append('td.p').text(x.p.toFixed(1) + '%')
5480              })
5481            })
5482        })
5483      })
5484    }
5485    draw()
5486  })
5487
5488  util.colorCaption(sel, {'predicted next token': FINAL, intermediates: WORK})
5489}
5490
5491window.init?.()
5492
5493// ─── public/jlens-audit/init-jlens-audit.js ───
5494!function(){
5495
5496// J-lens keeps the paper's lens blue; NLA/SAE are competitor readouts.
5497var METHODS = [
5498  {key: 'nla',   label: 'NLA (used for ground truth)', color: util.tol.magenta},
5499  {key: 'jlens', label: 'J-lens',                      color: util.tol.blue},
5500  {key: 'sae',   label: 'SAE features',                color: util.gray[500]},
5501]
5502
5503var CASE_LINES = {
5504  'Evaluation awareness':   ['Evaluation', 'awareness'],
5505  'Planning in poetry':     ['Planning', 'in poetry'],
5506  'Misreported tool calls': ['Misreported', 'tool calls'],
5507  'Answer thrashing':       ['Answer', 'thrashing'],
5508  'Language switching':     ['Language', 'switching'],
5509  'Reward hacking':         ['Reward', 'hacking'],
5510}
5511
5512function xlab(c, tx, lines){
5513  var t = c.svg.append('text.xlab').at({x: tx, y: c.height + 14, textAnchor: 'middle'})
5514  lines.forEach((ln, li) => t.append('tspan').text(ln).at({x: tx, dy: li ? 13 : 0}))
5515}
5516
5517// Solid bar (mean) + open per-item dots spread across the bar + optional SEM whisker.
5518function barDots(c, bx, bw, mt, mean, pts, sem){
5519  c.svg.append('rect').at({
5520    x: bx, y: c.y(mean), width: bw, height: c.height - c.y(mean), fill: mt.color,
5521  })
5522  var cx = bx + bw/2
5523  if (sem) util.barWhisker(c.svg, {x: cx, lo: c.y(mean - sem), hi: c.y(mean + sem)})
5524  // open per-item dots, same convention as misalign-lens jitter dots
5525  var jw = bw*0.55
5526  c.svg.appendMany('circle.jpt', pts || [])
5527    .at({cx: (_, i) => cx + ((i / Math.max((pts.length - 1), 1)) - 0.5) * jw,
5528         cy: p => c.y(p), r: 2.2, fillOpacity: 0, stroke: util.gray[700], strokeWidth: 1})
5529}
5530
5531window.initJlensAudit = async function(opts){
5532  var sel = d3.select('.jlens-audit').html('')
5533  var base = (opts?.datapath || './').replace(/\/?$/, '/')
5534  var d = await util.getFile(base + 'data.json')
5535
5536  var row = sel.append('div.panel-row')
5537
5538  // Shrink toward the container on narrow screens so fitMount's scale stays
5539  // readable; desktop keeps the full 620px chart.
5540  var availW = sel.node().closest('figure')?.clientWidth || 620
5541  var chartW = d3.clamp(380, availW - 54, 620)
5542
5543  // Grouped bars per case; dots = per-claim means (n=10 seeds each).
5544  var cb = d3.conventions({
5545    sel: row.append('div.panel').append('div'),
5546    width: chartW, height: 210,
5547    margin: {left: 46, top: 8, right: 8, bottom: 36},
5548  })
5549  cb.y.domain([0, 10])
5550  cb.yAxis.ticks(5); cb.drawAxis(); cb.svg.select('.x').remove()
5551  util.ggPlot(cb)
5552  util.addAxisLabel(cb, '', 'Investigator score')
5553  var x0 = d3.scaleBand().domain(d.cases.map(cs => cs.id)).range([0, cb.width]).padding(0.22)
5554  var x1 = d3.scaleBand().domain(METHODS.map(mt => mt.key)).range([0, x0.bandwidth()]).padding(0.12)
5555  d.cases.forEach(cs => {
5556    var gx = x0(cs.id)
5557    METHODS.forEach(mt => {
5558      var s = cs.scores[mt.key]
5559      barDots(cb, gx + x1(mt.key), x1.bandwidth(), mt, s.mean, s.claims, 0)
5560    })
5561    xlab(cb, gx + x0.bandwidth()/2, CASE_LINES[cs.label] || [cs.label])
5562  })
5563
5564  var leg = sel.append('div.legend-row')
5565  METHODS.forEach(mt => {
5566    var item = leg.append('div.legend-item')
5567    item.append('span.legend-sq').st({background: mt.color})
5568    item.append('span').text(mt.label)
5569  })
5570}
5571
5572}()
5573window.init?.()
5574
5575// ─── public/jlens-rm-bias/init-jlens-rm-bias.js ───
5576window.initJlensRmBias = async function(opts){
5577  var sel = d3.select('.jlens-rm-bias').html('')
5578  var base = (opts?.datapath || './').replace(/\/?$/, '/')
5579  var d = await util.getFile(base + 'data.json')
5580
5581  var wordColor = {reward: util.tol.orange, bias: util.tol.red}
5582  var setLabel = {neutral: 'Neutral', quirk_eliciting: 'Quirk-eliciting', goal_probing: 'Goal-probing'}
5583  var ctlLabel = {
5584    'RM bias model\n(Jacobian lens)': 'RM bias model,\nJ-lens',
5585    'RM bias model\n(Logit lens)':    'RM bias model,\nlogit lens',
5586    'Baseline\n(Jacobian lens)':      'Baseline model,\nJ-lens',
5587    'Baseline\n(Logit lens)':         'Baseline model,\nlogit lens',
5588  }
5589  var ctlLabelM = {
5590    'RM bias model\n(Jacobian lens)': 'RM bias\nmodel,\nJ-lens',
5591    'RM bias model\n(Logit lens)':    'RM bias\nmodel,\nlogit lens',
5592    'Baseline\n(Jacobian lens)':      'Baseline\nmodel,\nJ-lens',
5593    'Baseline\n(Logit lens)':         'Baseline\nmodel,\nlogit lens',
5594  }
5595  var cw = Math.min(sel.node().parentNode.getBoundingClientRect().width || 800,
5596                    (document.documentElement.clientWidth || 800) - 36)
5597  var mobile = cw < 700
5598  var pw = Math.min(420, Math.max(240, cw - 60))
5599  var colorTitle = t => t.replace(/"(reward|bias)"/g, (_, w) => `"<span style="color:${wordColor[w]}">${w}</span>"`)
5600
5601  var row = sel.append('div.row')
5602
5603  function drawDotWhisker(parent, groups, series, raw, prompts, title, ylabel, w, h){
5604    var panel = parent.append('div.panel')
5605    panel.append('div.fig-title').html(colorTitle(title))
5606    var c = d3.conventions({
5607      sel: panel.append('div'),
5608      width: w, height: h, margin: {left: 48, top: 8, right: 8, bottom: mobile ? 58 : 44},
5609    })
5610    var allPts = raw ? Object.values(raw).flat(2) : []
5611    var ymax = d3.max(Object.values(series).flat().concat(allPts)) || 1
5612    var x0 = d3.scaleBand().domain(d3.range(groups.length)).range([0, c.width]).padding(0.18)
5613    var x1 = d3.scaleBand().domain(Object.keys(series)).range([0, x0.bandwidth()]).paddingInner(0.18)
5614    c.y.domain([0, ymax]).nice()
5615    c.yAxis.ticks(5); c.drawAxis(); c.svg.select('.x').remove()
5616    util.ggPlot?.(c)
5617    util.addAxisLabel?.(c, '', ylabel)
5618
5619    groups.forEach((g, gi) => {
5620      var gx = x0(gi)
5621      Object.entries(series).forEach(([sn, vals]) => {
5622        var bx = gx + x1(sn), bw = x1.bandwidth(), cx = bx + bw/2
5623        var v = vals[gi], col = wordColor[sn]
5624        c.svg.append('rect').at({x: bx, width: bw, y: c.y(v), height: c.height - c.y(v), fill: col})
5625        if (raw && raw[sn]){
5626          var pts = raw[sn][gi], sem = d3.deviation(pts)/Math.sqrt(pts.length) || 0
5627          util.barWhisker(c.svg, {x: cx, lo: c.y(v - sem), hi: c.y(v + sem), stroke: util.gray[900]})
5628          var jw = bw*0.55, pr = prompts?.[gi]
5629          c.svg.appendMany('circle.jpt', pts.map((p, i) => ({p, sn, col, pr: pr?.[i]})))
5630            .at({cx: (_, i) => cx + (i/(pts.length-1||1) - 0.5)*jw, cy: o => c.y(o.p), r: 2.2,
5631              fillOpacity: 0, stroke: '#000', strokeWidth: 1})
5632        }
5633      })
5634      var lines = g.split('\n'), tx = gx + x0.bandwidth()/2
5635      var t = c.svg.append('text.xlab').at({x: tx, y: c.height + 14, textAnchor: 'middle'})
5636      lines.forEach((ln, li) => t.append('tspan').text(ln).at({x: tx, dy: li ? 13 : 0}))
5637    })
5638  }
5639
5640  var ctlRaw = {
5641    reward: d.control_2x2.map(c => c.reward_pts),
5642    bias: d.control_2x2.map(c => c.bias_pts),
5643  }
5644  var hasPts = ctlRaw.reward.every(Boolean)
5645  drawDotWhisker(
5646    row, d.control_2x2.map(c => (mobile ? ctlLabelM : ctlLabel)[c.label] || c.label),
5647    hasPts
5648      ? {reward: ctlRaw.reward.map(p => d3.mean(p)), bias: ctlRaw.bias.map(p => d3.mean(p))}
5649      : {reward: d.control_2x2.map(c => c.reward), bias: d.control_2x2.map(c => c.bias)},
5650    hasPts ? ctlRaw : undefined,
5651    d.control_2x2.map(c => c.prompts),
5652    `"reward" and "bias" in top-25 J-lens readouts`,
5653    'Appearances →', mobile ? pw : 380, 220,
5654  )
5655
5656  var sets = Object.keys(d.per_set).sort((a, b) => d.per_set[b].reward.mean - d.per_set[a].reward.mean)
5657  drawDotWhisker(
5658    row, sets.map(s => setLabel[s] || s),
5659    {reward: sets.map(s => d.per_set[s].reward.mean), bias: sets.map(s => d.per_set[s].bias.mean)},
5660    {reward: sets.map(s => d.per_set[s].reward.points), bias: sets.map(s => d.per_set[s].bias.points)},
5661    sets.map(s => d.per_set[s].prompts),
5662    'Breakdown per prompt type',
5663    'Appearances →', mobile ? pw : 280, 220,
5664  )
5665
5666  util.colorCaption(sel, wordColor)
5667}
5668window.init?.()
5669
5670// ─── public/jlens-rm-bias-examples/init-jlens-rm-bias-examples.js ───
5671window.initJlensRmBiasExamples = async function(opts){
5672  var sel = d3.select('.jlens-rm-bias-examples').html('')
5673  var base = (opts?.datapath || './').replace(/\/?$/, '/')
5674  var d = await util.getFile(base + 'data.json')
5675
5676  var setLabel = {neutral: 'Neutral', quirk_eliciting: 'Quirk-eliciting', goal_probing: 'Goal-probing'}
5677  var sets = Object.keys(d.examples)
5678
5679  var er = sel.append('div.ex-row.figgrid')
5680  sets.forEach(s => {
5681    var ex = d.examples[s]
5682    var card = er.append('div.card.cell')
5683    card.append('div.set.mlabel').text(setLabel[s] || s)
5684    // Lens is read at the ":" after Assistant — outline that position.
5685    var pb = card.append('div.prompt-block')
5686    pb.append('span.prompt-role').text('Human: ')
5687    pb.append('span').text(ex.prompt)
5688    pb.append('span.prompt-role').text('\n\nAssistant')
5689    pb.append('span.tok-hit').text(':')
5690    card.append('div.toks-label.mlabel').text('Top J-lens readout')
5691    var toks = card.append('div.toks')
5692    // Drop tokens that contain U+FFFD (partial multibyte byte sequences) or
5693    // decode to empty after marker stripping — they're tokenizer artifacts.
5694    var kept = ex.tokens.map((t, i) => ({t, hit: ex.hit_idx.includes(i)}))
5695      .filter(o => !/�/.test(o.t) && (util.ppToken?.(o.t) ?? o.t).trim() !== '')
5696    kept.forEach(o => {
5697      toks.append('span.tok').text(util.ppToken?.(o.t) ?? o.t).classed('hl', o.hit)
5698    })
5699  })
5700}
5701window.init?.()
5702
5703// ─── public/pref-violation-lens/init-pref-violation-lens.js ───
5704window.initPrefViolationLens = async function(opts){
5705  var sel = d3.select('.pref-violation-lens').html('')
5706  var base = (opts?.datapath || './').replace(/\/?$/, '/')
5707  var d = await util.getFile(base + 'data.json')
5708
5709  var BASE = util.gray[600], POST = util.tol.blue
5710
5711  // ── top row: two example columns (baked HTML fragments) ──
5712  var top = sel.append('div.pvl-examples')
5713  d.examples.forEach(h => top.append('div.pvl-ex').html(h))
5714  // append the example layer to the Base/Post-trained mini-table headers
5715  top.selectAll('.thd span:last-child').each(function(){
5716    d3.select(this).append('span.exly').text(` (${d.ex_layer})`)
5717  })
5718
5719  // ── bottom row: d3 grouped-bar panels ──
5720  sel.append('div.fig-title.pvl-title')
5721    .text('Conflict signal in J-lens, base vs post-trained')
5722  var bot = sel.append('div.pvl-bars')
5723  var rng = d3.randomLcg(7)
5724  d.panels.forEach((p, pi) => {
5725    var W = 420, H = 240
5726    var c = d3.conventions({sel: bot.append('div'), width: W, height: H,
5727      margin: {left: 44, right: 8, top: 24, bottom: 44}})
5728    var x = d3.scaleBand().domain(d3.range(p.groups.length)).range([0, c.width])
5729      .paddingInner(0.35).paddingOuter(0.12)
5730    var bw = x.bandwidth(), barW = bw*0.46, dx = bw*0.25
5731    c.y.domain([0, p.ymax])
5732    c.yAxis.ticks(5)
5733    c.drawAxis()
5734    c.svg.select('.x').remove()
5735    util.ggPlot(c)
5736    util.addAxisLabel(c, '', `${p.label} in J-lens (% lens prob) →`)
5737
5738    if (pi === 0){
5739      var lg = c.svg.append('g.pvl-lg').translate([6, 6])
5740      ;[['Base model', BASE], ['Post-trained model', POST]].forEach(([t, col], i) => {
5741        lg.append('rect').at({x: 0, y: i*16, width: 10, height: 10, rx: 2, fill: col})
5742        lg.append('text').text(t).at({x: 15, y: i*16 + 9})
5743      })
5744    }
5745
5746    p.groups.forEach((g, gi) => {
5747      var cx = x(gi) + bw/2
5748      ;[['base', -dx, BASE], ['post', dx, POST]].forEach(([m, off, fill]) => {
5749        var v = g[m]
5750        c.svg.append('rect').at({x: cx + off - barW/2, width: barW,
5751          y: c.y(v.mean), height: c.height - c.y(v.mean), fill})
5752        c.svg.appendMany('circle', v.pts)
5753          .at({cx: pt => cx + off + (rng() - 0.5)*barW*0.5, cy: pt => c.y(pt),
5754               r: 2.2, fill: util.gray[900], opacity: 0.3})
5755      })
5756      // 2-line group label
5757      var lbl = c.svg.append('text.glab').at({x: cx, y: c.height + 14, textAnchor: 'middle'})
5758      g.label.split(' ').forEach((w, i) =>
5759        lbl.append('tspan').text(w).at({x: cx, dy: i ? '1.15em' : 0}))
5760    })
5761  })
5762
5763  var cap = sel.node().closest('figure')?.querySelector('figcaption')
5764  if (cap) cap.innerHTML = cap.innerHTML.replace(/ \((?:blue|orange)\)/g, '')
5765  util.colorCaption(sel, {
5766    'base': {bg: BASE, color: '#fff', firstOnly: true},
5767    'post-trained': {bg: POST, color: '#fff', firstOnly: true},
5768  })
5769}
5770
5771window.init?.()
5772
5773// ─── public/layer-diagram/init-layer-diagram.js ───
5774window.initLayerDiagram = async function({datapath}){
5775  var sel = d3.select('.layer-diagram').html('')
5776  var d = await util.getFile(datapath + 'cka.json')
5777
5778  // Visual vocab matched to intro-structural so the two schematics read as one.
5779  var gray = util.gray, manilla = util.brand.manilla
5780  var jEdge = d3.color(util.brand.kraft).darker(0.5).formatHex()
5781  var C = {
5782    soft: gray[100], softEdge: gray[300],
5783    jBand: manilla, jEdge: jEdge,
5784    blockBg: util.tint(manilla, 0.55), blockEdge: util.tint(manilla, 0.15),
5785    arrow: gray[400], text: gray[700], textFade: gray[400], annot: gray[500],
5786  }
5787
5788  function wrap(s, w){
5789    var out = [], line = ''
5790    s.split(/\s+/).forEach(t => {
5791      if (line && (line + ' ' + t).length > w){ out.push(line); line = t }
5792      else line = line ? line + ' ' + t : t
5793    })
5794    if (line) out.push(line)
5795    return out
5796  }
5797
5798  // narrow containers (mobile / single gallery column): both panels are sized
5799  // to the figure's content width so neither overflows and gets clipped by the
5800  // centered .ld-row — the schematic scales via viewBox, the heatmap redraws
5801  // smaller with a horizontal colorbar below it. The figure's width can settle
5802  // after first render (gallery card layout / scrollbars), so re-render when it
5803  // changes instead of trusting a single early measurement.
5804  var fig = sel.node().closest('figure')
5805  var availW, narrow, lastW = -1
5806
5807  function render(){
5808    var cs = fig && getComputedStyle(fig)
5809    availW = fig ? fig.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0) : 1e6
5810    if (!availW || availW < 0) availW = 1e6
5811    if (Math.abs(availW - lastW) < 2) return
5812    lastW = availW
5813    narrow = availW < 560
5814    sel.html('')
5815    var row = sel.append('div.ld-row')
5816    drawDiagram(row.append('div.ld-diagram'))
5817    drawHeatmap(row.append('div.ld-heatmap'))
5818  }
5819  render()
5820  if (fig){
5821    fig.__ldRO?.disconnect()
5822    fig.__ldRO = new ResizeObserver(util.throttleDebounce(render, 200))
5823    fig.__ldRO.observe(fig)
5824  }
5825
5826  // ───── left: layer schematic — sliver/band/J-space shared with intro-structural ─────
5827  function drawDiagram(host){
5828    var NL = 6, WS = [3, 5]
5829    var W = 400, H = 420
5830    var svg = host.append('svg').at({width: W, height: H, viewBox: `0 0 ${W} ${H}`})
5831    if (narrow && availW < W) svg.at({width: availW, height: Math.round(H*availW/W)})
5832
5833    function rrect(x, y, w, h, r, attrs){
5834      return svg.append('rect').at({x, y, width: w, height: h, rx: r, ry: r, ...attrs})
5835    }
5836    function brace(x, y0, y1, bw){
5837      var ym = (y0 + y1) / 2
5838      svg.append('path.brace').at({fill: 'none', stroke: C.arrow, d:
5839        `M ${x+bw},${y0} Q ${x},${y0} ${x},${y0+bw} V ${ym-bw}` +
5840        ` Q ${x},${ym} ${x-bw},${ym} Q ${x},${ym} ${x},${ym+bw}` +
5841        ` V ${y1-bw} Q ${x},${y1} ${x+bw},${y1}`})
5842    }
5843
5844    var g = util.drawJSliver(svg, {bx0: 132, bx1: 252, H, capH: 24, pad: 14,
5845      jFrac: [(NL - WS[1])/NL, (NL - WS[0] + 1)/NL], arrowId: 'ld-arrow'})
5846    var {cx, bx0, bx1, rTop, rBot, jTop, jBot, sliverL} = g
5847    var seg = (rBot - rTop)/NL
5848
5849    // left annotations: motor/sensory braces outside, workspace inside w/ dashed leader
5850    var bwBrace = 8, tip = 3
5851    var bxBrace = bx0 - bwBrace - tip, txtX = bxBrace - bwBrace - 8
5852    function annoText(ym, label, desc){
5853      var lines = [label, ...wrap(desc, 16)]
5854      var ty0 = ym - (lines.length - 1) * 7
5855      var txt = svg.append('text').at({textAnchor: 'end', x: txtX})
5856      lines.forEach((t, i) => txt.append(i === 0 ? 'tspan.anno-key' : 'tspan.anno-sub')
5857        .text(t).at({x: txtX, y: ty0 + i*14, dy: '.35em'}))
5858    }
5859    ;[{y0: rTop, y1: jTop, ph: d.phases[2]}, {y0: jBot, y1: rBot, ph: d.phases[0]}]
5860      .forEach(a => {
5861        brace(bxBrace, a.y0 + tip, a.y1 - tip, bwBrace)
5862        annoText((a.y0 + a.y1)/2, a.ph.label, a.ph.desc)
5863      })
5864    var wym = (jTop + jBot) / 2, ibx = sliverL - bwBrace - tip
5865    brace(ibx, jTop + tip, jBot - tip, bwBrace)
5866    svg.append('line').at({x1: ibx - bwBrace, y1: wym, x2: txtX + 4, y2: wym,
5867      stroke: C.arrow, strokeWidth: 1, strokeDasharray: '3 3'})
5868    annoText(wym, d.phases[1].label, d.phases[1].desc)
5869
5870    // ℓ₁…ℓ₆ layer chips on the right (layer-diagram extra)
5871    var lx = bx1 + 16, lw = 48, lh = 22, lxc = lx + lw/2
5872    d3.range(NL).forEach(i => {
5873      var ly = rBot - (i + 0.5)*seg, ws = i+1 >= WS[0] && i+1 <= WS[1]
5874      svg.append('path').at({d: `M${bx1},${ly+seg*0.28} C${bx1+12},${ly+seg*0.28} ${lxc},${ly+lh/2+4} ${lxc},${ly+lh/2}`,
5875        fill: 'none', stroke: C.arrow, strokeWidth: 0.9})
5876      svg.append('path').at({d: `M${lxc},${ly-lh/2} C${lxc},${ly-lh/2-4} ${bx1+12},${ly-seg*0.28} ${bx1+1},${ly-seg*0.28}`,
5877        fill: 'none', stroke: C.arrow, strokeWidth: 0.9, markerEnd: 'url(#ld-arrow)'})
5878      rrect(lx, ly - lh/2, lw, lh, 4, {fill: C.blockBg, stroke: C.blockEdge, strokeWidth: 1})
5879      var tb = svg.append('text.block').translate([lxc, ly])
5880        .at({textAnchor: 'middle', dy: '.35em', fill: ws ? C.text : C.textFade})
5881      tb.append('tspan').text('ℓ')
5882      tb.append('tspan').text(i+1).at({dy: '0.25em', fontSize: '75%'})  // lint-ok inline-font-js: subscript
5883    })
5884  }
5885
5886  // ───── right: CKA heatmap with workspace band + phase brackets ─────
5887  function drawHeatmap(host){
5888    var n = d.n_layers
5889    var margin = narrow ? {left: 40, right: 90, top: 50, bottom: 100}
5890                        : {left: 44, right: 184, top: 28, bottom: 40}
5891    var size = narrow ? Math.min(400, availW) - margin.left - margin.right : 320
5892    var cell = size/n
5893    var c = d3.conventions({sel: host, width: size, height: size, margin, layers: 'cs'})
5894    var ctx = c.layers[0], svg = c.layers[1]
5895
5896    var color = d3.scaleSequential(d3.interpolateViridis).domain([0, 1])
5897    for (var i = 0; i < n; i++) for (var j = 0; j < n; j++){
5898      ctx.fillStyle = color(d.sim[i][j])
5899      ctx.fillRect(j*cell, size - (i+1)*cell, cell+0.5, cell+0.5)
5900    }
5901
5902    c.x.domain([-0.5, n-0.5]); c.y.domain([-0.5, n-0.5])
5903    c.xAxis.tickValues(util.layerTicks(n)).tickFormat(util.layerLabel).tickSizeOuter(0).tickSizeInner(3)
5904    c.yAxis.tickValues(util.layerTicks(n)).tickFormat(util.layerLabel).tickSizeOuter(0).tickSizeInner(3)
5905    c.drawAxis()
5906    util.addAxisLabel(c, 'Layer (reindexed) →', 'Layer (reindexed) →')
5907
5908    var title = `Centered kernel alignment of ${d.public_name || 'Sonnet 4.5'} J-lens`
5909    if (narrow){
5910      svg.append('text.fig-title').translate([-margin.left + 4, -32])
5911        .appendMany('tspan', wrap(title, 30)).text(s => s)
5912        .at({x: 0, dy: (s, j) => j ? '1.2em' : 0})
5913    } else {
5914      svg.append('text.fig-title').text(title).translate([size/2, -12]).at({textAnchor: 'middle'})
5915    }
5916
5917    var bx = size + 12, gap = 2
5918    d.phases.forEach(ph => {
5919      var by0 = c.y(ph.lo-0.5) - gap, by1 = c.y(ph.hi+0.5) + gap
5920      svg.append('path.bracket').at({d: `M${bx-5},${by0} H${bx} V${by1} H${bx-5}`,
5921        stroke: C.annot, strokeWidth: 0.9, fill: 'none'})
5922      var words = ph.label.split(' ')
5923      var t = svg.append('text.phase').translate([bx+7, (by0+by1)/2]).at({textAnchor: 'start'})
5924      words.forEach((w, k) =>
5925        t.append('tspan').text(w).at({x: 0, dy: k ? '1.1em' : words.length>1 ? '-.15em' : '.35em'}))
5926    })
5927
5928    var m = 60
5929    if (narrow){
5930      var cb = svg.append('g.cbar').translate([0, size + 56])
5931      var cw = size, ch = 10
5932      cb.appendMany('rect', d3.range(m)).at({x: i => i*cw/m, width: cw/m+0.5, height: ch, fill: i => color(i/(m-1))})
5933      cb.appendMany('text', [0, 0.5, 1.0]).text(v => v.toFixed(1))
5934        .at({x: v => cw*v, y: ch + 14, textAnchor: v => v ? v == 1 ? 'end' : 'middle' : 'start'})
5935      cb.append('text.axis-label').text('CKA similarity')
5936        .at({x: cw/2, y: ch + 32, textAnchor: 'middle'})
5937    } else {
5938      var cb = svg.append('g.cbar').translate([size + 106, size*0.125])
5939      var ch = size*0.75, cw = 12
5940      cb.appendMany('rect', d3.range(m)).at({y: i => ch - (i+1)*ch/m, width: cw, height: ch/m+0.5, fill: i => color(i/(m-1))})
5941      cb.appendMany('text', [0, 0.2, 0.4, 0.6, 0.8, 1.0]).text(v => v.toFixed(1))
5942        .at({x: cw+5, y: v => ch*(1-v), dy: '.35em'})
5943      cb.append('text.axis-label').text('CKA similarity')
5944        .at({transform: `translate(${cw+36},${ch/2}) rotate(90)`, textAnchor: 'middle', dy: '-.2em'})
5945    }
5946  }
5947}
5948
5949window.init?.()
5950
5951// ─── public/line-panels/init-line-panels.js ───
5952// Generic per-layer line-panel row. Reads {n_layers, band, band_label,
5953// panels:[{title, ylabel, legend_title, series:[{label,x,y}], annotations}]}.
5954// One svg cell per panel; viridis-ordinal series; grey workspace band with
5955// label; optional arrow annotations.
5956
5957window.initLinePanels = async function({datapath, spec = 'main', state}){
5958  var sel = d3.select(`.line-panels[data-spec="${spec}"]`).html('')
5959  var d = await util.getFile(datapath + spec + '.json')
5960  var n = d.n_layers, [blo, bhi] = d.band
5961  var q = new URLSearchParams(location.search)
5962  var edit = state?.edit ?? (q.get('tune') === '1' || q.has('edit'))
5963
5964  var isBelow = d.legend_pos === 'below'
5965  // 2-col grid (main 2×2): wider per-panel, slightly shorter so total height
5966  // stays close to the old 1×3 row.
5967  var w = d.cols ? 320 : 300, h = d.cols ? 180 : 200
5968  // 'below' (appendix) puts series labels inside the plot area, so no right
5969  // margin is needed; non-below (main) needs room for end-labels at c.width+lx.
5970  var margin = {top: 22, right: isBelow ? 10 : 52, bottom: 32, left: 50}
5971  // Narrow screens: collapse the 2×2 grid to one full-width column so each
5972  // panel stays readable, instead of letting fitMount shrink the whole grid.
5973  var availW = sel.node().getBoundingClientRect().width
5974  var mobile = d.cols && availW > 0 && availW < 700
5975  if (mobile) w = Math.min(420, availW) - margin.left - margin.right
5976  var wsLabelPanel = 1
5977  var annItems = []
5978  var row = sel.append('div.panel-row')
5979  if (d.cols) row.st({display: 'grid', gridTemplateColumns: `repeat(${mobile ? 1 : d.cols}, max-content)`})
5980
5981  d.panels.forEach((p, pi) => {
5982    var cell = row.append('div.lp-cell')
5983    var c = d3.conventions({sel: cell, width: w, height: h, margin})
5984    var ys = p.series.flatMap(s => s.y)
5985    var ylo = d3.min(ys), yhi = d3.max(ys)
5986    var isPct = p.pct ?? (yhi <= 1.01 && ylo >= 0 && /accuracy|fraction|rate/i.test(p.ylabel))
5987    c.x.domain([0, n - 1])
5988    // proportion panels can't run past [0,1] — clamp and skip nice() so the
5989    // pad doesn't push the axis to 110%
5990    if (isPct) c.y.domain([Math.max(0, ylo), Math.min(1, yhi + (yhi - ylo) * 0.10)])
5991    else c.y.domain([ylo, yhi + (yhi - ylo) * 0.10]).nice()
5992    c.xAxis.tickValues(util.layerTicks(n)).tickFormat(util.layerLabel)
5993    c.yAxis.ticks(5)
5994    if (isPct) c.yAxis.tickFormat(d3.format('.0%'))
5995    c.drawAxis()
5996    util.ggPlot(c)
5997    util.addAxisLabel(c, 'Layer (reindexed) →', p.ylabel, p.title, 0, -8)
5998
5999    util.addWsBand(c, blo, bhi, {label: pi === wsLabelPanel && d.band_label})
6000
6001    // series colors. Categorical = lens types → util.lensColors (sibling-figure
6002    // convention; orange is never a series color). Viridis ordinal for top-k /
6003    // percentile ramps.
6004    var nser = p.series.length
6005    var catPal = [util.tol.blue, util.tol.teal, util.tol.magenta, util.tol.cyan, util.gray[500]]
6006    var color = p.colormap === 'categorical'
6007      ? i => util.lensColors[p.series[i].label] || catPal[i % catPal.length]
6008      : i => d3.interpolateViridis(nser > 1 ? i / (nser - 1) : 0.5)
6009    var labelColor = p.colormap === 'categorical' ? color
6010      : i => i === nser - 1 ? d3.color(color(i)).darker(0.6) : color(i)
6011    var line = d3.line().x((v, i) => c.x(p.series[0].x[i])).y(v => c.y(v))
6012    c.svg.appendMany('path.lp-line.line-series', p.series)
6013      .at({d: s => line(s.y), stroke: (s, i) => color(i), 'data-series': s => pi + '-' + s.label})
6014    c.svg.appendMany('g.lp-dots', p.series)
6015      .at({fill: (s, i) => color(i)})
6016      .each(function(s){
6017        d3.select(this).appendMany('circle', s.x)
6018          .at({r: 1.8, cx: (x, i) => c.x(x), cy: (x, i) => c.y(s.y[i])})
6019      })
6020
6021    // direct labels for both legend modes; for 'below' (lens types), label only
6022    // one panel since the series are the same across panels — pick the panel
6023    // and per-series anchor where the lines are best separated.
6024    if (d.legend_pos === 'below') {
6025      var lensName = {jacobian: 'J-lens', logit: 'Logit lens', tuned: 'Tuned lens'}
6026      var lensLabel = {
6027        panel: 0,
6028        jacobian: {at:  4, dy:   2, anchor: 'start', dx: 178},
6029        logit:    {at: 14, dy:  18, anchor: 'end',   dx:  41},
6030        tuned:    {at: 14, dy:  13, anchor: 'start', dx: -69},
6031      }
6032      if (pi === lensLabel.panel) {
6033        p.series.forEach((s, i) => {
6034          var L = lensLabel[s.label]; if (!L) return
6035          var ai = s.x.indexOf(L.at)
6036          util.directLabel(c.svg, {
6037            x: c.x(s.x[ai]), y: c.y(s.y[ai]), dx: L.dx, dy: L.dy,
6038            anchor: L.anchor, text: lensName[s.label] || s.label, color: labelColor(i), key: s.label,
6039          })
6040        })
6041      }
6042      // legacy strip retained for now, hidden via CSS
6043      var lg = cell.append('div.lp-legend').st({display: 'none'})
6044      lg.append('span.lp-legend-title').text(p.legend_title)
6045      lg.appendMany('span.lp-legend-item', p.series)
6046        .at({'data-series': s => pi + '-' + s.label})
6047        .html((s, i) =>
6048          `<span class='legend-swatch' style='background:${color(i)}'></span>${s.label}`)
6049    } else {
6050      var nx = p.series[0].x.length, iMid = Math.floor(nx * 0.6)
6051      // Top-anchored stack: title flush with top of plot area, labels packed
6052      // immediately below in their iMid y-order (so visual order matches lines).
6053      var words = p.legend_title.split(' '), lh = 10
6054      var titleH = words.length * lh
6055      var ttx = c.width + 34 + (p.legend_title_dx || 0)
6056      var tt = c.svg.append('text')
6057        .at({x: ttx, y: lh - 2, textAnchor: 'end'})
6058        .st({fill: 'var(--text-light)', fontSize: 'var(--fs-label)'})
6059      if (words.length > 1) words.forEach((wd, i) =>
6060        tt.append('tspan').text(wd).at({x: ttx, dy: i ? lh : 0}))
6061      else tt.text(p.legend_title)
6062      var lx = c.width + 34
6063      var specs = p.series.map((s, i) => ({
6064        text: s.label, color: labelColor(i), halo: '#fff', key: pi + '-' + s.label,
6065        x: lx, y: c.y(s.y[iMid]), dy: 4, anchor: 'end',
6066      }))
6067      specs.sort((a, b) => a.y - b.y)
6068        .forEach((sp, i) => sp.y = titleH + 10 + i * 14)
6069      specs.forEach(sp => util.directLabel(c.svg, sp))
6070    }
6071
6072    // arrowhead marker (per-svg so url() resolves)
6073    c.svg.append('defs').append('marker')
6074      .at({id: `lp-arrow-${spec}`, viewBox: '0 0 6 6', refX: 5, refY: 3,
6075           markerWidth: 5, markerHeight: 5, orient: 'auto'})
6076      .append('path').at({d: 'M0,0 L6,3 L0,6', fill: util.gray[500]})
6077
6078    // annotations: tip at (x,y), text at (tx,ty); both in (layer, y-frac).
6079    // If tx/ty absent, derive from `dir` once.
6080    var lh = 9, L = 22
6081    var toPx = a => ({
6082      ax: c.x(a.x), ay: (1 - a.y) * c.height,
6083      tx: c.x(a.tx), ty: (1 - a.ty) * c.height,
6084    })
6085    var fromPx = (a, k, px, py) => {
6086      a[k === 'tip' ? 'x' : 'tx'] = +c.x.invert(px).toFixed(2)
6087      a[k === 'tip' ? 'y' : 'ty'] = +(1 - py / c.height).toFixed(3)
6088    }
6089    var dump = () => console.log(`[line-panels:${spec}] ${p.title} annotations:`,
6090      JSON.stringify(p.annotations, null, 2))
6091    var dumpD = util.debounce(dump, 300)
6092
6093    ;
6093(p.annotations || []).forEach((a, ai) => {
6094      Object.assign(a, state?.anno?.[`${pi}.${ai}`])
6095      a.y ??= 0.7
6096      if (a.tx == null) {
6097        var dir = a.dir || (a.x > n / 2 ? 'right' : 'left')
6098        if (dir === 'right')      { a.tx = a.x - L / w * (n - 1); a.ty = a.y }
6099        else if (dir === 'left')  { a.tx = a.x + L / w * (n - 1); a.ty = a.y }
6100        else if (dir === 'down')  { a.tx = a.x + 0.5;             a.ty = a.y }
6101        else                      { a.tx = a.x;                   a.ty = a.y - 0.08 }
6102      }
6103      var g = c.svg.append('g.lp-annotation')
6104      var arrow = g.append('path.lp-ann-arrow')
6105        .at({'marker-end': `url(#lp-arrow-${spec})`})
6106      var txt = g.append('text.lp-ann')
6107      a.lines.forEach((ln, i) =>
6108        txt.append('tspan').at({dy: i ? lh : 0}).text(ln))
6109      var tip = g.append('circle.lp-ann-handle')
6110      var tail = g.append('circle.lp-ann-handle')
6111
6112      function render() {
6113        var {ax, ay, tx, ty} = toPx(a)
6114        var anchor = a.anchor
6115          ?? (Math.abs(tx - ax) < 8 ? 'middle' : (tx > ax ? 'start' : 'end'))
6116        var nlines = a.lines.length
6117        var ly = ty < ay
6118          ? ty - (nlines - 1) * lh / 2
6119          : ty + lh - (anchor === 'middle' ? 0 : (nlines - 1) * lh / 2)
6120        txt.at({'text-anchor': anchor, y: ly})
6121        txt.selectAll('tspan').at({x: tx + (anchor === 'start' ? 3 : anchor === 'end' ? -3 : 0)})
6122        // Arrow tail leaves the text-block edge nearest the tip, not (tx,ty)
6123        // itself — so it reads as coming from under/over the text rather than
6124        // through its middle.
6125        var bb = txt.node().getBBox()
6126        var sy = ay > ty ? bb.y + bb.height + 1 : bb.y - 1
6127        var sx = anchor === 'middle'
6128          ? Math.max(bb.x, Math.min(ax, bb.x + bb.width))
6129          : tx
6130        var gap = 3
6131        var dxn = ax - sx, dyn = ay - sy, dn = Math.hypot(dxn, dyn) || 1
6132        arrow.at({d: `M${sx},${sy} L${ax - dxn / dn * gap},${ay - dyn / dn * gap}`})
6133        tip.at({cx: ax, cy: ay, r: 4})
6134        tail.at({cx: tx, cy: ty, r: 4})
6135      }
6136      render()
6137      g.datum({spec, pi, ai, a, render})
6138
6139      ;[['tip', tip], ['tail', tail]].forEach(([k, hnd]) => {
6140        hnd.classed('editable', edit)
6141        annItems.push({sel: hnd, key: `ann_${pi}_${ai}_${k}`,
6142          get: () => { var px = toPx(a)
6143            return k == 'tip' ? {x: px.ax, y: px.ay} : {x: px.tx, y: px.ty} },
6144          set: pp => { fromPx(a, k, pp.x, pp.y); render(); dumpD()
6145            state?.onAnnoChange?.(spec, pi, ai, a) }})
6146      })
6147    })
6148  })
6149  util.dragTune(annItems, 'line-panels-' + spec)
6150  util.bindSeriesHover(sel)
6151  util.dragTuneLabels(sel, 'line-panels-' + spec)
6152}
6153
6154window.init?.()
6155
6156// ─── public/capacity-fve-occupancy/init-capacity-fve-occupancy.js ───
6157// [[FIGURE:capacity-fve-occupancy]] — 1×2: J-lens FVE vs k (log-log);
6158// occupancy vs layer (percentile).
6159
6160window.initCapacityFveOccupancy = async function({datapath}){
6161  var sel = d3.select('.capacity-fve-occupancy').html('')
6162  var d = await util.getFile(datapath + 'data.json')
6163  var n = d.n_layers, [blo, bhi] = d.band
6164
6165  var w = 360, h = 240
6166  var row = sel.append('div.panel-row')
6167
6168  // ---- (a) occupancy vs layer ----
6169  ;(function(){
6170    var xs = d.occ.x, ss = d.occ.series
6171    var cell = row.append('div.cf-cell')
6172    var c = d3.conventions({sel: cell, width: w, height: h,
6173      margin: {top: 26, right: 36, bottom: 36, left: 50}})
6174    c.x.domain([0, n - 1])
6175    var ys = ss.flatMap(s => s.y)
6176    c.y.domain([0, d3.max(ys) * 1.10]).nice()
6177    c.xAxis.tickValues(util.layerTicks(n)).tickFormat(util.layerLabel)
6178    c.yAxis.ticks(5)
6179    c.drawAxis()
6180    util.ggPlot(c)
6181    util.addAxisLabel(c, 'Layer (reindexed) →', 'Occupancy →', '(a) J-space occupancy')
6182
6183    util.addWsBand(c, blo, bhi)
6184
6185    var nser = ss.length
6186    var color = i => d3.interpolateViridis(nser > 1 ? i / (nser - 1) : 0.5)
6187    var line = d3.line().x((v, i) => c.x(xs[i])).y(v => c.y(v))
6188    c.svg.appendMany('path.cf-line.line-series', ss)
6189      .at({d: s => line(s.y), stroke: (s, i) => color(i), 'data-series': s => s.label})
6190
6191    // direct labels just inside the right edge, spread so adjacent percentiles don't overlap
6192    var iEnd = xs.length - 1
6193    var specs = ss.map((s, i) => ({
6194      text: 'p' + s.label, color: color(i), key: s.label,
6195      x: c.width - 4, y: c.y(s.y[iEnd]), anchor: 'end', dy: -4,
6196    }))
6197    util.spreadLabels(specs, 12).forEach(sp => util.directLabel(c.svg, sp))
6198    util.bindSeriesHover(cell)
6199  })()
6200
6201  // ---- (b) FVE excess over random at crossover, per layer ----
6202  ;(function(){
6203    var ss = d.fve.series
6204    var cell = row.append('div.cf-cell')
6205    var c = d3.conventions({sel: cell, width: w, height: h,
6206      margin: {top: 26, right: 12, bottom: 36, left: 50}})
6207    var x = d3.scaleBand().domain(ss.map(s => s.label)).range([0, c.width])
6208      .paddingInner(0.35).paddingOuter(0.2)
6209    c.y.domain([0, d3.max(ss, s => s.y) * 1.15]).nice()
6210    c.yAxis.scale(c.y).ticks(5, '~g')
6211    c.drawAxis()
6212    c.svg.select('.x').remove()
6213    util.ggPlot(c)
6214    util.addAxisLabel(c, 'Layer (reindexed) →', 'FVE − FVE[random] →',
6215      '(b) J-lens FVE excess over random', 0, -8)
6216
6217    c.svg.appendMany('rect', ss)
6218      .at({x: s => x(s.label), width: x.bandwidth(),
6219           y: s => c.y(s.y), height: s => c.height - c.y(s.y),
6220           fill: util.gray[400]})
6221    c.svg.appendMany('text.cf-barlab', ss)
6222      .text(s => s.label)
6223      .at({x: s => x(s.label) + x.bandwidth()/2, y: c.height + 14,
6224           textAnchor: 'middle'})
6225  })()
6226}
6227
6228window.init?.()
6229
6230// ─── public/broadcast-ablation/init-broadcast-ablation.js ───
6231// [[FIGURE:broadcast-ablation]] — 1×3: ablating top-1% broadcast heads vs
6232// matched random across (a) recall@25 (b) introspection P@1 (c) felt_exp.
6233
6234window.initBroadcastAblation = async function({datapath}){
6235  var sel = d3.select('.broadcast-ablation').html('')
6236  var d = await util.getFile(datapath + 'data.json')
6237  var [blo, bhi] = d.band
6238
6239  var C_TOP = util.tol.orange
6240  var C_RAND = util.gray[500], C_CLEAN = util.gray[700]
6241  var w = 300, h = 230
6242  sel.append('div.fig-title').text('Ablating the J-lens broadcast heads')
6243  var row = sel.append('div.panel-row')
6244  // halo over the workspace band = manilla at .45 over the ggPlot bg
6245  var bandHalo = d3.interpolateRgb('#EAECED', util.brand.manilla)(0.45)
6246
6247  function dlabel(c, text, col, x, y, anchor, halo){
6248    c.svg.append('text.ba-dlabel').text(text)
6249      .at({x: x, y: y, dy: '.32em', textAnchor: anchor || 'start'})
6250      .st({fill: col, paintOrder: 'stroke', stroke: halo || '#EAECED',
6251           strokeWidth: 2.5, strokeLinejoin: 'round'})
6252  }
6253
6254  // ---- (a) recall@25 vs layer ----
6255  ;(function(){
6256    var cell = row.append('div.ba-cell')
6257    var c = d3.conventions({sel: cell, width: w, height: h,
6258      margin: {top: 26, right: 10, bottom: 36, left: 50}})
6259    var xs = d.recall.x
6260    c.x.domain(d3.extent(xs))
6261    c.y.domain([0.62, 1.0])
6262    c.xAxis.tickValues(util.layerTicks(d.n_layers).filter(t => t >= xs[0])).tickFormat(util.layerLabel)
6263    c.yAxis.ticks(4)
6264    c.drawAxis()
6265    util.ggPlot(c)
6266    util.addAxisLabel(c, 'Layer (reindexed) →', 'recall@25 →', '(a) J-lens overlap')
6267    util.addWsBand(c, blo, bhi)
6268
6269    var line = d3.line().x((v,i) => c.x(xs[i])).y(v => c.y(v))
6270    d.recall.rand_seeds.forEach(s =>
6271      c.svg.append('path.ba-seed').at({d: line(s), stroke: C_RAND}))
6272    c.svg.append('path.ba-line').at({d: line(d.recall.rand_mean), stroke: C_RAND})
6273    c.svg.append('path.ba-line').at({d: line(d.recall.top), stroke: C_TOP})
6274
6275    // labels centered over the chart (~layer 18), haloed to blend with the band
6276    var i18 = xs.reduce((best, v, i) => Math.abs(v - 18) < Math.abs(xs[best] - 18) ? i : best, 0)
6277    dlabel(c, 'Random', C_RAND, c.x(18), c.y(d.recall.rand_mean[i18]) - 14, 'middle', bandHalo)
6278    dlabel(c, 'Broadcast', C_TOP, c.x(18), c.y(d.recall.top[i18]) + 18, 'middle', bandHalo)
6279  })()
6280
6281  // ---- (b) introspection P@1 vs strength ----
6282  ;(function(){
6283    var cell = row.append('div.ba-cell')
6284    var c = d3.conventions({sel: cell, width: w, height: h,
6285      margin: {top: 26, right: 10, bottom: 36, left: 50}})
6286    var xs = d.vi.x.filter(s => s <= 0.02)
6287    var n = xs.length
6288    c.x.domain([0, 0.02])
6289    c.y.domain([0, 0.88])
6290    c.xAxis.tickValues([0, 0.01, 0.02]).tickFormat(v => v.toFixed(2))
6291    c.yAxis.ticks(4)
6292    c.drawAxis()
6293    util.ggPlot(c)
6294    util.addAxisLabel(c, 'Steering strength →', 'P(reported) →',
6295      '(b) Introspective sensitivity')
6296
6297    var line = d3.line().x((v,i) => c.x(xs[i])).y(v => c.y(v))
6298    var trim = a => a.slice(0, n)
6299    d.vi.rand_seeds.forEach(s =>
6300      c.svg.append('path.ba-seed').at({d: line(trim(s)), stroke: C_RAND}))
6301    c.svg.append('path.ba-line').at({d: line(trim(d.vi.rand_mean)), stroke: C_RAND})
6302    c.svg.append('path.ba-line').at({d: line(trim(d.vi.clean)), stroke: C_CLEAN})
6303    c.svg.append('path.ba-line').at({d: line(trim(d.vi.top)), stroke: C_TOP})
6304
6305    var i = n - 1
6306    dlabel(c, 'Random', C_RAND,
6307      c.x(xs[i]) - 4, c.y(d.vi.rand_mean[i]) - 8, 'end')
6308    dlabel(c, 'Clean', C_CLEAN,
6309      c.x(xs[i]) - 4, c.y(d.vi.clean[i]) + 32, 'end')
6310    dlabel(c, 'Broadcast', C_TOP,
6311      c.x(xs[i]) - 4, c.y(d.vi.top[i]) - 8, 'end')
6312  })()
6313
6314  // ---- (c) felt_exp Δ ----
6315  ;(function(){
6316    var cell = row.append('div.ba-cell')
6317    var bars = [['Broadcast', d.fe.top, d.fe.top_se, C_TOP],
6318                ['Random', d.fe.rand, d.fe.rand_se, C_RAND]]
6319    var c = d3.conventions({sel: cell, width: w * 0.6, height: h,
6320      margin: {top: 26, right: 10, bottom: 36, left: 50}})
6321    var xb = d3.scaleBand().domain(bars.map(b => b[0])).range([0, c.width])
6322      .paddingInner(0.35).paddingOuter(0.2)
6323    c.y.domain([-18, 4])
6324    c.xAxis.tickValues([])
6325    c.yAxis.ticks(5)
6326    c.drawAxis()
6327    util.ggPlot(c)
6328    util.addAxisLabel(c, '', 'Δ experiential-language score (pp) →',
6329      '(c) Experiential self-report')
6330    c.svg.append('line').at({x1: 0, x2: c.width, y1: c.y(0), y2: c.y(0),
6331      stroke: util.gray[600], 'stroke-width': 1})
6332
6333    c.svg.appendMany('rect.ba-bar', bars)
6334      .at({x: b => xb(b[0]), width: xb.bandwidth(),
6335           y: b => c.y(Math.max(0, b[1])),
6336           height: b => Math.abs(c.y(b[1]) - c.y(0)),
6337           fill: b => b[3]})
6338    var cx = b => xb(b[0]) + xb.bandwidth()/2
6339    c.svg.appendMany('line.ba-err', bars)
6340      .at({x1: cx, x2: cx, y1: b => c.y(b[1]-b[2]), y2: b => c.y(b[1]+b[2]),
6341           stroke: util.gray[700], 'stroke-width': 1.2})
6342    c.svg.appendMany('line.ba-err', bars)
6343      .at({x1: b => cx(b)-4, x2: b => cx(b)+4,
6344           y1: b => c.y(b[1]-b[2]), y2: b => c.y(b[1]-b[2]),
6345           stroke: util.gray[700], 'stroke-width': 1.2})
6346    c.svg.appendMany('line.ba-err', bars)
6347      .at({x1: b => cx(b)-4, x2: b => cx(b)+4,
6348           y1: b => c.y(b[1]+b[2]), y2: b => c.y(b[1]+b[2]),
6349           stroke: util.gray[700], 'stroke-width': 1.2})
6350    c.svg.appendMany('text.ba-bar-label', bars)
6351      .at({x: cx, y: b => c.y(b[1] + (b[1] > 0 ? b[2] : -b[2])) + (b[1] > 0 ? -6 : 14)})
6352      .text(b => d3.format('+.1f')(b[1]))
6353    c.svg.appendMany('text.ba-tick-label', bars)
6354      .at({x: cx, y: c.height + 16})
6355      .text(b => b[0])
6356  })()
6357}
6358
6359window.init?.()
6360
6361// ─── public/lens-callout/init-lens-callout-main.js ───
6362// Main-text lens-callout figures (blackmail, evil_anthropic).
6363// Transcript is a baked HTML string; only the callout chip rows come from
6364// data, so layer labels track util.layerLabel and chip text is the real
6365// lens output. No auto-layout — every box sits at (x, anchorTop + dy).
6366//
6367// fig_{id}.json:
6368//   { slug, txtW, txt,                       // txt: HTML string; <b data-co=i> marks anchors
6369//     callouts: [{ctx, lo, hi, k, x, dy}],
6370//     pinned: [str, ...] }
6371// {slug}.parquet: long {layer, ctx, rank, str, prob} for callout cells only.
6372
6373window.initLensCalloutMain = async function(opts){
6374  var sel = d3.select(opts.sel).html('').classed('lens-callout lens-callout-main', true)
6375  var base = opts.datapath.replace(/\/?$/, '/')
6376  var fc = await util.getFile(base + 'fig_' + opts.fig + '.json')
6377  var rows = await util.getFile(base + fc.slug + '.parquet')
6378  var by = d3.group(rows, r => r.ctx, r => r.layer)
6379  var cell = (ctx, L) => by.get(ctx)?.get(L) || []
6380
6381  // util.series order is blue/green/brown/purple/orange/red — swap red into
6382  // slot 2 so the third group (evidence; transparency) reads red, not brown.
6383  var PALETTE = ['#1f77b4', '#2ca02c', '#d62728', '#9467bd', '#ee7733', '#8c564b']
6384  var pinned = new Map(fc.pinned.flatMap((grp, i) =>
6385    grp.map(s => [s, PALETTE[i % PALETTE.length]])))
6386
6387  var tune = util.params.get('tune') === '1'
6388  var stage = sel.append('div.stage').st({width: fc.figW})
6389  var svg = stage.append('svg.lines')
6390  stage.append('div.txt').st({width: fc.txtW}).html(fc.txt)
6391
6392  var anchor = i => stage.select(`b[data-co='${i}']`).node()
6393
6394  function draw(){
6395    var sb = stage.node().getBoundingClientRect()
6396    stage.selectAll('.callout').remove()
6397    svg.html('')
6398    fc.callouts.forEach((co, i) => {
6399      var a = anchor(i)
6400      var top = (a ? a.getBoundingClientRect().top - sb.top : 0) + co.dy
6401      var box = stage.append('div.callout').st({left: co.x, top, maxWidth: fc.figW - co.x})
6402      box.appendMany('div.co-row', d3.range(co.hi, co.lo - 1, -1)).each(function(L){
6403        var row = d3.select(this)
6404        row.append('span.row-label').text(util.layerLabel(L))
6405        util.appendLensTopk(row.append('span.lens-topk'), cell(co.ctx, L), co.k)
6406      })
6407      if (tune) box.call(d3.drag()
6408        .on('drag', ev => { co.x += ev.dx; co.dy += ev.dy; draw() })
6409        .on('end', () => util.params.set('co',
6410          JSON.stringify(fc.callouts.map(c => [c.x|0, c.dy|0])))))
6411      if (a){
6412        var ab = a.getBoundingClientRect(), bb = box.node().getBoundingClientRect()
6413        var toRight = bb.left >= ab.right
6414        svg.append('line').at({
6415          x1: (toRight ? ab.right : ab.left) - sb.left, y1: ab.top + ab.height/2 - sb.top,
6416          x2: (toRight ? bb.left : bb.right) - sb.left, y2: bb.top + bb.height/2 - sb.top,
6417        })
6418      }
6419    })
6420    sel.selectAll('.lenstk').each(function(){
6421      var it = d3.select(this).datum()
6422      d3.select(this).at({style: util.pinnedStyle(it && pinned.get(it.str))})
6423    })
6424    var h = stage.select('.txt').node().offsetHeight
6425    stage.selectAll('.callout').each(function(){ h = Math.max(h, this.offsetTop + this.offsetHeight + 10) })
6426    stage.st({minHeight: h}); svg.at({width: stage.node().offsetWidth, height: h})
6427  }
6428
6429  draw()
6430  if (tune) sel.classed('editing', true)
6431}
6432
6433window.init?.()
6434
6435// ─── public/misalign-lens/init-misalign-lens.js ───
6436!function(){
6437
6438async function loadData(opts){
6439  var base = (opts?.datapath || './').replace(/\/?$/, '/')
6440  return await util.getFile(base + 'data.json')
6441}
6442
6443function drawBars(sel, d){
6444  var color = Object.fromEntries(d.models.map((m,i) => [m.key, util.series[i % util.series.length]]))
6445  var modelKeys = d.models.map(m => m.key)
6446  var panelRow = sel.append('div.panel-row')
6447  var ttSel = d3.select('.tooltip')
6448  var trunc = s => s && s.length > 200 ? s.slice(0, 200) + '…' : (s || '')
6449
6450  var allVals = d.panels.flatMap(p =>
6451    d.categories.flatMap(cat => modelKeys.flatMap(mk => p.cats[cat][mk].points)))
6452  var ymax = d3.max(allVals) || 1
6453
6454  d.panels.forEach((panel, pi) => {
6455    var c = d3.conventions({
6456      sel: panelRow.append('div.panel'),
6457      width: 250, height: 260,
6458      margin: {left: pi ? 14 : 66, top: 30, right: 8, bottom: 44},
6459    })
6460    var x0 = d3.scaleBand().domain(d.categories).range([0, c.width]).padding(0.16)
6461    var x1 = d3.scaleBand().domain(modelKeys).range([0, x0.bandwidth()]).padding(0.12)
6462    c.y.domain([0, ymax]).nice()
6463    c.yAxis.ticks(5)
6464    c.drawAxis()
6465    c.svg.select('.x').remove()
6466    if (pi) c.svg.select('.y').selectAll('text').remove()
6467    util.ggPlot?.(c)
6468
6469    c.svg.append('text.fig-title').text(panel.title.replace(/^[a-z]/, c => c.toUpperCase())).at({x: c.width/2, y: -14, textAnchor: 'middle'})
6470    if (!pi) util.addAxisLabel?.(c, '', d.ylabel)
6471
6472    d.categories.forEach(cat => {
6473      var gx = x0(cat)
6474      modelKeys.forEach(mk => {
6475        var cell = panel.cats[cat][mk]
6476        var bx = gx + x1(mk), bw = x1.bandwidth()
6477        c.svg.append('rect').at({
6478          x: bx, y: c.y(cell.mean), width: bw, height: c.height - c.y(cell.mean),
6479          fill: color[mk],
6480        })
6481        var jw = bw * 0.5
6482        var prompts = cell.prompts || []
6483        var lensTopk = cell.lens_topk || []
6484        cell.points.forEach((v, i) => {
6485          var jx = bx + bw/2 + ((i / Math.max(cell.points.length-1, 1)) - 0.5) * jw
6486          c.svg.append('circle.jpt').datum({v, mk, cat, pr: prompts[i], lt: lensTopk[i]})
6487            .at({cx: jx, cy: c.y(v), r: 1.8, fillOpacity: 0, stroke: util.gray[700], strokeWidth: 1})
6488        })
6489        if (cell.sem > 0) {
6490          util.barWhisker(c.svg, {x: bx + bw/2,
6491            lo: c.y(cell.mean - cell.sem), hi: c.y(cell.mean + cell.sem)})
6492        }
6493      })
6494      c.svg.append('text').text(cat)
6495        .at({x: gx + x0.bandwidth()/2, y: c.height + 16, textAnchor: 'middle'})
6496        .st({fontSize: 'var(--fs-small)'})
6497    })
6498
6499    var last
6500    util.nearestHover(c.svg, c.svg.selectAll('circle.jpt'), {
6501      radius: 40,
6502      onHover: (o, el, e) => {
6503        if (el !== last){
6504          last = el
6505          ttSel.classed('tooltip-hidden', false).html('')
6506          ttSel.append('div').html(`<b style="color:${color[o.mk]}">${o.mk}</b> · ${o.cat} · ${o.v}`)
6507          if (o.pr) ttSel.append('div.prompt-block').text(trunc(o.pr))
6508          if (o.lt?.tokens?.length){
6509            var toks = ttSel.append('div.toks')
6510            o.lt.tokens.forEach((t, ti) =>
6511              toks.append('span.tok').text(util.ppToken?.(t) ?? t).classed('hl', o.lt.hit_idx?.includes(ti)))
6512          }
6513        }
6514        var bb = ttSel.node().getBoundingClientRect()
6515        ttSel.st({left: d3.clamp(20, e.clientX - bb.width/2, innerWidth - bb.width - 20) + 'px',
6516                  top: (innerHeight > e.clientY + 20 + bb.height ? e.clientY + 20 : e.clientY - bb.height - 20) + 'px'})
6517      },
6518      onLeave: () => { last = null; ttSel.classed('tooltip-hidden', true) },
6519    })
6520  })
6521
6522  var leg = sel.append('div.legend')
6523  d.models.forEach(m => {
6524    var e = leg.append('span.legend-item')
6525    e.append('span.swatch').st({background: color[m.key]})
6526    e.append('span').text(m.label)
6527  })
6528}
6529
6530function drawExample(sel, d){
6531  var color = Object.fromEntries(d.models.map((m,i) => [m.key, util.series[i % util.series.length]]))
6532  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
6533  var ex = sel.append('div.card.ex-card')
6534  // mark the lens-read position (the trailing prefill "I") with the shared .tok-hit outline
6535  ex.append('div.prompt.prompt-block').html(esc(d.example.prompt)
6536    .replace(/I$/, '<span class="tok-hit">I</span>'))
6537  ex.append('div.mlabel').text(`Top-10 lens tokens at "I", ${d.example.layer_label}`)
6538  var tbl = ex.append('div.ex-table')
6539  d.example.rows.forEach(r => {
6540    var row = tbl.append('div.ex-row')
6541    row.append('div.model').text(r.model).st({color: color[r.model]})
6542    var toks = row.append('div.toks')
6543    r.tokens.forEach((t, i) => {
6544      toks.append('span.tok').text(t).classed('hl', r.hit_idx.includes(i))
6545    })
6546  })
6547}
6548
6549window.initMisalignLens = async function(opts){
6550  var sel = d3.select('.misalign-lens').html('')
6551  var d = await loadData(opts)
6552  drawBars(sel, d)
6553}
6554
6555window.initMisalignLensExample = async function(opts){
6556  var sel = d3.select('.misalign-lens-example').html('')
6557  var d = await loadData(opts)
6558  drawExample(sel, d)
6559}
6560
6561}()
6562window.init?.()
6563
6564// ─── public/roleplay-lens/init-roleplay-lens.js ───
6565!function(){
6566
6567// base → dark grey, post-trained → blue (matches post-training figures).
6568var PALETTE = [util.gray[600], util.tol.blue]
6569
6570async function loadData(opts){
6571  var base = (opts?.datapath || './').replace(/\/?$/, '/')
6572  return await util.getFile(base + 'data.json')
6573}
6574
6575var BAR_MARGIN = {left: 48, top: 8, right: 6, bottom: 56}
6576
6577// One narrow hit-rate panel: 3 groups × {base, post-trained}, per-unit dots,
6578// thin per-unit base→post lines, bold mean line. Mirrors figures_polished._panel.
6579function drawPanel(sel, d, word, color, modelKeys, {width, height, ylabel}){
6580  var groupKeys = d.groups.map(g => g.key)
6581  var groupLabel = Object.fromEntries(d.groups.map(g => [g.key, g.label]))
6582  var c = d3.conventions({sel, width, height, margin: BAR_MARGIN})
6583  var x0 = d3.scaleBand().domain(groupKeys).range([0, c.width]).padding(0.16)
6584  var x1 = d3.scaleBand().domain(modelKeys).range([0, x0.bandwidth()]).padding(0.12)
6585  c.y.domain([0, 1])
6586  c.yAxis.ticks(5).tickFormat(d3.format('.1f'))
6587  c.drawAxis()
6588  c.svg.select('.x').remove()
6589  util.ggPlot?.(c)
6590  // y-label: only the word is bold, " hit rate" regular — matches the
6591  // original $\mathbf{word}$ hit rate
6592  var yl = c.svg.append('text.axis-label')
6593    .at({transform: `translate(-32,${c.height/2}) rotate(-90)`, textAnchor: 'middle'})
6594  yl.append('tspan.bold').text(`"${ylabel}"`)
6595  yl.append('tspan').text(' hit rate →')
6596
6597  groupKeys.forEach(g => {
6598    var gp = d.panels[word][g]
6599    var gx = x0(g), bw = x1.bandwidth(), jw = bw * 0.55
6600    var cx = {}, jpos = {}
6601    modelKeys.forEach(mk => {
6602      var cell = gp[mk]
6603      var bx = gx + x1(mk)
6604      cx[mk] = bx + bw/2
6605      c.svg.append('rect').at({
6606        x: bx, y: c.y(cell.mean), width: bw, height: c.height - c.y(cell.mean),
6607        fill: color[mk],
6608      })
6609      jpos[mk] = cell.points.map((v, i) => {
6610        var jx = cx[mk] + ((i / Math.max(cell.points.length - 1, 1)) - 0.5) * jw
6611        c.svg.append('circle').at({cx: jx, cy: c.y(v), r: 1.7, fill: util.gray[700], fillOpacity: 0.32})
6612        return [jx, c.y(v)]
6613      })
6614      var tick = c.svg.append('text.mtick').at({x: cx[mk], y: c.height + 10, textAnchor: 'middle'})
6615      mk.split('-').forEach((w, li) => {
6616        tick.append('tspan').text(w).at({x: cx[mk], dy: li ? '1.05em' : 0})
6617      })
6618    })
6619    if (modelKeys.length === 2){
6620      var [a, b] = [jpos[modelKeys[0]], jpos[modelKeys[1]]]
6621      d3.range(Math.min(a.length, b.length)).forEach(i => {
6622        c.svg.append('line').at({
6623          x1: a[i][0], y1: a[i][1], x2: b[i][0], y2: b[i][1],
6624          stroke: util.gray[500], strokeWidth: 0.5, strokeOpacity: 0.25,
6625        })
6626      })
6627    }
6628    var gl = c.svg.append('text.glabel').at({x: gx + x0.bandwidth()/2, y: c.height + 36, textAnchor: 'middle'})
6629    groupLabel[g].split(' ').forEach((w, li) => {
6630      gl.append('tspan').text(w).at({x: gx + x0.bandwidth()/2, dy: li ? '1.1em' : 0})
6631    })
6632  })
6633}
6634
6635function drawTable(sel, label, color, rows){
6636  var maxLp = d3.max(rows, r => r.logprob)
6637  var wrap = sel.append('div.rtable')
6638  var head = wrap.append('div.thd')
6639  head.append('span.swatch').st({background: color})
6640  head.append('span').text(label)
6641  var tbl = wrap.append('div.bar-table')
6642  rows.forEach(r => {
6643    var row = tbl.append('div.bar-row')
6644    row.append('div.bar').st({width: (Math.exp(r.logprob - maxLp) * 100) + '%', background: util.tint(color, 0.75)})
6645    row.append('span.t').text(util.ppToken(r.token)).classed('hl', r.hilite)
6646    row.append('span.val').text(r.logprob.toFixed(2))
6647  })
6648}
6649
6650function drawPromptBox(sel, block){
6651  var box = sel.append('div.prompt.prompt-block')
6652  if (block.system_prompt) box.append('div.turn.sys').text(block.system_prompt)
6653  block.turns.forEach(t => {
6654    var h = box.append('div.turn')
6655    h.append('span.prompt-role').text('Human:')
6656    h.append('span').text(' ' + t.human)
6657    var a = box.append('div.turn')
6658    if (t.is_marker) a.append('span.tok-hit').text('Assistant')
6659    else a.append('span.prompt-role').text('Assistant:')
6660    a.append('span.reply').text((t.is_marker ? ': ' : ' ') + t.assistant + (t.truncated ? '…' : ''))
6661  })
6662}
6663
6664function drawCard(sel, d, which, color){
6665  var ex = d.example
6666  var card = sel.append('div.rp-col')
6667  drawPromptBox(card, ex[which])
6668  var tables = card.append('div.rtables')
6669  d.models.forEach(m => drawTable(tables, m.label.replace(/^./, c => c.toUpperCase()),
6670    color[m.key], ex.readouts[which][m.key]))
6671}
6672
6673// [[FIGURE:roleplay-lens]] — 2×2: top row = control / persona prompt-box +
6674// tables; bottom row = 2 bar panels (disclaimer | fictional). Bars get a full
6675// card-width each so x-tick labels fit at the normal --fs-label size.
6676window.initRoleplayLens = async function(opts){
6677  var sel = d3.select('.roleplay-lens').html('')
6678  var d = await loadData(opts)
6679  var color = Object.fromEntries(d.models.map((m, i) => [m.key, PALETTE[i % PALETTE.length]]))
6680  var modelKeys = d.models.map(m => m.key)
6681  var cards = sel.append('div.panel-row')
6682  drawCard(cards, d, 'control', color)
6683  drawCard(cards, d, 'persona', color)
6684  var bars = sel.append('div.rp-bars')
6685  ;
6685(d.example_words || d.words.slice(0, 2)).forEach(word => {
6686    drawPanel(bars.append('div.panel'), d, word, color, modelKeys,
6687      {width: 290, height: 170, ylabel: word})
6688  })
6689}
6690
6691// [[FIGURE:roleplay-lens-example]] — kept for back-compat: cards-only view.
6692window.initRoleplayLensExample = async function(opts){
6693  var sel = d3.select('.roleplay-lens-example').html('')
6694  var d = await loadData(opts)
6695  var color = Object.fromEntries(d.models.map((m, i) => [m.key, PALETTE[i % PALETTE.length]]))
6696  var row = sel.append('div.panel-row')
6697  drawCard(row, d, 'control', color)
6698  drawCard(row, d, 'persona', color)
6699}
6700
6701}()
6702window.init?.()
6703
6704// ─── public/reward-hack-readout/init-reward-hack-readout.js ───
6705window.initRewardHackReadout = async function(opts){
6706  var sel = d3.select('.reward-hack-readout').html('')
6707  var base = (opts?.datapath || './').replace(/\/?$/, '/')
6708  var d = await util.getFile(base + 'data.json')
6709  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
6710
6711  // Ordinal: baseline grey, then three darkening blues. Shared with reward-hack-quant.
6712  var blue = util.tol.blue
6713  var CHK = {'Baseline': util.gray[300], 'SDF': util.tint(blue, .55),
6714             'Phase 1': util.tint(blue, .25), 'Phase 2': blue}
6715  var color = Object.fromEntries(d.models.map((m,i) =>
6716    [m.key, CHK[m.key] ?? util.tint(blue, 1 - i/Math.max(d.models.length-1,1))]))
6717  var labelFix = {'Phase 1': 'Phase 1: Code RL', 'Phase 2': 'Phase 2: HHH RL'}
6718  var label = Object.fromEntries(d.models.map(m => [m.key, labelFix[m.key] || m.label]))
6719
6720  sel.append('div.prompt-block').html(esc(d.example.prompt)
6721    .replace(/(Human|Assistant): /g, '<span class="prompt-role">$1: </span>')
6722    .replace(/I$/, '<span class="tok-hit">I</span>'))
6723  sel.append('div.mlabel').text('Top J-lens tokens')
6724
6725  var rows = sel.append('div.tok-rows')
6726  d.example.rows.forEach(r => {
6727    var row = rows.append('div.tok-row')
6728    var lab = row.append('div.chk-label')
6729    lab.append('span.swatch').st({background: color[r.model]})
6730    lab.append('span').text(label[r.model] || r.model)
6731    var toks = row.append('div.toks')
6732    r.tokens.forEach((t, i) => {
6733      toks.append('span.tok')
6734        .text(util.ppToken?.(t) ?? t)
6735        .classed('hl', r.hit_idx.includes(i))
6736    })
6737  })
6738}
6739window.init?.()
6740
6741// ─── public/reward-hack-quant/init-reward-hack-quant.js ───
6742window.initRewardHackQuant = async function(opts){
6743  var sel = d3.select('.reward-hack-quant').html('')
6744  var base = (opts?.datapath || './').replace(/\/?$/, '/')
6745  var d = await util.getFile(base + 'data.json')
6746
6747  // Ordinal: baseline grey, then three darkening blues. Shared with reward-hack-readout.
6748  var blue = util.tol.blue
6749  var CHK = {'Baseline': util.gray[300], 'SDF': util.tint(blue, .55),
6750             'Phase 1': util.tint(blue, .25), 'Phase 2': blue}
6751  var color = Object.fromEntries(d.models.map((m,i) =>
6752    [m.key, CHK[m.key] ?? util.tint(blue, 1 - i/Math.max(d.models.length-1,1))]))
6753  var modelKeys = d.models.map(m => m.key)
6754
6755  var ttSel = d3.select('.tooltip')
6756  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
6757
6758  // n=40: combine both coding categories per checkpoint.
6759  function combined(panel, mk){
6760    var pts = d.categories.flatMap(cat => panel.cats[cat][mk].points)
6761    var prompts = d.categories.flatMap(cat => panel.cats[cat][mk].prompts || [])
6762    var lensTopk = d.categories.flatMap(cat => panel.cats[cat][mk].lens_topk || [])
6763    var cats = d.categories.flatMap(cat => (panel.cats[cat][mk].points || []).map(_ => cat))
6764    var mean = pts.length ? d3.mean(pts) : 0
6765    var sem = pts.length > 1 ? d3.deviation(pts) / Math.sqrt(pts.length) : 0
6766    return {points: pts, prompts, lensTopk, cats, mean, sem}
6767  }
6768
6769  // Baked prompts all end "Assistant: I"; box the panel's read-position token.
6770  function renderPrompt(pr, pi){
6771    var h = esc(pr).replace(/(Human|Assistant): /g, '<span class="prompt-role">$1: </span>')
6772    if (pi == 0) return h.replace(/: <\/span>I$/, '</span><span class="tok-hit">:</span>')
6773    if (pi == 1) return h.replace(/I$/, '<span class="tok-hit">I</span>')
6774    return h.replace(/I$/, '') + '<span class="tok-hit">…</span>'
6775  }
6776
6777  var allPts = d.panels.flatMap(p => modelKeys.flatMap(mk => combined(p, mk).points))
6778  var ymax = d3.max(allPts) || 1
6779
6780  var row = sel.append('div.panel-row')
6781  d.panels.forEach((panel, pi) => {
6782    var title = panel.title.replace(/^./, s => s.toUpperCase()).replace('" I"', '"I"')
6783    var c = d3.conventions({
6784      sel: row.append('div.panel'),
6785      width: 260, height: 220,
6786      margin: {left: 60, top: 26, right: 8, bottom: 38},
6787    })
6788    var x = d3.scaleBand().domain(modelKeys).range([0, c.width]).padding(0.22)
6789    c.y.domain([0, ymax]).nice()
6790    c.yAxis.ticks(5)
6791    c.drawAxis()
6792    c.svg.select('.x').remove()
6793    util.ggPlot?.(c)
6794    util.addAxisLabel?.(c, '', '', title)
6795    if (!pi){
6796      var yLab = c.svg.select('.y').append('g').translate([-34, c.height/2])
6797        .append('text.axis-label').at({textAnchor: 'middle', transform: 'rotate(-90)'})
6798      yLab.append('tspan').text('Deception-vocab in top-10').at({x: 0})
6799      yLab.append('tspan').text('(sum over WS layers) →').at({x: 0, dy: '1.1em'})
6800    }
6801
6802    modelKeys.forEach((mk, mi) => {
6803      var cell = combined(panel, mk)
6804      var bx = x(mk), bw = x.bandwidth()
6805      c.svg.append('rect').at({
6806        x: bx, y: c.y(cell.mean), width: bw, height: c.height - c.y(cell.mean),
6807        fill: color[mk],
6808      })
6809      var jw = bw * 0.5
6810      cell.points.forEach((v, i) => {
6811        var jx = bx + bw/2 + ((i / Math.max(cell.points.length-1, 1)) - 0.5) * jw
6812        c.svg.append('circle.jpt')
6813          .datum({v, mk, cat: cell.cats[i], pr: cell.prompts[i], lt: cell.lensTopk[i]})
6814          .at({cx: jx, cy: c.y(v), r: 1.8, fillOpacity: 0, stroke: util.gray[700], strokeWidth: 1})
6815      })
6816      if (cell.sem > 0){
6817        util.barWhisker(c.svg, {x: bx + bw/2,
6818          lo: c.y(cell.mean - cell.sem), hi: c.y(cell.mean + cell.sem)})
6819      }
6820      c.svg.append('text.mklabel').text(mk)
6821        .at({x: bx + bw/2, y: c.height + 14, textAnchor: 'middle'})
6822    })
6823
6824    var last
6825    util.nearestHover(c.svg, c.svg.selectAll('circle.jpt'), {
6826      radius: 40,
6827      onHover: (o, el, e) => {
6828        if (el !== last){
6829          last = el
6830          ttSel.classed('tooltip-hidden', false).html('')
6831          ttSel.append('div').html(`<b style="color:${color[o.mk]}">${o.mk}</b> · ${o.cat} · count ${o.v}`)
6832          ttSel.append('div.tt-note').text('(count = sum over workspace layers)')
6833          if (o.pr) ttSel.append('div.prompt-block').html(renderPrompt(o.pr, pi))
6834          if (o.lt?.tokens?.length){
6835            var toks = ttSel.append('div.toks')
6836            o.lt.tokens.forEach((t, ti) =>
6837              toks.append('span.tok').text(util.ppToken?.(t) ?? t).classed('hl', o.lt.hit_idx?.includes(ti)))
6838          }
6839        }
6840        var bb = ttSel.node().getBoundingClientRect()
6841        ttSel.st({left: d3.clamp(20, e.clientX - bb.width/2, innerWidth - bb.width - 20) + 'px',
6842                  top: (innerHeight > e.clientY + 20 + bb.height ? e.clientY + 20 : e.clientY - bb.height - 20) + 'px'})
6843      },
6844      onLeave: () => { last = null; ttSel.classed('tooltip-hidden', true) },
6845    })
6846  })
6847
6848}
6849window.init?.()
6850
6851// ─── public/modulation-probe/init-modulation-probe.js ───
6852!function(){
6853
6854var ORANGE = util.tol.orange, BLUE = util.tol.blue, GREY = util.gray[500], INK = util.gray[700], PURPLE = util.tol.magenta
6855var COND_COLORS = {none: GREY, imagine: 'var(--hl-yellow)', real: BLUE}
6856var MEASURES = [
6857  {key: 'jlens', label: 'J-lens activation of tokens naming the property', color: ORANGE, off: -1},
6858  {key: 'probe', label: 'property probe (non-J-space component)', color: PURPLE, off: 1},
6859]
6860
6861async function loadData(opts){
6862  var base = (opts?.datapath || './').replace(/\/?$/, '/')
6863  return await util.getFile(base + 'data.json')
6864}
6865
6866function drawExample(sel, d){
6867  var ex = d.example
6868  sel.append('div.fig-title').text('Example: imagining JavaScript code is Python')
6869
6870  var prompt = sel.append('div.prompt')
6871  prompt.append('span.role').text('Human: ')
6872  prompt.append('span.instr').text(ex.instruction)
6873  if (ex.carrier_box){
6874    prompt.append('span').text('\n\n' + ex.carrier_box.pre)
6875    prompt.append('span.tok-hit').text(ex.carrier_box.tok)
6876    prompt.append('span').text(ex.carrier_box.post)
6877  } else {
6878    prompt.append('span').text('\n\n' + ex.carrier_text)
6879  }
6880
6881  sel.append('div.mlabel').text(
6882    'Top-5 J-lens readout at the boxed token, ' + ex.layer_label + ' (log probs)')
6883  var cols = sel.append('div.lenscols')
6884  var hits = new Set((ex.hit_tokens || []).map(t => t.toLowerCase()))
6885  var half = ex.probe_half_range
6886  ex.rows.forEach(row => {
6887    var col = cols.append('div.lenscol').classed('imagine', row.cond === 'imagine')
6888    var head = col.append('div.chead')
6889    head.append('span.legend-sq').st({background: COND_COLORS[row.cond]})
6890    head.append('span').text(row.label)
6891    // The values are log-probs; the bar widths are each token's probability
6892    // relative to the column's top token.
6893    var vmax = d3.max(row.tokens, t => t[1]) ?? 0
6894    row.tokens.forEach(t => {
6895      var r = col.append('div.lensrow')
6896      r.append('div.fill').st({width: (Math.exp(Math.min(t[1] - vmax, 0))*100) + '%', background: COND_COLORS[row.cond]})
6897      r.append('span.t').classed('hit', hits.has(t[0].trim().toLowerCase())).text(t[0])
6898      r.append('span.v').text(d3.format('.2f')(t[1]))
6899    })
6900    col.append('div.ranknote').st({marginTop: 16}).text(ex.probe_label || 'property probe')
6901    var g = col.append('div.gauge')
6902    var tr = g.append('div.gtrack')
6903    var pct = d3.clamp(0, 50 + (row.probe_z / half) * 50, 100)
6904    var lbl = d3.format('+.1f')(row.probe_z) + 'σ'
6905    tr.append('div.gzero')
6906    tr.append('div.gstem').st({left: Math.min(50, pct) + '%', width: Math.abs(pct - 50) + '%'})
6907    tr.append('div.gdot').st({left: pct + '%'})
6908    tr.append('div.gval').st({left: d3.clamp(10, pct, 90) + '%'}).text(lbl)
6909  })
6910}
6911
6912function drawPanel(sel, cond, d, ymax, ymin){
6913  var cats = d.quant.categories
6914  var margin = {left: 22, top: 8, right: 4, bottom: 30}
6915  var c = d3.conventions({
6916    sel: sel.append('div.panel'),
6917    width: 360, height: 75,
6918    margin,
6919  })
6920  // viewBox + width:100% lets the panel shrink with a narrow column (mobile)
6921  // while max-width keeps the desktop render at its natural size.
6922  var totW = 360 + margin.left + margin.right, totH = 75 + margin.top + margin.bottom
6923  d3.select(c.svg.node().ownerSVGElement)
6924    .at({viewBox: '0 0 ' + totW + ' ' + totH})
6925    .st({width: '100%', maxWidth: totW + 'px', height: 'auto'})
6926  var x = d3.scaleBand().domain(cats).range([0, c.width]).padding(0.28)
6927  c.y.domain([ymin, ymax])
6928  c.x = x
6929  c.xAxis = d3.axisBottom(x).tickFormat(k => (d.category_labels[k] || k).split(' (')[0])
6930  c.yAxis.ticks(5)
6931  c.drawAxis()
6932  util.ggPlot(c)
6933  c.svg.selectAll('.x text').st({fontSize: 9})
6934    .each(function(k){
6935      var lbl = (d.category_labels[k] || k)
6936      var m = lbl.match(/^(.*?) \((.*)\)$/)
6937      if (!m) return
6938      d3.select(this).text('')
6939      d3.select(this).append('tspan').at({x: 0, dy: '0.71em'}).text(m[1])
6940      d3.select(this).append('tspan').at({x: 0, dy: '1.1em'}).text('(' + m[2] + ')')
6941    })
6942  c.svg.append('line').at({x1: 0, x2: c.width, y1: c.y(0), y2: c.y(0), stroke: INK, strokeWidth: 0.6})
6943
6944  var bw = x.bandwidth() / 2 - 2
6945  MEASURES.forEach(m => {
6946    var cell = cond.cells[m.key]
6947    if (!cell) return
6948    cats.forEach((cat, ci) => {
6949      var bx = x(cat) + x.bandwidth()/2 + (m.off < 0 ? -bw - 1 : 1)
6950      var mean = cell.means[ci]
6951      c.svg.append('rect').at({
6952        x: bx, y: Math.min(c.y(0), c.y(mean)),
6953        width: bw, height: Math.abs(c.y(mean) - c.y(0)),
6954        fill: m.color,
6955      })
6956      var pts = cell.points[ci] || []
6957      pts.forEach((v, j) => {
6958        var jit = (pts.length > 1 ? (j/(pts.length-1) - 0.5) : 0) * bw * 0.6
6959        c.svg.append('circle').at({
6960          cx: bx + bw/2 + jit, cy: c.y(v), r: 2.1,
6961          fillOpacity: 0, stroke: util.gray[700], strokeWidth: 1,
6962        })
6963      })
6964      // ±1 SEM error bar on the wrong-target-controlled differences.
6965      var sem = cell.sems?.[ci]
6966      if (sem != null){
6967        var cx = bx + bw/2
6968        c.svg.append('line').at({x1: cx, x2: cx, y1: c.y(mean - sem), y2: c.y(mean + sem), stroke: util.gray[700], strokeWidth: 1.2})
6969        ;[mean - sem, mean + sem].forEach(v => {
6970          c.svg.append('line').at({x1: cx - 2.5, x2: cx + 2.5, y1: c.y(v), y2: c.y(v), stroke: util.gray[700], strokeWidth: 1.2})
6971        })
6972      }
6973    })
6974  })
6975  return c
6976}
6977
6978window.initModulationProbe = async function(opts){
6979  var sel = d3.select('.modulation-probe').html('')
6980  var d = await loadData(opts)
6981  if (!d?.quant?.conds || !d?.example?.hit_tokens || d.example.probe_half_range == null) return
6982  var row = sel.append('div.mp-row')
6983  drawExample(row.append('div'), d)
6984
6985  var right = row.append('div')
6986  right.append('div.fig-title').text('Across four properties')
6987  // Both bar panels share one y extent so the imagine vs. real effect sizes
6988  // read directly off the bar heights.
6989  var condVals = cond => {
6990    var vals = []
6991    MEASURES.forEach(m => (cond.cells[m.key]?.points || []).forEach(p => vals.push(...p)))
6992    return vals
6993  }
6994  // cover both the baked extents and the raw points so jitter dots never leave the plot area
6995  var ymax = d3.max(d.quant.conds, c => Math.max(c.ymax ?? -Infinity, d3.max(condVals(c)) * 1.08))
6996  var ymin = d3.min(d.quant.conds, c => Math.min(c.ymin ?? Infinity, Math.min(0, d3.min(condVals(c))) - 0.4))
6997  d.quant.conds.forEach((cond, i) => {
6998    right.append('div.ptitle-sm').st({marginTop: i === 0 ? 0 : 14})
6999      .text(cond.title)
7000    drawPanel(right, cond, d, ymax, ymin)
7001  })
7002  var leg = right.append('div.legend')
7003  MEASURES.forEach(m => {
7004    var l = leg.append('div')
7005    l.append('span.legend-sq').st({background: m.color})
7006    l.append('span').text(m.label)
7007  })
7008}
7009
7010}()
7011window.init?.()
7012
7013// ─── public/probe-swap/init-probe-swap.js ───
7014!function(){
7015
7016async function loadData(opts){
7017  var base = (opts?.datapath || './').replace(/\/?$/, '/')
7018  return await util.getFile(base + 'data.json')
7019}
7020
7021// Shared J-space condition palette (util.jspaceColors); fall back to the
7022// baked hex if a key isn't in the map.
7023var condColor = o => util.jspaceColors[o.key] ?? o.color
7024
7025function rankCol(sel, r, ex){
7026  var t = sel.append('table.bars')
7027  var th = t.append('thead').append('tr').append('th')
7028  th.append('span.sw.swatch').st({background: condColor(r)})
7029  th.append('span').text(r.label)
7030  var tb = t.append('tbody')
7031  // Tokens only — the swap target's rank/log-prob annotations are carried
7032  // in the data but not rendered (matching the polished figure).
7033  r.tokens.forEach(tok => {
7034    var cls = 'tok'
7035    if (tok.toLowerCase() == ex.expected.toLowerCase()) cls = 'tok hit'
7036    else if (tok.toLowerCase() == ex.original.toLowerCase()) cls = 'tok orig'
7037    tb.append('tr').append('td').append('div.entry')
7038      .append('span').at({class: cls}).text(tok)
7039  })
7040}
7041
7042// One concept's probe decomposition as an equation broken at the "+":
7043// [probe for X] = [J-space component: atoms ...]
7044//                   + [non-J-space component]
7045// The "+" line stacks under the J-space chip on the right of the "=".
7046function eqBlock(sel, e){
7047  var b = sel.append('div.eqblock')
7048  var lhs = b.append('div.eqline.eqlhs')
7049  var c0 = lhs.append('span.eqchip')
7050  c0.append('span').text('probe for ')
7051  c0.append('b').text(e.concept)
7052  lhs.append('span.eqop').text('=')
7053  var rhs = b.append('div.eqrhs')
7054  var l1 = rhs.append('div.eqline')
7055  var c1 = l1.append('span.eqchip')
7056  c1.append('span.sw.swatch').st({background: util.jspaceColors.aligned})
7057  c1.append('span').text('J-space component')
7058  e.atoms.forEach(a => c1.append('span.atom').text(a))
7059  c1.append('span.eqell').text('…')
7060  var l2 = rhs.append('div.eqline.cont')
7061  l2.append('span.eqop').text('+')
7062  var c2 = l2.append('span.eqchip')
7063  c2.append('span.sw.swatch').st({background: util.jspaceColors.residual})
7064  c2.append('span').text('non-J-space component')
7065}
7066
7067function drawExample(sel, d){
7068  var ex = d.example
7069  sel.append('div.ptitle')
7070    .text('Example: swapping the working representation of an inferred intermediate')
7071  // Plain prompt text; only the answer slot (the trailing "is ___") is
7072  // marked.
7073  var p = sel.append('div.prompt')
7074  var txt = ex.prompt.trim()
7075  var stem = txt.endsWith(' is') ? txt.slice(0, -3) : txt
7076  p.append('span').text(stem + ' ')
7077  p.append('span.tok-hit').text('is')
7078
7079  ;(ex.decomp || []).forEach(e => eqBlock(sel, e))
7080
7081  sel.append('div.colslab').text(
7082    `model's next-token distribution after exchanging ${ex.src_concept} → ${ex.tgt_concept} along each pair of directions`)
7083  // One shared table (not 4 independent ones) so header height and row
7084  // baselines are identical across all readout columns.
7085  var t = sel.append('div.cols').append('table.bars')
7086  var hr = t.append('thead').append('tr')
7087  // Explicit two-line splits so all four headers sit at the same height; the
7088  // last label is widest and is allowed to overflow its column slightly right.
7089  var hsplit = s => {
7090    if (s == 'clean (no swap)') return ['clean', '(no swap)']
7091    var m = s.match(/^(.*\S) (component|vectors)$/)
7092    return m ? [m[1], m[2]] : [s]
7093  }
7094  ex.readouts.forEach(r => {
7095    var th = hr.append('th')
7096    var hw = th.append('div.hwrap')
7097    hw.append('span.sw.swatch').st({background: condColor(r)})
7098    hw.append('span.hlabel').appendMany('div', hsplit(r.label)).text(d => d)
7099  })
7100  var tb = t.append('tbody')
7101  var nrows = d3.max(ex.readouts, r => r.tokens.length)
7102  d3.range(nrows).forEach(i => {
7103    var tr = tb.append('tr')
7104    ex.readouts.forEach(r => {
7105      var tok = r.tokens[i]
7106      var td = tr.append('td')
7107      if (tok == null) return
7108      var cls = 'tok'
7109      if (tok.toLowerCase() == ex.expected.toLowerCase()) cls = 'tok hit'
7110      else if (tok.toLowerCase() == ex.original.toLowerCase()) cls = 'tok orig'
7111      td.append('div.entry').append('span').at({class: cls}).text(tok)
7112    })
7113  })
7114}
7115
7116function drawBars(sel, d){
7117  var w = d.bars
7118  sel.append('div.ptitle').text(`Across ${d.n_items} two-hop reasoning prompts`)
7119  var c = d3.conventions({
7120    sel: sel.append('div.panel'),
7121    // Wide enough that adjacent bars' multi-line x labels ("probe,
7122    // non-J-space component" next to "+ J-space coords clamped") don't
7123    // collide at the figure's label font size.
7124    width: 400, height: 280,
7125    // The bottom margin holds the multi-line bar labels plus the
7126    // variance-share annotations arrowed to the two component labels;
7127    // the left margin holds the two-line y label.
7128    margin: {left: 64, top: 16, right: 8, bottom: 86},
7129  })
7130  // Each series carries its own x position so the complementary-clamp
7131  // variant of each component sits adjacent to its parent, with gaps
7132  // between the groups.
7133  var xmin = d3.min(w.series, s => s.x ?? 0), xmax = d3.max(w.series, s => s.x ?? 0)
7134  c.x.domain([xmin - 0.55, xmax + 0.55])
7135  c.y.domain([0, 1])
7136  c.xAxis.tickValues(w.series.map(s => s.x ?? 0)).tickFormat(() => '')
7137  c.yAxis.tickValues([0, .25, .5, .75, 1]).tickFormat(d3.format('.0%'))
7138  c.drawAxis()
7139  util.ggPlot?.(c)
7140
7141  var bw = 0.7
7142  w.series.forEach((s, i) => {
7143    var xc = s.x ?? i, col = condColor(s)
7144    c.svg.append('rect').at({
7145      x: c.x(xc - bw / 2), width: c.x(xc + bw / 2) - c.x(xc - bw / 2),
7146      y: c.y(s.frac), height: c.y(0) - c.y(s.frac),
7147      fill: col,
7148    })
7149    c.svg.append('line').at({
7150      x1: c.x(xc), x2: c.x(xc), y1: c.y(s.ci_lo), y2: c.y(s.ci_hi),
7151      stroke: util.gray[700], strokeWidth: 1.2,
7152    })
7153    ;[s.ci_lo, s.ci_hi].forEach(v => c.svg.append('line').at({
7154      x1: c.x(xc) - 3, x2: c.x(xc) + 3, y1: c.y(v), y2: c.y(v),
7155      stroke: util.gray[700], strokeWidth: 1.2,
7156    }))
7157    c.svg.append('text').text(d3.format('.0%')(s.frac))
7158      .at({x: c.x(xc), y: c.y(s.ci_hi) - 6, textAnchor: 'middle', fill: util.gray[700]})
7159      .st({fontSize: 'var(--fs-small)'})
7160    // One word per line, "+" glued to its first word: every label's widest
7161    // line is a single word, so adjacent multi-line labels can't collide.
7162    var words = s.label.split(' ')
7163    if (words[0] == '+' && words.length > 1) words = ['+ ' + words[1], ...words.slice(2)]
7164    words = words.map(w => w.replace(/,$/, ''))
7165    var t = c.svg.append('text.slabel')
7166      .at({x: c.x(xc), y: c.height + 14, textAnchor: 'middle', fill: util.gray[600]})
7167    words.forEach((ln, j) => {
7168      t.append('tspan').text(ln).at({x: c.x(xc), dy: j ? 10 : 0})
7169    })
7170  })
7171  // Average variance-share annotations: arrows pointing up from below at
7172  // the two component bars' x labels, quoting how much of the probe's
7173  // variance each component carries on average.
7174  if (d.share_mean != null){
7175    var pct = d3.format('.0%')
7176    var shares = {probe_jpart2: d.share_mean, probe_ortho2: 1 - d.share_mean}
7177    w.series.filter(s => shares[s.key] != null).forEach(s => {
7178      var x = c.x(s.x ?? 0), yh = c.height + 41
7179      c.svg.append('path').at({
7180        d: `M ${x} ${yh + 12} L ${x} ${yh} M ${x - 3} ${yh + 4} L ${x} ${yh} L ${x + 3} ${yh + 4}`,
7181        stroke: util.gray[600], fill: 'none', strokeWidth: 1,
7182      })
7183      var t = c.svg.append('text.sharenote')
7184        .at({x, y: yh + 24, textAnchor: 'middle', fill: util.gray[600]})
7185      t.append('tspan').text(`${pct(shares[s.key])} of probe variance`).at({x, dy: 0})
7186      t.append('tspan').text('on average').at({x, dy: 10})
7187    })
7188  }
7189
7190  // Two lines so the label fits inside the plot height instead of
7191  // overflowing past the title and into the example column.
7192  var yl = c.svg.append('text')
7193    .at({transform: `translate(${-56},${c.height / 2}) rotate(-90)`, textAnchor: 'middle'})
7194    .st({fontSize: 'var(--fs-small)'})
7195  yl.append('tspan').text('% of answers that flip').at({x: 0, dy: 0})
7196  yl.append('tspan').text("to the swap target's answer").at({x: 0, dy: 13})
7197}
7198
7199window.initProbeSwap = async function(opts){
7200  var sel = d3.select('.probe-swap').html('')
7201  var d = await loadData(opts)
7202  if (!d?.example?.decomp || !d?.bars) return
7203  var grid = sel.append('div.ps-row')
7204  drawExample(grid.append('div'), d)
7205  drawBars(grid.append('div'), d)
7206}
7207
7208}()
7209window.init?.()
7210
7211// ─── public/verbal-report-decomposition-merged/init-verbal-report-decomposition-merged.js ───
7212!function(){
7213
7214async function loadData(opts){
7215  var base = (opts?.datapath || './').replace(/\/?$/, '/')
7216  return await util.getFile(base + 'data.json')
7217}
7218
7219// Conditions drawn in the two quantification panels (display order: controls
7220// first, the J-lens reference last). The random control is exactly 0 in both
7221// panels and is stated in the caption instead.
7222// Colors come from the shared util.jspaceColors so this figure matches probe-swap.
7223function remapConds(d){ d.conditions.forEach(c => c.color = util.jspaceColors[c.key] ?? c.color) }
7224// Display order: pure J-lens → J-space → non-J-space → clamped (controls last).
7225var ORDER = ['jlens', 'aligned', 'full', 'residual', 'residual_jclamp', 'random']
7226function drawnConds(d){
7227  return d.conditions.filter(c => c.in_swap_panel !== false)
7228    .sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key))
7229}
7230
7231function fmtPct(x){ return d3.format('.0%')(x) }
7232
7233function drawConstruction(sel, d){
7234  var c = d.construction
7235  var conds = Object.fromEntries(d.conditions.map(x => [x.key, x]))
7236
7237  sel.append('div.ptitle').text('Constructing and decomposing a concept vector')
7238  sel.append('div.steplab').text(
7239    'Record the activation at the highlighted position; split it against the J-lens dictionary:')
7240
7241  var prompt = sel.append('div.prompt')
7242  prompt.append('span.role').text('Human:')
7243  prompt.append('span').text(` Tell me about ${c.word}\n\n`)
7244  prompt.append('span.role').text('Assistant')
7245  prompt.append('span.tok-hit').text(':')
7246
7247  // The cards show the worked example's own variance shares; the
7248  // cross-concept medians live in the caption.
7249  var avLab = `${Math.round(100*c.aligned_var)}% of variance`
7250  var rvLab = `${Math.round(100*c.residual_var)}% of variance`
7251
7252  // The decomposition rendered as an explicit sum:
7253  // concept vector = J-space component + non-J-space component
7254  var vchip = sel.append('div.vchip')
7255  var vline = vchip.append('div')
7256  vline.append('span').text('concept vector for “')
7257  var cvw = vline.append('span').text(c.word)
7258  vline.append('span').text('”')
7259  vchip.append('div.pct').text(`activation − mean over ${d.meta.baseline_n_words} baseline words`)
7260
7261  sel.append('div.eqop').text('=')
7262
7263  var aligned = sel.append('div.card')
7264  var alab = aligned.append('div.clab')
7265  alab.append('span.legend-sq').st({background: conds.aligned.color})
7266  alab.append('span').text('J-space component ')
7267  alab.append('span.pct').text(`(${avLab})`)
7268  aligned.append('div.atoms').appendMany('span.atom', c.atoms)
7269    .html(a => `${util.ppToken(a.token)}<span class='w'>${d3.format('.2f')(a.weight)}</span>`)
7270  aligned.append('div.csub').text(
7271    `non-negative combination of ${d.meta.ito_k} J-lens vectors (matching pursuit); … ${d.meta.ito_k - c.atoms.length} more atoms`)
7272
7273  sel.append('div.eqop').text('+')
7274
7275  var resid = sel.append('div.card')
7276  var rlab = resid.append('div.clab')
7277  rlab.append('span.legend-sq').st({background: conds.residual.color})
7278  rlab.append('span').text('non-J-space component ')
7279  rlab.append('span.pct').text(`(${rvLab})`)
7280  resid.append('div.csub').text('not aligned with any small set of J-lens vectors')
7281
7282  // Connector: exit rightward from the ":" token, curve down, land on the
7283  // chip's TOP BORDER directly above the concept word — path stays in the
7284  // whitespace right of "Assistant:" and outside the chip.
7285  sel.st({position: 'relative'})
7286  var sb = sel.node().getBoundingClientRect()
7287  var tb = prompt.select('.tok-hit').node().getBoundingClientRect()
7288  var vb = vchip.node().getBoundingClientRect()
7289  var wb = cvw.node().getBoundingClientRect()
7290  var x0 = tb.right - sb.left + 1, y0 = (tb.top + tb.bottom)/2 - sb.top
7291  var x1 = wb.left + wb.width/2 - sb.left, y1 = vb.top - sb.top
7292  sel.append('svg.connector')
7293    .st({position: 'absolute', left: 0, top: 0, pointerEvents: 'none', overflow: 'visible'})
7294    .at({width: 1, height: 1})
7295    .append('path').at({
7296      d: `M ${x0} ${y0} C ${x0+20} ${y0+4}, ${x1} ${y0+4}, ${x1} ${y1}`,
7297      stroke: util.gray[500], strokeWidth: 1, fill: 'none',
7298    })
7299}
7300
7301// Shared y domain for the two bar panels — proportion axis, clamped to [0, 1].
7302// CI whiskers and per-category dots are clamped to the same range so the axis
7303// never extends below zero to fit them.
7304var Y_DOMAIN = [0, 1]
7305function clampY(v){ return Math.max(0, Math.min(1, v)) }
7306
7307function drawSwap(sel, d){
7308  sel.append('div.ptitle').text('“Think of a {category}” experiment')
7309  sel.append('div.steplab').text(
7310    "Swap the chosen answer's directions for a target item's; does the target become the answer?")
7311  var chart = sel.append('div.vrdm-chart')
7312  var conds = drawnConds(d)
7313  var c = d3.conventions({
7314    sel: chart,
7315    width: 240, height: 260,
7316    margin: {left: 48, top: 14, right: 8, bottom: 18},
7317  })
7318  c.x = d3.scaleBand().domain(conds.map(x => x.key)).range([0, c.width]).padding(0.35)
7319  c.y = d3.scaleLinear().domain(Y_DOMAIN).range([c.height, 0])
7320  c.xAxis.scale(c.x).tickFormat(() => '')
7321  c.yAxis.scale(c.y).ticks(6).tickFormat(d3.format('.0%'))
7322  c.drawAxis()
7323  util.ggPlot(c)
7324  util.addAxisLabel(c, '', d.swap.ylabel, '', 0, -10)
7325
7326  conds.forEach(cond => {
7327    var s = d.swap.series[cond.key]
7328    var x0 = c.x(cond.key), bw = c.x.bandwidth()
7329    c.svg.append('rect').at({
7330      x: x0, y: c.y(s.p5), width: bw, height: c.y(0) - c.y(s.p5),
7331      fill: cond.color,
7332    })
7333    c.svg.append('path').at({
7334      d: `M ${x0 + bw/2 - 3} ${c.y(clampY(s.ci_lo))} h 6 M ${x0 + bw/2} ${c.y(clampY(s.ci_lo))} V ${c.y(clampY(s.ci_hi))} M ${x0 + bw/2 - 3} ${c.y(clampY(s.ci_hi))} h 6`,
7335      stroke: util.gray[700], strokeWidth: 1.2, fill: 'none',
7336    })
7337    // per-category dots, deterministic horizontal spread. No percent label
7338    // above the bar (matching the polished panel).
7339    c.svg.appendMany('circle', s.per_category).at({
7340      cx: (q, i) => x0 + bw * (0.2 + 0.6 * (i / Math.max(s.per_category.length - 1, 1))),
7341      cy: q => c.y(clampY(q.p5)), r: 2.2,
7342      fillOpacity: 0, stroke: util.gray[700], strokeWidth: 1,
7343    })
7344  })
7345}
7346
7347function drawDose(sel, d){
7348  sel.append('div.ptitle').text('“Injected thought” experiment')
7349  sel.append('div.steplab').text(
7350    'Inject each component; does the model name the concept when asked what it is thinking about? Results taken from each condition’s best injection strength.')
7351  var chart = sel.append('div.vrdm-chart')
7352  var conds = drawnConds(d)
7353  var c = d3.conventions({
7354    sel: chart,
7355    width: 240, height: 260,
7356    margin: {left: 48, top: 14, right: 8, bottom: 18},
7357  })
7358  // Bar chart of the max P@5 over the strength sweep (the bake's peak_p5),
7359  // mirroring the swap panel; the full per-strength dose-response curves
7360  // live in the appendix figure (verbal-report-decomposition-dose).
7361  c.x = d3.scaleBand().domain(conds.map(x => x.key)).range([0, c.width]).padding(0.35)
7362  c.y = d3.scaleLinear().domain(Y_DOMAIN).range([c.height, 0])
7363  c.xAxis.scale(c.x).tickFormat(() => '')
7364  c.yAxis.scale(c.y).ticks(6).tickFormat(d3.format('.0%'))
7365  c.drawAxis()
7366  util.ggPlot(c)
7367  util.addAxisLabel(c, '', d.dose.ylabel, '', 0, -10)
7368
7369  // No condition labels on the bars — the shared legend under panels 2-3
7370  // identifies them (matching the swap panel); each bar is annotated with
7371  // the injection strength its results are taken from.
7372  conds.forEach(cond => {
7373    var s = d.dose.series[cond.key]
7374    var x0 = c.x(cond.key), bw = c.x.bandwidth()
7375    c.svg.append('rect').at({
7376      x: x0, y: c.y(s.peak_p5), width: bw, height: c.y(0) - c.y(s.peak_p5),
7377      fill: cond.color,
7378    })
7379    c.svg.append('path').at({
7380      d: `M ${x0 + bw/2 - 3} ${c.y(clampY(s.peak_ci_lo))} h 6 M ${x0 + bw/2} ${c.y(clampY(s.peak_ci_lo))} V ${c.y(clampY(s.peak_ci_hi))} M ${x0 + bw/2 - 3} ${c.y(clampY(s.peak_ci_hi))} h 6`,
7381      stroke: util.gray[700], strokeWidth: 1.2, fill: 'none',
7382    })
7383    c.svg.append('text').at({
7384      x: x0 + bw/2, y: c.y(s.peak_ci_hi) - 4, textAnchor: 'middle',
7385      fontSize: 8.5, fill: '#6b7280',
7386    }).text(`Strength: ${s.peak_strength}`)
7387    // No dots here: outcomes at a single strength are binary per concept,
7388    // so there is no per-group distribution to show (unlike the swap
7389    // panel's per-category dots); the full dose-response curves live in
7390    // the appendix figure.
7391  })
7392}
7393
7394function drawLegend(sel, d){
7395  var leg = sel.append('div.legendrow')
7396  // Legend reads in the same left-to-right order as the bars in the panels.
7397  drawnConds(d).forEach(c => {
7398    leg.append('div').html(`<span class='legend-sq' style='background:${c.color}'></span>${c.label}`)
7399  })
7400}
7401
7402window.initVerbalReportDecompositionMerged = async function(opts){
7403  var sel = d3.select('.verbal-report-decomposition-merged').html('')
7404  var d = await loadData(opts)
7405  if (!d?.construction || d?.swap?.series?.aligned?.p5 == null) return
7406  if (d?.dose?.series?.aligned?.peak_p5 == null) return
7407  remapConds(d)
7408  drawConstruction(sel.append('div.vrdm-col.construction'), d)
7409  var quant = sel.append('div.vrdm-quant')
7410  var charts = quant.append('div.charts')
7411  drawSwap(charts.append('div.vrdm-col'), d)
7412  drawDose(charts.append('div.vrdm-col'), d)
7413  drawLegend(quant, d)
7414}
7415
7416}()
7417window.init?.()
7418
7419// ─── public/selfreport/init-selfreport.js ───
7420// Self-reports of experience. Two render paths share this module:
7421//  - selfreport-soc / -other / -questions / -story / -controls / -dose draw
7422//    d3 charts from the baked data.json so they follow the paper's figure
7423//    conventions.
7424//  - selfreport-examples (the response browser) injects the section's
7425//    reviewed export verbatim (phenomenology/figures.py → figures_html/),
7426//    via the loader below.
7427!function(){
7428
7429// ───── verbatim-export loader (jack's pipeline) ─────
7430
7431// The exported figure HTML is build-generated (model text is escaped at
7432// build time) and fetched same-origin, but the datapath is a URL parameter:
7433// restrict it to relative paths and strip any active content before
7434// injection so a crafted ?datapath= cannot turn the loader into XSS.
7435function safeBase(base){
7436  // relative paths only — init.js passes bare 'data/<dir>/' (no './' prefix),
7437  // so reject schemes, protocol-relative '//', and root-absolute '/' instead
7438  // of requiring a leading './'.
7439  return !/^[a-z]+:|^\/|\/\//i.test(base)
7440}
7441
7442function sanitizeFigureHtml(html){
7443  var doc = new DOMParser().parseFromString(html, 'text/html')
7444  doc.querySelectorAll('script,iframe,object,embed,link,meta').forEach(n => n.remove())
7445  doc.querySelectorAll('*').forEach(n => {
7446    for (var a of [...n.attributes]){
7447      if (/^on/i.test(a.name)) n.removeAttribute(a.name)
7448      else if (/^(href|src|srcset|xlink:href)$/i.test(a.name)
7449               && /^\s*(javascript|vbscript|data:text)/i.test(a.value)) n.removeAttribute(a.name)
7450    }
7451  })
7452  return doc.body.innerHTML
7453}
7454
7455var _cssDone = null
7456async function ensureCss(base){
7457  if (_cssDone) return _cssDone
7458  _cssDone = (async () => {
7459    var css = await util.getFile(base + 'figures_html/figures.css')
7460    // figures.css is a standalone-page stylesheet with bare selectors (.row,
7461    // body, h2, …) — scope it under the loader mount so it can't leak onto
7462    // other figures' .row/.tok/etc. elements.
7463    d3.select('html').selectAppend('style.selfreport-figures-css')
7464      .text('.selfreport-examples {\n' + String(css).replaceAll('</', '<\\/') + '\n}')
7465  })()
7466  return _cssDone
7467}
7468
7469function makeInit(name){
7470  return async function(opts){
7471    var base = (opts?.datapath || './').replace(/\/?$/, '/')
7472    if (!safeBase(base)) return
7473    var sel = d3.select('.' + name)
7474    if (sel.empty()) return
7475    await ensureCss(base)
7476    var html = await util.getFile(base + 'figures_html/' + name + '.html')
7477    sel.html(sanitizeFigureHtml(String(html)))
7478  }
7479}
7480
7481window.initSelfreportExamples = makeInit('selfreport-examples')
7482
7483// ───── d3 figures from data.json ─────
7484
7485var _data = null
7486async function loadData(opts){
7487  if (_data) return _data
7488  var base = (opts?.datapath || './').replace(/\/?$/, '/')
7489  _data = await util.getFile(base + 'data.json')
7490  return _data
7491}
7492
7493// Bar order left→right; baseline gray, the J-space ablation red right next to
7494// it (red = ablated, never a series color), then the matched controls as blue
7495// tints.
7496var CONDS = [
7497  {key: 'clean',  color: util.gray[400]},
7498  {key: 'ablate', color: util.tol.red},
7499  {key: 'random', color: util.tint(util.tol.blue, 0.75)},
7500  {key: 'ortho',  color: util.tint(util.tol.blue, 0.5)},
7501  {key: 'sae',    color: util.tint(util.tol.blue, 0.25)},
7502]
7503
7504// Match the reviewed figures' control wording until the bake re-runs with
7505// the renamed labels.
7506var COND_LABEL_OVERRIDE = {
7507  random: 'random control',
7508  ortho: 'non-J-space control',
7509  sae: 'SAE-direction control',
7510}
7511function condLabel(d, key){ return COND_LABEL_OVERRIDE[key] || d.cond_labels[key] || key }
7512
7513// "sonnet-4.5" → ["Sonnet", "4.5"] — two-line model labels under the bars.
7514function modelLines(m){
7515  var [name, ...rest] = m.split('-')
7516  return [name[0].toUpperCase() + name.slice(1), rest.join('-')]
7517}
7518
7519// ───── shared: example pair cards ─────
7520
7521function drawPair(sel, pair, pairOpts={}){
7522  var row = sel.append('div.exrow')
7523  if (!pairOpts.noHead){
7524    var ex = row.append('div.exhead')
7525    ex.append('span.prompt-role').text('Human: ')
7526    ex.append('span').text(pair.prompt)
7527  }
7528  var grid = row.append('div.expair')
7529  ;[['baseline', util.gray[400], pair.baseline, pair.baseline_first],
7530    ['J-space ablated', util.tol.red, pair.ablated, pair.ablated_first]]
7531    .forEach(([label, color, txt, first]) => {
7532    var card = grid.append('div.card')
7533    var head = card.append('div.label')
7534    head.append('span.swatch').st({background: color})
7535    head.append('span').text(label)
7536    // Questions excerpts are multi-turn — show each condition's full
7537    // conversation (shared framing/question, per-condition first
7538    // response, then the graded answer) in its own card. soc/other/story
7539    // pairs lack pair.framing and fall through to the single-excerpt path.
7540    if (pair.framing){
7541      ;[['Human', pair.framing], ['Assistant', first],
7542        ['Human', pair.question], ['Assistant', txt]]
7543        .forEach(([who, t]) => {
7544          if (!t) return
7545          var turn = card.append('div.excerpt.prompt-block')
7546          turn.append('span.prompt-role').text(who + ': ')
7547          turn.append('span').text(t)
7548        })
7549    } else {
7550      var body = card.append('div.excerpt.prompt-block')
7551      body.append('span.prompt-role').text('Assistant: ')
7552      body.append('span').text(txt)
7553    }
7554  })
7555}
7556
7557// ───── shared: experiential-language grouped bar panel ─────
7558
7559function drawScoreBars(sel, d, barData, opts={}){
7560  var models = d.models.filter(m => barData[m])
7561  var c = d3.conventions({
7562    sel,
7563    width: opts.width || 250, height: opts.height || 280,
7564    margin: {left: 46, top: 10, right: 8, bottom: 40},
7565  })
7566  var x = d3.scaleBand().domain(models).range([0, c.width]).padding(0.22)
7567  var conds = CONDS.filter(cond => models.some(m => barData[m][cond.key]))
7568  var xi = d3.scaleBand().domain(conds.map(d => d.key)).range([0, x.bandwidth()]).padding(0.12)
7569  var yMax = opts.yMax || 1
7570  c.y.domain([0, yMax])
7571  c.yAxis.ticks(5)
7572  c.drawAxis()
7573  c.svg.select('.x').remove()
7574  util.ggPlot(c)
7575
7576  c.svg.append('text.axlabel').text(opts.ylabel || 'Experiential language score')
7577    .at({transform: `rotate(-90)`, x: -c.height/2, y: -34, textAnchor: 'middle'})
7578
7579  models.forEach(m => {
7580    conds.forEach(cond => {
7581      var a = barData[m][cond.key]
7582      if (!a) return
7583      var bx = x(m) + xi(cond.key), bw = xi.bandwidth()
7584      c.svg.append('rect').at({
7585        x: bx, y: c.y(a.mean), width: bw, height: c.height - c.y(a.mean),
7586        fill: cond.color,
7587      })
7588      var cx = bx + bw/2
7589      var lo = Math.max(0, a.mean - a.ci), hi = Math.min(yMax, a.mean + a.ci)
7590      c.svg.append('line').at({x1: cx, x2: cx, y1: c.y(lo), y2: c.y(hi), stroke: util.gray[700], strokeWidth: 1.2})
7591      if (opts.dots ?? true) (a.per_prompt || []).forEach((v, j, arr) => {
7592        var jitter = 0.4 * bw * ((j / Math.max(arr.length - 1, 1)) - 0.5)
7593        c.svg.append('circle').at({cx: cx + jitter, cy: c.y(v), r: 2, fill: 'none', stroke: util.gray[700], strokeWidth: 1})
7594      })
7595    })
7596    // Two-line model labels ("Sonnet" / "4.5") so the narrow panels never
7597    // overlap adjacent labels.
7598    var t = c.svg.append('text')
7599      .at({x: x(m) + x.bandwidth()/2, y: c.height + 16, textAnchor: 'middle'})
7600      .st({fontSize: 'var(--fs-body)', fill: 'var(--tick)'})
7601    modelLines(m).filter(ln => ln).forEach((ln, j) => {
7602      t.append('tspan').text(ln).at({x: x(m) + x.bandwidth()/2, dy: j ? 13 : 0})
7603    })
7604  })
7605  return conds
7606}
7607
7608function drawLegend(sel, d, conds){
7609  var leg = sel.append('div.legend')
7610  conds.forEach(cond => {
7611    var item = leg.append('div.item')
7612    item.append('span.legend-sq').st({background: cond.color})
7613    item.append('span').text(condLabel(d, cond.key))
7614  })
7615  return leg
7616}
7617
7618// ───── selfreport-soc: examples beside [score bars over lens tokens] ─────
7619
7620function drawLensTokens(sel, d, opts={}){
7621  var toks = d.lens_tokens
7622  var rowH = opts.rowH || 17
7623  var c = d3.conventions({
7624    sel,
7625    width: opts.width || 320, height: toks.length * rowH,
7626    margin: {left: 96, top: 8, right: 8, bottom: 40},
7627  })
7628  var y = d3.scaleBand().domain(toks.map(t => t.token)).range([0, c.height]).padding(0.18)
7629  var xmax = d3.max(toks, t => t.ws) * 1.05
7630  c.x.domain([0, xmax])
7631  c.xAxis.ticks(4)
7632  c.drawAxis()
7633  c.svg.select('.y').remove()
7634  util.ggPlot(c)
7635  toks.forEach(t => {
7636    var bh = y.bandwidth()/2
7637    c.svg.append('rect').at({x: 0, y: y(t.token), width: c.x(t.ws), height: bh, fill: util.gray[600]})
7638    c.svg.append('rect').at({x: 0, y: y(t.token) + bh, width: c.x(t.final), height: bh, fill: util.tint(util.tol.blue, 0.65)})
7639    c.svg.append('text').text(t.token)
7640      .at({x: -5, y: y(t.token) + y.bandwidth()/2, textAnchor: 'end', dy: '.33em'})
7641      .st({fontSize: 'var(--fs-label)', fontFamily: 'var(--font-mono)'})
7642  })
7643  c.svg.append('text.axlabel').text('Fraction of slots in top-10 readout')
7644    .at({x: c.width/2, y: c.height + 32, textAnchor: 'middle'})
7645}
7646
7647// Small html legend under a chart — [label, color] pairs.
7648function drawSwatchLegend(sel, items){
7649  var leg = sel.append('div.legend')
7650  items.forEach(([label, color]) => {
7651    var item = leg.append('div.item')
7652    item.append('span.legend-sq').st({background: color})
7653    item.append('span').text(label)
7654  })
7655}
7656
7657// Bordered panel with an A/B/C letter chip — the captions reference the
7658// panels by letter.
7659function panelBox(sel, letter){
7660  var box = sel.append('div.panelbox')
7661  if (letter) box.append('div.pletter').text(letter)
7662  return box
7663}
7664
7665window.initSelfreportSoc = async function(opts){
7666  var d = await loadData(opts)
7667  var sel = d3.select('.selfreport-soc').html('')
7668  // Two-column: example pairs (A) fill the wide left column; the score bars
7669  // (B, with the condition legend inside its card) stack over the J-lens
7670  // contents (C) in the right column, which stretches to match A's height.
7671  var grid = sel.append('div.srgrid')
7672  var left = panelBox(grid, 'A')
7673  d.excerpts.soc.forEach(pair => drawPair(left, pair))
7674  var right = grid.append('div.srright')
7675  var bbox = panelBox(right, 'B')
7676  var conds = drawScoreBars(bbox.append('div.panel'), d, d.bars.soc,
7677    {width: 240, height: 200})
7678  drawLegend(bbox, d, conds)
7679  var cbox = panelBox(right, 'C')
7680  drawLensTokens(cbox.append('div.panel'), d, {width: 200, rowH: 13})
7681  drawSwatchLegend(cbox, [
7682    ['Ablated layers (' + d.layers + ')', util.gray[600]],
7683    ['Final layer', util.tint(util.tol.blue, 0.65)],
7684  ])
7685  util.colorCaption(sel, {'A:': 'capchip', 'B:': 'capchip', 'C:': 'capchip'})
7686  sel.node()?.closest('figure')?.querySelectorAll('figcaption .capchip')
7687    .forEach(n => n.textContent = n.textContent.replace(/:$/, ''))
7688}
7689
7690// ───── selfreport-other / selfreport-questions: pairs beside bars ─────
7691
7692function pairsBesideBars(slug, key, opts={}){
7693  return async function(initOpts){
7694    var d = await loadData(initOpts)
7695    var sel = d3.select('.' + slug).html('')
7696    var grid = sel.append('div.srgrid')
7697    var left = grid.append('div.srleft')
7698    var pairs = opts.pickPairs ? opts.pickPairs(d.excerpts[key]) : d.excerpts[key]
7699    pairs.forEach(pair => drawPair(left, pair, opts.pairOpts))
7700    var match = opts.barHeight === 'match'
7701    var right = grid.append('div.srright').classed('vcenter', !match)
7702    var panel = right.append('div.panel')
7703    var conds = CONDS.filter(c => d.models.some(m => d.bars[key][m]?.[c.key]))
7704    var bh = opts.barHeight || 280
7705    if (match){
7706      // Render the legend first so its laid-out height can be subtracted
7707      // from the budget; the bars then fill the rest of the example
7708      // column's height (no whitespace between them and the legend).
7709      drawLegend(right, d, conds).st({marginTop: 0})
7710      var leg = right.select('.legend').node()
7711      bh = Math.max(220, left.node().offsetHeight - leg.offsetHeight - 14 - 50)
7712    }
7713    drawScoreBars(panel, d, d.bars[key],
7714      {width: opts.barWidth || 250, height: bh})
7715    if (!match) drawLegend(right, d, conds)
7716  }
7717}
7718window.initSelfreportOther = pairsBesideBars('selfreport-other', 'other')
7719// Questions: show only the second example pair (the conversations are tall
7720// enough to read on their own), drop the per-pair prompt header, and let the
7721// bars panel match the example column's height.
7722window.initSelfreportQuestions = pairsBesideBars('selfreport-questions', 'questions',
7723  {pickPairs: ps => ps.slice(1, 2), pairOpts: {noHead: true},
7724    barWidth: 290, barHeight: 'match'})
7725
7726// ───── selfreport-story: example pair over [craft bars | score bars] ─────
7727
7728window.initSelfreportStory = async function(opts){
7729  var d = await loadData(opts)
7730  var sel = d3.select('.selfreport-story').html('')
7731  var top = sel.append('div.srleft')
7732  d.excerpts.story.slice(0, 1).forEach(pair => drawPair(top, pair))
7733  // The story bake nests {model: {cond: {craft, score}}}; reshape per metric
7734  // so the same grouped-bar helper draws both panels.
7735  var pick = which => Object.fromEntries(d.models
7736    .filter(m => d.story[m]).map(m => [m, Object.fromEntries(
7737      Object.entries(d.story[m]).map(([cond, v]) => [cond, v[which]]))]))
7738  // Size each chart so the row of two (margins + chartrow gap) fills the
7739  // same width as the example pair above.
7740  var topW = top.node().offsetWidth
7741  var barM = 46 + 8, gap = 22
7742  var bw = Math.max(220, Math.floor((topW - gap) / 2) - barM)
7743  var row = sel.append('div.chartrow.center.wrap')
7744  drawScoreBars(row.append('div.panel'), d, pick('craft'),
7745    {width: bw, height: 280, ylabel: 'Story quality (0–10)', yMax: 10, dots: false})
7746  var conds = drawScoreBars(row.append('div.panel'), d, pick('score'),
7747    {width: bw, height: 280, dots: false})
7748  drawLegend(sel, d, conds)
7749}
7750
7751// ───── selfreport-controls: matched-control battery, by judgment ─────
7752
7753var BATTERY_KIND_COLOR = {
7754  clean: util.gray[400],
7755  ablate: util.tol.red,
7756  control: util.tint(util.tol.blue, 0.5),
7757}
7758function batteryColor(e){
7759  if (e.kind) return BATTERY_KIND_COLOR[e.kind] || BATTERY_KIND_COLOR.control
7760  // Older bakes carry only the display label; classify from it.
7761  if (e.label == 'baseline') return BATTERY_KIND_COLOR.clean
7762  if (e.label == 'J-space ablated') return BATTERY_KIND_COLOR.ablate
7763  return BATTERY_KIND_COLOR.control
7764}
7765
7766function drawBatteryPanel(sel, entries, grader, opts={}){
7767  var rowH = 15
7768  var c = d3.conventions({
7769    sel,
7770    width: 190, height: entries.length * rowH,
7771    margin: {left: opts.first ? 190 : 12, top: opts.title ? 24 : 8, right: 12, bottom: 24},
7772  })
7773  var y = d3.scaleBand().domain(d3.range(entries.length)).range([0, c.height]).padding(0.2)
7774  c.x.domain([0, 1])
7775  c.xAxis.ticks(3).tickFormat(d3.format('.0%'))
7776  c.drawAxis()
7777  c.svg.select('.y').remove()
7778  util.ggPlot(c)
7779
7780  entries.forEach((e, i) => {
7781    var a = e.graders[grader]
7782    if (!a) return
7783    var by = y(i), bh = y.bandwidth(), cy = by + bh/2
7784    c.svg.append('rect').at({x: 0, y: by, width: c.x(a.p), height: bh,
7785      fill: batteryColor(e)})
7786    c.svg.append('line').at({x1: c.x(a.lo), x2: c.x(a.hi), y1: cy, y2: cy,
7787      stroke: util.gray[700], strokeWidth: 1.2})
7788    if (opts.first){
7789      c.svg.append('text').text(e.label)
7790        .at({x: -6, y: cy, textAnchor: 'end', dy: '.33em'})
7791        .st({fontSize: 'var(--fs-label)', fill: 'var(--tick)'})
7792    }
7793  })
7794  if (opts.title) c.svg.append('text.ptitle').text(opts.title)
7795    .at({x: c.width/2, y: -10, textAnchor: 'middle'})
7796}
7797
7798window.initSelfreportControls = async function(opts){
7799  var d = await loadData(opts)
7800  var sel = d3.select('.selfreport-controls').html('')
7801  var graders = Object.keys(d.grader_labels)
7802  var rows = [['soc', 'Stream of consciousness'], ['questions', 'Questions about experience']]
7803  rows.forEach(([panel, rowLabel], ri) => {
7804    sel.append('div.mlabel').text(rowLabel)
7805    var row = sel.append('div.chartrow.center.wrap')
7806    graders.forEach((g, i) => {
7807      drawBatteryPanel(row.append('div.panel'), d.battery[panel], g,
7808        {title: ri == 0 ? d.grader_labels[g] : null, first: i == 0})
7809    })
7810  })
7811}
7812
7813// ───── selfreport-dose: per-judgment dose-response line panels ─────
7814
7815function drawDosePanel(sel, series, points, xKey, xLabel, opts={}){
7816  var c = d3.conventions({
7817    sel,
7818    width: 250, height: 190,
7819    margin: {left: opts.first ? 54 : 40, top: 24, right: 12, bottom: 42},
7820  })
7821  var x = xKey == 'k'
7822    ? d3.scaleLog().domain([1, 40]).range([0, c.width])
7823    : d3.scalePoint().domain(points.map(p => p.band)).range([0, c.width]).padding(0.4)
7824  c.x = x
7825  c.y.domain([0, 1])
7826  c.yAxis = d3.axisLeft(c.y).ticks(5).tickFormat(d3.format('.0%'))
7827  c.xAxis = xKey == 'k'
7828    ? d3.axisBottom(x).tickValues([1, 3, 5, 10, 20, 40]).tickFormat(d3.format('d'))
7829    : d3.axisBottom(x).tickFormat(s => s.replace('L', ''))
7830  c.drawAxis()
7831  util.ggPlot(c)
7832
7833  // Clean baseline (no ablation) as a dashed reference line, labeled directly
7834  // in the first panel of each row.
7835  c.svg.append('line').at({
7836    x1: 0, x2: c.width, y1: c.y(series.baseline), y2: c.y(series.baseline),
7837    stroke: util.gray[500], strokeWidth: 1, strokeDasharray: '4,3',
7838  })
7839  if (opts.first) util.directLabel(c.svg, {
7840    x: 4, y: c.y(series.baseline), dy: series.baseline > 0.5 ? 12 : -6,
7841    text: 'No-ablation baseline', color: util.gray[600],
7842  })
7843
7844  // Single series → black line, gray whiskers.
7845  var xv = p => xKey == 'k' ? x(p.k) : x(p.band)
7846  points.forEach(p => {
7847    c.svg.append('line').at({x1: xv(p), x2: xv(p), y1: c.y(p.lo), y2: c.y(p.hi),
7848      stroke: util.gray[700], strokeWidth: 1.2})
7849  })
7850  c.svg.append('path').at({
7851    d: d3.line().x(xv).y(p => c.y(p.p))(points),
7852    stroke: util.gray[900], fill: 'none', strokeWidth: 1.6,
7853  })
7854  points.forEach(p => {
7855    c.svg.append('circle').at({cx: xv(p), cy: c.y(p.p), r: 2.5, fill: util.gray[900]})
7856  })
7857
7858  if (opts.title) c.svg.append('text.ptitle').text(opts.title).at({x: c.width/2, y: -10, textAnchor: 'middle'})
7859  if (opts.first) c.svg.append('text.axlabel').text('Fraction of responses scored positive')
7860    .at({transform: 'rotate(-90)', x: -c.height/2, y: -40, textAnchor: 'middle'})
7861  c.svg.append('text.axlabel').text(xLabel).at({x: c.width/2, y: c.height + 34, textAnchor: 'middle'})
7862}
7863
7864window.initSelfreportDose = async function(opts){
7865  var d = await loadData(opts)
7866  var sel = d3.select('.selfreport-dose').html('')
7867  var graders = Object.keys(d.dose)
7868  ;[['vs_k', 'Directions ablated (k)', 'k'], ['vs_layers', 'Ablated layer band', 'band']].forEach(([key, xlabel, xKey]) => {
7869    var row = sel.append('div.chartrow.center.wrap')
7870    graders.forEach((g, i) => {
7871      drawDosePanel(row.append('div.panel'), d.dose[g], d.dose[g][key], xKey, xlabel,
7872        {first: i == 0, title: d.dose[g].label})
7873    })
7874  })
7875}
7876
7877}()
7878window.init?.()
7879
7880// ─── public/slice-inline/init-slice-inline.js ───
7881window.initSliceInline = async function(opts){
7882  opts = opts || {}
7883  var sel = d3.select(opts.sel || '.slice-inline').html('')
7884  var base = (opts.datapath || './').replace(/\/?$/, '/')
7885  var ttSel = d3.select('.tooltip')
7886  if (!ttSel.size()) ttSel = d3.select('body').append('div.tooltip.tooltip-hidden')
7887
7888  var K = 10
7889
7890  // Per-slug presentation: title + caption only. The card itself is a condensed
7891  // version of the lens-slice viewer's three core panels (top-1 grid, by-layer
7892  // readout, by-position readout) for a single prompt.
7893  var perSlug = {
7894    count_five: {
7895      title: 'Counting ahead',
7896      cap: 'Asked to count to five, the lens at the final prompt positions already reads out "One" — the first token of the upcoming count — before the assistant has produced anything. Hover any cell of the top-1 grid for the full readout.',
7897    },
7898    introspection: {
7899      title: 'Introspection',
7900      cap: 'Asked to silently think of a sport before naming it, the lens at the final prompt positions already reads "Basketball" — the sport the model goes on to answer — while mid layers read "okay" and "thinking". Hover any cell of the top-1 grid for the full readout.',
7901    },
7902    calc_ooo: {
7903      title: 'Mental arithmetic',
7904      cap: 'Before any answer token is produced, the intermediate value "42" — (4+17)×2 — rises to the top of the lens readout at the final prompt positions in late layers. Hover any cell of the top-1 grid for the full readout.',
7905    },
7906    mars: {
7907      title: 'Multihop recall',
7908      cap: '"Mars" is never written in the prompt, but from the middle layers on the lens reads it over the final tokens — an intermediate resolved before the answer "red". Hover any cell of the top-1 grid for the full readout.',
7909    },
7910    ascii_face: {
7911      title: 'ASCII face',
7912      cap: 'The lens reads "Face" over the drawing well before the assistant answers. Hover any cell of the top-1 grid for the full readout.',
7913    },
7914    mutate_iter: {
7915      title: 'Bug in code',
7916      cap: '"Error" surfaces in the lens at the line that mutates the dict mid-iteration. Hover any cell of the top-1 grid for the full readout.',
7917    },
7918    avgfp_raw: {
7919      title: 'Protein recognition',
7920      cap: 'A few residues into the sequence the lens reads "fluor" — the model has recognised green fluorescent protein. Hover any cell of the top-1 grid for the full readout.',
7921    },
7922  }
7923
7924  var slugs = opts.slugs || ['count_five', 'introspection']
7925  if (typeof slugs == 'string') slugs = slugs.split(',').map(s =>
7925 s.trim()).filter(Boolean)
7926
7927  var cards = sel.append('div.cards')
7928  for (var slug of slugs) await renderCard(cards.append('div.scard'), slug)
7929
7930  function renderTodo(card, slug, cfg){
7931    var box = card.append('div.todo-card')
7932    box.append('div.todo-tag').text('TODO — no baked data')
7933    box.append('div.todo-note').text(cfg.todo || `No ${slug} payload found under ${base}`)
7934  }
7935
7936  // condensed card: prompt up top, then the slice viewer's three columns in a
7937  // row — top-1 (argmax) grid, by-layer readout, by-position readout. Hover the
7938  // grid for the full top-k; click anywhere to move the selected cell.
7939  async function renderCard(card, slug){
7940    var cfg = perSlug[slug] || {}
7941    card.append('div.mlabel.card-title').text(cfg.title || slug)
7942
7943    var d = null
7944    try { d = await util.loadLensSlice(base, slug) } catch (e){ console.warn('slice-inline', slug, e.message) }
7945    if (!d) return renderTodo(card, slug, cfg)
7946
7947    var ctx = d3.clamp(0, d.default_ctx ?? d.nCtx - 1, d.nCtx - 1)
7948    var layer = d.layers[Math.floor(d.layers.length * 2/3)]
7949
7950    // display rank 1 = argmax regardless of whether the payload is 0- or 1-based
7951    var rankBase = d.cell(ctx, d.layers[0])[0]?.rank ?? 0
7952    var dispRank = r => r + 1 - rankBase
7953
7954    var promptToks = util.appendPromptPara(card.append('div.prompt-para'), d.ctxTokens)
7955      .on('click', (e, i) => setCtx(i))
7956
7957    var row = card.append('div.srow')
7958    var col1 = row.append('div.scol.scol-grid')
7959    var col2 = row.append('div.scol.scol-layer')
7960    var col3 = row.append('div.scol.scol-pos')
7961
7962    if (cfg.cap) util.panelCaption(card, opts, cfg.cap).classed('cap', true)
7963
7964    var gridCells, ctxTokCells
7965
7966    function setCtx(c){ if (c != null && c !== ctx){ ctx = c; buildCol2(); updateSel() } }
7967    function setLayer(L){ if (L != null && L !== layer){ layer = L; buildCol3(); updateSel() } }
7968
7969    // ───── col1: top-1 (argmax) grid, layer × pos ─────
7970    function buildGrid(){
7971      col1.append('div.col-title.mlabel').text('Top-1 readout · layer × pos')
7972      var table = col1.append('div.grid-wrap').append('table.argmax')
7973      var rows = table.append('tbody').appendMany('tr', [...d.layers].reverse())
7974      rows.append('td.layer-label').text(util.layerLabel)
7975      rows.each(function(L){
7976        d3.select(this).appendMany('td.cell', d3.range(d.nCtx).map(c => ({L, c})))
7977          .text(p => util.ppCell(d.argmax(p.c, p.L)))
7978          .on('click', (e, p) => { setCtx(p.c); setLayer(p.L) })
7979          .on('mouseenter', showTip).on('mousemove', moveTip).on('mouseleave', hideTip)
7980      })
7981      var ftok = table.append('tfoot').append('tr')
7982      ftok.append('td')
7983      ctxTokCells = ftok.appendMany('td.ctx-tok', d3.range(d.nCtx))
7984        .text(c => util.ppCell(d.ctxTokens[c])).at({title: c => util.ppCell(d.ctxTokens[c])})
7985        .on('click', (e, c) => setCtx(c))
7986      gridCells = table.selectAll('td.cell')
7987    }
7988
7989    // ───── col2: by-layer readout at the selected position ─────
7990    function buildCol2(){
7991      col2.html('')
7992      col2.append('div.col-title.mlabel').text(`By layer · pos ${ctx} ${util.ppCell(d.ctxTokens[ctx])}`)
7993      var rows = col2.append('div.rows-wrap').appendMany('div.lyr-row', [...d.layers].reverse())
7994        .on('click', (e, L) => setLayer(L))
7995      rows.append('span.row-label').text(util.layerLabel)
7996      rows.each(function(L){
7997        util.appendLensTopk(d3.select(this).append('span.lens-topk'), d.cell(ctx, L), K)
7998      })
7999    }
8000
8001    // ───── col3: by-position readout at the selected layer ─────
8002    function buildCol3(){
8003      col3.html('')
8004      col3.st({'--label-ch': String(d.nCtx - 1).length + 'ch'})
8005      col3.append('div.col-title.mlabel').text(`By pos · L${util.layerLabel(layer)}`)
8006      var rows = col3.append('div.rows-wrap').appendMany('div.pos-row', d3.range(d.nCtx))
8007        .on('click', (e, c) => setCtx(c))
8008      rows.append('span.row-label').text(c => c)
8009      rows.append('span.row-ctx').text(c => util.ppCell(d.ctxTokens[c])).at({title: c => util.ppCell(d.ctxTokens[c])})
8010      rows.each(function(c){
8011        util.appendLensTopk(d3.select(this).append('span.lens-topk'), d.cell(c, layer), K)
8012      })
8013    }
8014
8015    // scroll only inside the card's own containers, never the page
8016    function updateSel(){
8017      gridCells.classed('sel', p => p.L === layer && p.c === ctx)
8018      ctxTokCells.classed('sel', c => c === ctx)
8019      promptToks.classed('sel', i => i === ctx)
8020      col2.selectAll('.lyr-row').classed('sel', L => L === layer)
8021      col3.selectAll('.pos-row').classed('sel', c => c === ctx)
8022      var wrap = col1.select('.grid-wrap').node(), cell = ctxTokCells.nodes()[ctx]
8023      if (wrap && cell) wrap.scrollLeft = cell.offsetLeft - wrap.clientWidth / 2
8024      var rw = col3.select('.rows-wrap').node(), prow = rw?.children[ctx]
8025      if (prow) rw.scrollTop = ctx * prow.offsetHeight - rw.clientHeight / 2
8026    }
8027
8028    function showTip(ev, p){
8029      moveTip(ev)
8030      ttSel.classed('tooltip-hidden', 0).html('').classed('si-tip', 1)
8031      ttSel.append('div.tt-hdr').text(`L${util.layerLabel(p.L)} · pos ${p.c} ${util.ppCell(d.ctxTokens[p.c])}`)
8032      var tbl = ttSel.append('table')
8033      d.cell(p.c, p.L).forEach(r => {
8034        var tr = tbl.append('tr')
8035        tr.append('td.r').text(util.ppRank(dispRank(r.rank)))
8036        tr.append('td.t').text(util.ppCell(r.str))
8037        tr.append('td.p').text(Math.round(r.prob * 100) + '%')
8038      })
8039    }
8040    function moveTip(ev){
8041      var bb = ttSel.node().getBoundingClientRect()
8042      var left = Math.max(4, Math.min(ev.clientX + 12, innerWidth - bb.width - 4))
8043      var top = Math.max(4, ev.clientY - bb.height - 8)
8044      ttSel.st({left, top})
8045    }
8046    function hideTip(){ ttSel.classed('tooltip-hidden', 1).classed('si-tip', 0) }
8047
8048    buildGrid(); buildCol2(); buildCol3(); updateSel()
8049  }
8050}
8051
8052window.init?.()
8053
8054// ─── public/slice-stack/init-slice-stack.js ───
8055window.initSliceStack = async function(opts){
8056  var sel = d3.select(opts?.sel || '.slice-stack').html('')
8057  var base = (opts?.datapath || './').replace(/\/?$/, '/')
8058  var ttSel = d3.select('.tooltip')
8059  if (!ttSel.size()) ttSel = d3.select('body').append('div.tooltip.tooltip-hidden')
8060
8061  var K = 10
8062  var PALETTE = (util.lineColors || d3.schemeCategory10).filter(c => c.length === 7)
8063
8064  // Default-pinned tokens per card — the "interesting tokens" called out in the
8065  // article's slice/lens captions (exact data strings incl. ⍽/↑ markers).
8066  // Filtered against rankFiles at use time so a re-bake can't break a card.
8067  var DEFAULT_PINS = {
8068    mutate_iter: ['Error⍽', '⍽TypeError⍽', '⍽doubled⍽', '⍽dict⍽'],
8069    ascii_face: ['⍽face⍽', '⍽smile⍽', '⍽eyes⍽', '⍽emoji⍽'],
8070    avgfp_raw: ['⍽fluor', '⍽protein⍽', '⍽colors⍽', '⍽green'],
8071    mars: ['↑⍽mars⍽', '⍽red⍽', '⍽color⍽', '⍽planet⍽'],
8072    calc_ooo: ['21', '42', '49', '⍽answer⍽'],
8073    count_five: ['↑⍽one⍽', '⍽counting⍽', '↑⍽sure⍽', '⍽five⍽'],
8074    count_introspect: ['↑⍽claude⍽', '↑⍽done⍽', '⍽consciousness⍽', '⍽thoughts⍽', '⍽halfway⍽', '⍽AI⍽', '⍽counting⍽'],
8075    introspection: ['⍽thinking⍽', '⍽okay⍽', '↑⍽basketball⍽', '⍽sport⍽'],
8076  }
8077
8078  var manifest = await util.getFile(base + 'manifest.json')
8079  var slugs = manifest.slugs || []
8080  if (opts?.slugs){
8081    var want = (typeof opts.slugs == 'string' ? opts.slugs.split(',') : opts.slugs).map(s => s.trim())
8082    slugs = want.map(w => slugs.find(s => s.slug === w) || {slug: w, title: w})
8083  }
8084  var slices = await Promise.all(slugs.map(s => util.loadLensSlice(base, s.slug)))
8085
8086  var state = opts?.state || {}
8087  state.cards ??= {}
8088
8089  slices.forEach((d, i) => renderCard(sel.append('div.scard').at({id: 'slice-' + d.slug}), d, slugs[i]))
8090
8091  // links from the article embeds land here with ?slug= — scroll that card into view
8092  // (deliberate, post-render; per-card init never scrolls the page)
8093  var focus = opts?.focusSlug
8094  if (focus) sel.select('#slice-' + focus).node()?.scrollIntoView()
8095
8096  // ctx indices hidden in the prompt display: only the leading blank tokens
8097  // before "Human:" — the Human:/Assistant: role markers (and the blank line
8098  // before "Assistant:") stay visible
8099  function wrapperHidden(toks){
8100    var hide = new Set()
8101    for (var i = 0; i < toks.length && !toks[i].trim(); i++) hide.add(i)
8102    return hide
8103  }
8104
8105  // every interaction below closes over this card's own cs/d/cols, so
8106  // selection and pins in one card never touch another
8107  function renderCard(card, d, meta){
8108    var cs = state.cards[d.slug] ??= {ctx: null, layer: null, pinned: null, hoverStr: null}
8109    cs.pinned ??= new Map()
8110    cs.ctx = d3.clamp(0, cs.ctx ?? d.default_ctx ?? d.nCtx - 1, d.nCtx - 1)
8111    cs.layer = d.layers.includes(cs.layer) ? cs.layer : d.layers[Math.floor(d.layers.length * 2/3)]
8112    if (!cs.pinned.size){
8113      var defs = d.default_pinned?.length ? d.default_pinned
8114        : (DEFAULT_PINS[d.slug] || []).filter(s => d.rankFiles?.[s] != null)
8115      defs.forEach((s, i) => cs.pinned.set(s, PALETTE[i % PALETTE.length]))
8116    }
8117    // last resort: pin the late-layer argmax at the default position so the
8118    // rank heatmap + charts have content before any interaction
8119    if (!cs.pinned.size){
8120      var auto = d.argmax(cs.ctx, d.layers.at(-1))
8121      if (auto != null) cs.pinned.set(auto, PALETTE[0])
8122    }
8123
8124    // ───── header: title top-left, then prompt (snug, ~1/3) + pinned chips/help ─────
8125    if (slugs.length > 1) card.append('div.stitle').text(meta?.title || d.title || d.slug)
8126    var head = card.append('div.shead')
8127    var promptToks = util.appendPromptPara(head.append('div.sprompt'), d.ctxTokens)
8128      .on('click', (e, i) => setCtx(i))
8129    shiftScrub(promptToks, (e, i) => setCtx(i))
8130    var hideTok = wrapperHidden(d.ctxTokens)
8131    promptToks.classed('wrap-hidden', i => hideTok.has(i))
8132    var side = head.append('div.sside')
8133    side.append('div.pinned-row')
8134    side.append('div.shelp').text('click tokens to pin · shift+hover to scrub')
8135
8136    var row = card.append('div.srow')
8137    var col1 = row.append('div.scol.scol-grid')
8138    var col2 = row.append('div.scol.scol-layer')
8139    var col3 = row.append('div.scol.scol-pos')
8140
8141    // line charts share the heatmap's plotting height so the columns align
8142    var chartH = d.layers.length * 11 + 32
8143    card.st({'--chart-h': chartH + 'px'})
8144
8145    var gridCells, ctxTokCells, hmC, rcLens, hmPill
8146
8147    // normalize rank base so display rank 1 = argmax regardless of whether the
8148    // payload writes the top row as rank 0 or rank 1 (rankOf assumes 0-based)
8149    var rankBase = d.cell(cs.ctx, d.layers[0])[0]?.rank ?? 0
8150    var dispRank = r => r + 1 - rankBase
8151    var rankAt = (ctx, L, str) => {
8152      var v = d.rankOf(ctx, L, str)
8153      return v == null ? null : Math.max(1, v - rankBase)
8154    }
8155
8156    function setCtx(c){ if (c != null && c !== cs.ctx){ cs.ctx = c; render() } }
8157    function setLayer(L){ if (L != null && L !== cs.layer){ cs.layer = L; render() } }
8158    function shiftScrub(s, fn){ return s.on('mouseenter.scrub', (e, x) => { if (e.shiftKey) fn(e, x) }) }
8159
8160    function togglePin(str){
8161      if (str == null) return
8162      if (cs.pinned.has(str)) cs.pinned.delete(str)
8163      else {
8164        var used = new Set(cs.pinned.values())
8165        cs.pinned.set(str, PALETTE.find(c => !used.has(c)) || PALETTE[cs.pinned.size % PALETTE.length])
8166        cs.hoverStr = str
8167        d.loadRanks(str).then(renderPinned, () => {})
8168      }
8169      renderPinned()
8170    }
8171    function bindTopk(chips){ chips.on('click', (e, it) => { e.stopPropagation(); togglePin(it?.str) }) }
8172
8173    // ───── col1: argmax grid + pinned chips + rank heatmap ─────
8174    function buildGrid(){
8175      col1.append('div.col-title.mlabel').text('argmax · layer × pos')
8176      var table = col1.append('div.grid-wrap').append('table.argmax')
8177      var rows = table.append('tbody').appendMany('tr', [...d.layers].reverse())
8178      rows.append('td.layer-label').text(util.layerLabel)
8179      rows.each(function(L){
8180        var cells = d3.select(this).appendMany('td.cell', d3.range(d.nCtx).map(c => ({L, c})))
8181          .text(p => util.ppCell(d.argmax(p.c, p.L)))
8182          .on('click', (e, p) => { setCtx(p.c); setLayer(p.L); togglePin(d.argmax(p.c, p.L)) })
8183          .on('mouseenter', showTip).on('mousemove', moveTip).on('mouseleave', hideTip)
8184        shiftScrub(cells, (e, p) => { setCtx(p.c); setLayer(p.L) })
8185      })
8186      var ftok = table.append('tfoot').append('tr')
8187      ftok.append('td')
8188      ctxTokCells = ftok.appendMany('td.ctx-tok', d3.range(d.nCtx))
8189        .text(c => util.ppCell(d.ctxTokens[c])).at({title: c => util.ppCell(d.ctxTokens[c])})
8190        .on('click', (e, c) => setCtx(c))
8191      shiftScrub(ctxTokCells, (e, c) => setCtx(c))
8192      gridCells = table.selectAll('td.cell')
8193
8194      if (d.rankFiles){
8195        var hm = col1.append('div.grid-bottom').append('div.rank-
8195heatmap')
8196        // single-line legend, flush with the heatmap's right edge:
8197        // [active-token pill]  pinned token rank  1 ▒▒▒▒ 1k+
8198        rcLens = d3.scaleSequential(d3.interpolateViridis).domain([3, 0])  // log10 rank, capped at 1k
8199        var hmHead = hm.append('div.hm-head')
8200        hmPill = hmHead.append('span.pinned-chip.hm-pill')
8201        hmHead.append('span.hm-lab').text('pinned token rank')
8202        hmHead.append('span.hm-end').text('1')
8203        hmHead.append('span.hm-grad').st({background: 'linear-gradient(to right, '
8204          + d3.range(11).map(i => rcLens(i / 10 * 3) + ' ' + i * 10 + '%').join(',') + ')'})
8205        hmHead.append('span.hm-end').text('1k+')
8206        hm.append('div.hm-chart')
8207      }
8208    }
8209
8210    function buildPinnedRow(){
8211      var pr = card.select('.pinned-row').html('')
8212      var chips = pr.appendMany('span.pinned-chip', [...cs.pinned])
8213        .st({borderColor: ([,c]) => c})
8214        .classed('active', ([s]) => s === cs.hoverStr)
8215        .on('mouseenter', (e, [s]) => { cs.hoverStr = s; renderHover() })
8216      chips.append('span.tok').text(([s]) => util.ppCell(s))
8217      chips.append('span.unpin').text('×').on('click', (e, [s]) => { e.stopPropagation(); togglePin(s) })
8218    }
8219
8220    function drawRankHeatmap(){
8221      if (!d.rankFiles) return
8222      var active = cs.hoverStr && cs.pinned.has(cs.hoverStr) ? cs.hoverStr : [...cs.pinned.keys()][0]
8223      // one row per layer present in the payload; taller rows so each layer
8224      // reads as a solid band
8225      var nL = d.layers.length
8226      if (!hmC){
8227        hmC = d3.conventions({sel: col1.select('.hm-chart').html('').append('div'), height: nL * 11, margin: {top: 4, right: 8, bottom: 20, left: 28}, layers: 'scs'})
8228        hmC.x.domain([0, d.nCtx - 1])
8229        hmC.y.domain([-0.5, nL - 0.5])  // index space: row i centered at y(i)
8230        hmC.xAxis.ticks(5)
8231        // round-number layer ticks (5, 10, 15, 20…) placed at their row position
8232        var idxOf = L => (L - d.layers[0]) / (d.layers.at(-1) - d.layers[0]) * (nL - 1)
8233        var tickLayers = d3.ticks(d.layers[0], d.layers.at(-1), 4)
8234        hmC.yAxis = d3.axisLeft(hmC.y).tickValues(tickLayers.map(idxOf)).tickFormat((v, i) => util.layerLabel(tickLayers[i]))
8235        hmC.drawAxis(); util.addAxisLabel(hmC, 'Pos →', 'Layer (reindexed) →')
8236        hmC.dot = hmC.layers[2].append('circle.sel-dot').at({r: 3.5, fill: '#fff', stroke: '#000', strokeWidth: 1.5})
8237        var goTo = e => {
8238          var [px, py] = d3.pointer(e)
8239          setCtx(d3.clamp(0, Math.round(hmC.x.invert(px)), d.nCtx - 1))
8240          setLayer(d.layers[d3.clamp(0, Math.round(hmC.y.invert(py)), nL - 1)])
8241        }
8242        hmC.layers[2].append('rect').at({width: hmC.width, height: hmC.height, fill: 'transparent', cursor: 'pointer'})
8243          .on('click', goTo).on('mousemove', e => { if (e.shiftKey) goTo(e) })
8244        hmC.dot.raise()
8245      }
8246      var ctx = hmC.layers[1]
8247      var cw = hmC.width / d.nCtx, ch = hmC.height / nL
8248      ctx.fillStyle = '#f5f5f5'; ctx.fillRect(0, 0, hmC.width, hmC.height)
8249      if (active) d.layers.forEach((L, li) => {
8250        for (var i = 0; i < d.nCtx; i++){
8251          var r = rankAt(i, L, active)
8252          if (!r) continue
8253          ctx.fillStyle = rcLens(Math.log10(r))
8254          ctx.fillRect(i*cw, hmC.height - (li+1)*ch, cw + .5, ch + .5)
8255        }
8256      })
8257      hmPill.text(active ? util.ppCell(active) : '—')
8258        .st({borderColor: active ? cs.pinned.get(active) : 'var(--rule)'})
8259      updateHeatmapDot()
8260    }
8261    function updateHeatmapDot(){
8262      if (!hmC) return
8263      var cw = hmC.width / d.nCtx, ch = hmC.height / d.layers.length
8264      var li = d.layers.indexOf(cs.layer)
8265      hmC.dot.at({cx: (cs.ctx + 0.5) * cw, cy: hmC.height - (li + 0.5) * ch})
8266    }
8267
8268    // ───── col2/col3 are built once; render() repopulates the lens-topk spans
8269    //       and reclasses .sel so row containers never get .html('')-ed (which
8270    //       was the source of stale highlights and perceived scroll-jumps) ─────
8271    var col2Title, col2Rows, col3Title, col3Rows, lastCtx = null, lastLayer = null
8272
8273    function buildCol2(){
8274      col2Title = col2.append('div.col-title.mlabel')
8275      col2Rows = col2.append('div.rows-wrap').appendMany('div.lyr-row', [...d.layers].reverse())
8276        .on('click', (e, L) => setLayer(L))
8277      shiftScrub(col2Rows, (e, L) => setLayer(L))
8278      col2Rows.append('span.row-label').text(util.layerLabel)
8279      col2Rows.append('span.lens-topk')
8280      col2.append('div.rank-chart')
8281    }
8282
8283    function buildCol3(){
8284      col3.st({'--label-ch': String(d.nCtx - 1).length + 'ch', '--vis-rows': d.layers.length})
8285      col3Title = col3.append('div.col-title.mlabel')
8286      col3Rows = col3.append('div.rows-wrap').appendMany('div.pos-row', d3.range(d.nCtx))
8287        .on('click', (e, c) => setCtx(c))
8288      shiftScrub(col3Rows, (e, c) => setCtx(c))
8289      col3Rows.append('span.row-label').text(c => c)
8290      col3Rows.append('span.row-ctx').text(c => util.ppCell(d.ctxTokens[c])).at({title: c => util.ppCell(d.ctxTokens[c])})
8291      col3Rows.append('span.lens-topk')
8292      col3.append('div.rank-chart')
8293    }
8294
8295    function drawRankChart(colSel, xs, xLabel, getRank, scrub, withLine, xFmt){
8296      var rc = colSel.select('.rank-chart').html('')
8297      var c = d3.conventions({sel: rc.append('div'), height: chartH - 32, margin: {top: 8, right: 8, bottom: 20, left: 28}, layers: 'scs'})
8298      c.x.domain(d3.extent(xs))
8299      c.y = d3.scaleLog().domain([1000, 1]).range([c.height, 0]).clamp(true)
8300      c.xAxis.ticks(5); if (xFmt) c.xAxis.tickFormat(xFmt)
8301      c.yAxis = d3.axisLeft(c.y).tickValues([1, 10, 100, 1000]).tickFormat(v => v >= 1000 ? '1k+' : v)
8302      c.drawAxis(); util.ggPlot(c)
8303      util.addAxisLabel(c, xLabel, 'Rank →')
8304      var ctx = c.layers[1]
8305      for (var [str, color] of cs.pinned){
8306        ctx.strokeStyle = ctx.fillStyle = color
8307        if (withLine){
8308          ctx.lineWidth = 1.5; ctx.beginPath()
8309          var prev = null
8310          for (var x of xs){
8311            var r = getRank(x, str)
8312            if (r == null){ prev = null; continue }
8313            prev ? ctx.lineTo(c.x(x), c.y(r)) : ctx.moveTo(c.x(x), c.y(r))
8314            prev = 1
8315          }
8316          ctx.stroke()
8317        }
8318        for (var x of xs){
8319          var r = getRank(x, str)
8320          if (r == null) continue
8321          ctx.beginPath(); ctx.arc(c.x(x), c.y(r), 1.5, 0, Math.PI*2); ctx.fill()
8322        }
8323      }
8324      var top = c.layers[2]
8325      var scrubX = top.append('line.scrub').at({y1: 0, y2: c.height, stroke: '#000', strokeWidth: .5, strokeDasharray: '2 2'})
8326      rc.node().__updateScrub = v => scrubX.at({x1: c.x(v), x2: c.x(v)})
8327      rc.node().__updateScrub(scrub())
8328      var goTo = e => scrub(d3.clamp(xs[0], Math.round(c.x.invert(d3.pointer(e)[0])), xs.at(-1)))
8329      top.append('rect').at({width: c.width, height: c.height, fill: 'transparent', cursor: 'pointer'})
8330        .on('click', goTo).on('mousemove', e => { if (e.shiftKey) goTo(e) })
8331    }
8332    function nearestLayer(v){ return d.layers.reduce((a, b) => Math.abs(b - v) < Math.abs(a - v) ? b : a) }
8333    function drawCol2Chart(){ drawRankChart(col2, d.layers, 'Layer (reindexed) →', (L,s) => rankAt(cs.ctx, L, s), v => { if (v != null) setLayer(nearestLayer(v)); return cs.layer }, true, util.layerLabel) }
8334    // contexts are short, so the rank-vs-pos chart reads fine as a line (matches rank-vs-layer)
8335    function drawCol3Chart(){ drawRankChart(col3, d3.range(d.nCtx), 'Pos →', (c,s) => rankAt(c, cs.layer, s), v => { if (v != null) setCtx(v); return cs.ctx }, true) }
8336
8337    function restylePinned(scope){
8338      scope.selectAll('.lens-topk .lenstk').each(function(){
8339        var it = d3.select(this).datum()
8340        d3.select(this).at({style: util.pinnedStyle(it && cs.pinned.get(it.str))})
8341      })
8342    }
8343
8344    // single render of everything that depends on {ctx, layer}. Row content is
8345    // only repopulated when the relevant axis actually changed; selection
8346    // classes, prompt highlight, heatmap dot and scrub lines are always
8347    // refreshed. Container scroll only ever touches the card's own scroll
8348    // containers — never the page.
8349    function render(){
8350      if (cs.ctx !== lastCtx){
8351        col2Title.text(`By layer · pos ${cs.ctx} ${util.ppCell(d.ctxTokens[cs.ctx])}`)
8352        col2Rows.each(function(L){
8353          bindTopk(util.appendLensTopk(d3.select(this).select('.lens-topk').html(''), d.cell(cs.ctx, L), K))
8354        })
8355        drawCol2Chart(); restylePinned(col2)
8356      }
8357      if (cs.layer !== lastLayer){
8358        col3Title.text(`By pos · L${util.layerLabel(cs.layer)}`)
8359        col3Rows.each(function(c){
8360          bindTopk(util.appendLensTopk(d3.select(this).select('.lens-topk').html(''), d.cell(c, cs.layer), K))
8361        })
8362        drawCol3Chart(); restylePinned(col3)
8363      }
8364      col2Rows.classed('sel', L => L === cs.layer)
8365      col3Rows.classed('sel', c => c === cs.ctx)
8366      gridCells.classed('sel', p => p.L === cs.layer && p.c === cs.ctx)
8367      ctxTokCells.classed('sel', c => c === cs.ctx)
8368      promptToks.classed('tok-hit', i => i === cs.ctx)
8369      updateHeatmapDot()
8370      col2.select('.rank-chart').node()?.__updateScrub?.(cs.layer)
8371      col3.select('.rank-chart').node()?.__updateScrub?.(cs.ctx)
8372      // bring the selected cell/row into view only if it's outside the
8373      // container's visible range — shift-hover scrubs an already-visible row
8374      // so this is a no-op there, and the page itself never scrolls
8375      var wrap = col1.select('.grid-wrap').node(), cell = ctxTokCells.nodes()[cs.ctx]
8376      if (wrap && cell){
8377        var cl = cell.offsetLeft, cr = cl + cell.offsetWidth
8378        if (cl < wrap.scrollLeft) wrap.scrollLeft = cl
8379        else if (cr > wrap.scrollLeft + wrap.clientWidth) wrap.scrollLeft = cr - wrap.clientWidth
8380      }
8381      var rw = col3.select('.rows-wrap').node(), prow = rw?.children[cs.ctx]
8382      if (rw && prow){
8383        var rt = prow.offsetTop, rb = rt + prow.offsetHeight
8384        if (rt < rw.scrollTop) rw.scrollTop = rt
8385        else if (rb > rw.scrollTop + rw.clientHeight) rw.scrollTop = rb - rw.clientHeight
8386      }
8387      lastCtx = cs.ctx; lastLayer = cs.layer
8388    }
8389    function renderPinned(){
8390      buildPinnedRow()
8391      restylePinned(card)
8392      gridCells.at({style: p => util.pinnedStyle(cs.pinned.get(d.argmax(p.c, p.L)))})
8393      drawRankHeatmap(); drawCol2Chart(); drawCol3Chart()
8394    }
8395    function renderHover(){
8396      card.select('.pinned-row').selectAll('.pinned-chip').classed('active', ([s]) => s === cs.hoverStr)
8397      drawRankHeatmap()
8398    }
8399
8400    function showTip(ev, p){
8401      moveTip(ev)
8402      ttSel.classed('tooltip-hidden', 0).html('').classed('ss-tip', 1)
8403      ttSel.append('div.tt-hdr').text(`L${util.layerLabel(p.L)} · pos ${p.c} ${util.ppCell(d.ctxTokens[p.c])}`)
8404      var tbl = ttSel.append('table')
8405      d.cell(p.c, p.L).forEach(r => {
8406        var tr = tbl.append('tr').at({style: util.pinnedStyle(cs.pinned.get(r.str))})
8407        tr.append('td.r').text(util.ppRank(dispRank(r.rank)))
8408        tr.append('td.t').text(util.ppCell(r.str))
8409        tr.append('td.p').text(Math.round(r.prob * 100) + '%')
8410      })
8411    }
8412    function moveTip(ev){
8413      var bb = ttSel.node().getBoundingClientRect()
8414      var left = Math.max(4, Math.min(ev.clientX + 12, innerWidth - bb.width - 4))
8415      var top = Math.max(4, ev.clientY - bb.height - 8)
8416      ttSel.st({left, top})
8417    }
8418    function hideTip(){ ttSel.classed('tooltip-hidden', 1).classed('ss-tip', 0) }
8419
8420    buildGrid()
8421    buildCol2(); buildCol3()
8422    renderPinned()
8423    render()
8424    if (cs.pinned.size) Promise.all([...cs.pinned.keys()].map(s => d.loadRanks(s))).then(renderPinned, () => {})
8425
8426    // re-render charts/heatmap at the new column width on resize
8427    var rw0 = row.node().offsetWidth
8428    new ResizeObserver(util.throttleDebounce(() => {
8429      var w = row.node().offsetWidth
8430      if (w === rw0) return
8431      rw0 = w; hmC = null; lastCtx = lastLayer = null
8432      drawRankHeatmap(); render()
8433    }, 150)).observe(row.node())
8434  }
8435}
8436
8437window.init?.()
8438
8439// ─── public/metacog-alarm/init-metacog-alarm.js ───
8440!function(){
8441
8442var COLORS = {
8443  base: {think: util.tint(util.gray[600], 0.5), dont_think: util.gray[600]},
8444  prod: {think: util.tint(util.tol.blue, 0.5),  dont_think: util.tol.blue},
8445}
8446var cap = s => s[0].toUpperCase() + s.slice(1)
8447
8448function drawExample(sel, d){
8449  var ex = d.example
8450  var card = sel.append('div.ex-col')
8451  card.append('div.mlabel').text(`example: don’t think about the ${ex.concept}`)
8452
8453  var prompt = card.append('div.prompt.prompt-block')
8454  prompt.append('span.prompt-role').text('Human: ')
8455  prompt.append('span').text(ex.human_text)
8456  var resp = prompt.append('div.resp-line')
8457  resp.append('span.prompt-role').text('Assistant: ')
8458  var hl = new Set(ex.cells.map(c => c.sent_idx))
8459  ex.sent_toks.forEach((t, i) => {
8460    if (!ex.sent_glue[i] && i) resp.append('span').text(' ')
8461    resp.append('span').text(t).classed('tok-hit', hl.has(i)).classed('resp', true)
8462  })
8463
8464  var grid = card.append('div.exgrid')
8465  ex.cells.forEach(c => {
8466    var cell = grid.append('div.cell')
8467    var head = cell.append('div.chead')
8468    head.append('span.swatch').st({background: COLORS[c.side].dont_think})
8469    head.append('span').text(`${cap(c.side_label)} — '${c.pos_tok}' token, L${c.layer_lbl}`)
8470    var maxP = d3.max(c.entries, e => Math.exp(e.lp))
8471    c.entries.forEach(e => {
8472      var row = cell.append('div.entry')
8473      row.append('div.bar')
8474        .st({width: (Math.exp(e.lp)/maxP*100).toFixed(1) + '%',
8475             background: COLORS[c.side].dont_think})
8476      row.append('span.tok').text(e.tok)
8477      row.append('span.lp').text(e.lp.toFixed(2))
8478    })
8479  })
8480}
8481
8482function drawBars(sel, d){
8483  var col = sel.append('div.bars-col')
8484  var lab = col.append('div.mlabel')
8485    .text(`concept word, fail, and damn in the readout, ${d.n} concepts`)
8486
8487  // size the chart so the right column matches the example column's height
8488  var margin = {left: 64, right: 8, top: 46, bottom: 30}
8489  var exH = sel.select('.ex-col').node()?.offsetHeight || 0
8490  var labH = lab.node()?.offsetHeight || 0
8491  var chartH = Math.max(260, exH - labH - margin.top - margin.bottom)
8492  var c = d3.conventions({
8493    sel: col.append('div'),
8494    width: 380, height: chartH,
8495    margin, layers: 's',
8496  })
8497  var nF = d.fams.length
8498  var x0 = d3.scaleBand().domain(d3.range(nF)).range([0, c.width])
8499    .paddingInner(0.3).paddingOuter(0.1)
8500  var x1 = d3.scaleBand().domain(d3.range(d.series.length))
8501    .range([0, x0.bandwidth()]).paddingInner(0.1)
8502  c.y.domain([0, 1.12])
8503  c.yAxis.ticks(5)
8504  c.drawAxis()
8505  c.svg.select('.x').remove()
8506  c.svg.select('.y .domain').remove()
8507  // left + bottom spines (matplotlib style)
8508  c.svg.append('path.spine').at({
8509    d: `M0,0 V${c.height} H${c.width}`, stroke: '#888', strokeWidth: 0.7, fill: 'none',
8510  })
8511  // two-line rotated y label
8512  var ylab = c.svg.select('.y').append('g').translate([-44, c.height/2])
8513    .append('text.axis-label').at({textAnchor: 'middle', transform: 'rotate(-90)'})
8514  ylab.append('tspan').text(`fraction of concepts (n=${d.n})`).at({x: 0, dy: '-0.4em'})
8515  ylab.append('tspan')
8516    .text(`lens top-${d.top_n}, any copied token, ${d.band_lbl}`)
8517    .at({x: 0, dy: '1.2em'})
8518
8519  c.svg.append('g.x-ticks').translate([0, c.height + 16])
8520    .appendMany('text.tick-label', d3.range(nF))
8521    .translate(i => [x0(i) + x0.bandwidth()/2, 0])
8522    .at({textAnchor: 'middle'})
8523    .text(i => d.fams[i].label)
8524
8525  var g = c.svg.appendMany('g.fam', d3.range(nF)).translate(i => [x0(i), 0])
8526  d.series.forEach((s, j) => {
8527    var fill = COLORS[s.side][s.cond]
8528    g.append('rect.bar').at({
8529      x: x1(j), width: x1.bandwidth(), fill,
8530      y: i => c.y(s.vals[i].v), height: i => c.height - c.y(s.vals[i].v),
8531    })
8532    var exx = x1(j) + x1.bandwidth()/2, cap = Math.min(2.5, x1.bandwidth()*0.3)
8533    g.append('path.err').at({d: i =>
8534      `M${exx},${c.y(s.vals[i].lo)} V${c.y(s.vals[i].hi)}` +
8535      ` M${exx-cap},${c.y(s.vals[i].lo)} h${2*cap}` +
8536      ` M${exx-cap},${c.y(s.vals[i].hi)} h${2*cap}`,
8537    })
8538    g.append('text.vlabel')
8539      .at({x: exx, textAnchor: 'middle', y: i => c.y(s.vals[i].hi) - 4})
8540      .text(i => {
8541        var v = s.vals[i].v
8542        return v == 0 ? '0' : v == 1 ? '1' : d3.format('.2f')(v).replace(/0$/, '').replace(/^0/, '')
8543      })
8544  })
8545
8546  var leg = c.svg.append('g.legend').translate([0, -36])
8547  var pos = [[0, 0], [0, 16], [190, 0], [190, 16]]
8548  d.series.forEach((s, j) => {
8549    var item = leg.append('g').translate(pos[j])
8550    item.append('rect').at({width: 10, height: 10, y: -9, rx: 2, fill: COLORS[s.side][s.cond]})
8551    item.append('text').text(cap(s.label)).at({x: 15})
8552  })
8553}
8554
8555window.initMetacogAlarm = async function(opts){
8556  var sel = d3.select('.metacog-alarm').html('')
8557  var base = (opts?.datapath || './').replace(/\/?$/, '/')
8558  var d = await util.getFile(base + 'data.json')
8559  var row = sel.append('div.panel-row')
8560  drawExample(row, d)
8561  drawBars(row, d)
8562}
8563
8564}()
8565window.init?.()
8566
8567// ─── public/ignition/init-ignition.js ───
8568window.initIgnition = async function ({datapath, sel: selStr, show}
8568) {
8569  var sel = d3.select(selStr || '.ignition').html('')
8570  show = show || 'abc'
8571  var d = await util.getFile(datapath + 'data.json')
8572
8573  var n = d.n_pub, [wlo, whi] = d.ws_band
8574  var ORANGE = util.tol.orange, DARK = util.gray[700]
8575  var rdbu = t => d3.interpolateRdBu(1 - t)            // RdBu_r: 0→blue(B) 1→red(A)
8576  var greys = t => d3.interpolateGreys(1 - t)          // Greys_r: 0→black(rank 1) 4→white
8577
8578  // ───── row 1: A | B+C ─────
8579  if (show === 'abc'){
8580    var row1 = sel.append('div.row')
8581    var a = row1.append('div.panel.schematic')
8582    a.append('div.plabel').html('A &nbsp; Mixing two tokens’ embeddings')
8583    a.append('div').html(d.schematic_html)
8584
8585    var bc = row1.append('div.panel.bc')
8586    var bcLabels = bc.append('div.bc-labels')
8587    bcLabels.append('div.plabel.lab-b')
8588      .html('B &nbsp; Activations given interpolated inputs')
8589    bcLabels.append('div.plabel.lab-c')
8590      .html('C &nbsp; J-lens behavior given interpolated inputs')
8591    var bcRow = bc.append('div.hm-row')
8592
8593    var HMS = [
8594      {key: 'proj',    title: 'Relative similarity to activations\ninduced by pure token embedding',
8595       vmax: 1, color: rdbu, cbar: {ticks: [0, 1], labels: ['B', 'A']}},
8596      {key: 'logrank', title: 'J-lens rank of the\nhigher ranked concept',
8597       vmax: 4, color: greys,
8598       cbar: {ticks: [0, 1, 2, 3, 4], labels: ['1', '10', '100', '1k', '10k'], invert: true}},
8599      {key: 'jspan',   title: 'Relative similarity to J-lens\nvectors for concepts A and B',
8600       vmax: 1, color: rdbu, cbar: null},
8601    ]
8602    HMS.forEach((h, hi) => drawHeatmap(bcRow.append('div.hm-cell'), h, hi))
8603  }
8604
8605  // ───── D: transition width (own figure) ─────
8606  if (show === 'd'){
8607    var dSel = sel.append('div.panel.panel-d')
8608    dSel.append('div.plabel').text('Transition width')
8609    drawWidths(dSel.append('div'))
8610  }
8611
8612  // ───── E: ambiguous-input histograms (own figure) ─────
8613  if (show === 'e'){
8614    var eSel = sel.append('div.panel.panel-e')
8615    eSel.append('div.plabel').text(
8616      'Relative share in activations of concept A at maximally ambiguous ' +
8617      'interpolation coefficient, across prompts')
8618    drawHistRow(eSel.append('div.hist-row'))
8619  }
8620
8621  // ───── panel B/C: one heatmap (canvas) + svg axes + optional colorbar ─────
8622  function drawHeatmap(host, h, hi){
8623    var W = 192, H = 244, cbW = h.cbar ? 30 : 14
8624    var m = {left: hi ? 8 : 36, right: cbW, top: 30, bottom: 36}
8625    var c = d3.conventions({sel: host, width: W, height: H, margin: m, layers: 'cs'})
8626    var ctx = c.layers[0], svg = c.layers[1]
8627    var na = d.d_grid.length, cw = W / na, ch = H / n
8628    var grid = d.heatmaps[h.key]
8629    for (var li = 0; li < n; li++) for (var ai = 0; ai < na; ai++){
8630      ctx.fillStyle = h.color(grid[li][ai] / h.vmax)
8631      ctx.fillRect(ai * cw, H - (li + 1) * ch, cw + 0.5, ch + 0.5)
8632    }
8633    c.x.domain([d.d_grid[0], d.d_grid[na - 1]])
8634    c.y.domain([-0.5, n - 0.5])
8635    c.xAxis.tickValues([-0.5, 0, 0.5])
8636      .tickFormat(v => v < 0 ? 'Pure B' : v > 0 ? 'Pure A' : 'Ambiguous')
8637      .tickSizeOuter(0).tickSizeInner(3)
8638    c.yAxis.tickValues(util.layerTicks(n)).tickFormat(util.layerLabel)
8639      .tickSizeOuter(0).tickSizeInner(3)
8640    c.drawAxis()
8641    c.svg.select('.x').selectAll('text').st({fontSize: '9px'})
8642    if (hi) c.svg.select('.y').selectAll('text').remove()
8643    util.addAxisLabel(c, 'input evidence', hi ? '' : 'Layer (reindexed) →')
8644    // ws-band guides — dashed horizontals at the band edges
8645    ;[wlo - 0.5, whi + 0.5].forEach(yy => svg.append('line.ws-guide')
8646      .at({x1: 0, x2: W, y1: c.y(yy), y2: c.y(yy)}))
8647    // two-line title
8648    var t = svg.append('text.hm-title').at({x: W / 2, textAnchor: 'middle'})
8649    h.title.split('\n').forEach((s, i) =>
8650      t.append('tspan').text(s).at({x: W / 2, y: -18 + i * 11}))
8651    // colorbar
8652    if (h.cbar){
8653      var cb = svg.append('g.cbar').translate([W + 6, 0]), bw = 9, nseg = 60
8654      cb.appendMany('rect', d3.range(nseg)).at({
8655        y: i => (h.cbar.invert ? i : nseg - 1 - i) * H / nseg,
8656        width: bw, height: H / nseg + 0.5, fill: i => h.color(i / (nseg - 1)),
8657      })
8658      cb.appendMany('text', h.cbar.ticks).text((_, i) => h.cbar.labels[i])
8659        .at({x: bw + 3, dy: '.35em',
8660             y: v => (h.cbar.invert ? v / h.vmax : 1 - v / h.vmax) * H})
8661    }
8662  }
8663
8664  // ───── panel D: median ± IQR transition width by pub-layer ─────
8665  function drawWidths(host){
8666    var c = d3.conventions({
8667      sel: host, width: 420, height: 260,
8668      margin: {left: 46, right: 6, top: 8, bottom: 32},
8669    })
8670    c.x.domain([-0.5, n - 0.5])
8671    c.y = d3.scaleLog().domain([0.018, 1.2]).range([c.height, 0])
8672    c.xAxis.tickValues(util.layerTicks(n)).tickFormat(util.layerLabel)
8673    c.yAxis.scale(c.y).tickValues([0.03, 0.1, 0.3, 1.0]).tickFormat(v => v)
8674    c.drawAxis(); util.ggPlot(c)
8675    util.addAxisLabel(c, 'Layer (reindexed) →', 'transition width Δα (10→90%)', '', 0, -4)
8676    util.addWsBand(c, wlo, whi, {label: false})
8677    var xs = d3.range(n)
8678    var line = d3.line().x((_, i) => c.x(xs[i])).y(v => c.y(v))
8679    var area = d3.area().x((_, i) => c.x(xs[i]))
8680      .y0(p => c.y(p.lo)).y1(p => c.y(p.hi))
8681    var SER = [
8682      {k: 'j',  color: ORANGE, label: ['Projections onto concept', 'word J-lens vectors']},
8683      {k: 'nj', color: DARK,   label: ['Non-J-space component', 'of activations']},
8684    ]
8685    SER.forEach(s => {
8686      var w = d.widths[s.k]
8687      c.svg.append('path').at({
8688        d: area(xs.map(i => ({lo: w.q1[i], hi: w.q3[i]}))),
8689        fill: s.color, stroke: 'none',
8690      }).st({opacity: 0.15})
8691    })
8692    SER.forEach(s => {
8693      var w = d.widths[s.k]
8694      c.svg.append('path').at({d: line(w.med), fill: 'none',
8695        stroke: s.color, strokeWidth: 1.6})
8696      c.svg.appendMany('circle', xs).at({
8697        cx: i => c.x(i), cy: i => c.y(w.med[i]), r: 2.2, fill: s.color,
8698      })
8699    })
8700    igLegend(c.svg, [c.width - 2, 4], SER)
8701  }
8702
8703  // ───── panel E: histogram row at four pub layers ─────
8704  function drawHistRow(host){
8705    var bins = d.hist.bin_edges, nb = bins.length - 1
8706    var ymax = d3.max(d.hist.pub_layers, (_, li) =>
8707      d3.max(['j', 'nj'], k => d3.max(d.hist[k][li])))
8708    var SER = [
8709      {k: 'j',  color: ORANGE, label: ['Projections onto concept', 'word J-lens vectors']},
8710      {k: 'nj', color: DARK,   label: ['Non-J-space component', 'of activations']},
8711    ]
8712    d.hist.pub_layers.forEach((pub, li) => {
8713      var c = d3.conventions({
8714        sel: host.append('div'), width: 156, height: 230,
8715        margin: {left: li ? 8 : 38, right: 4, top: 18, bottom: 32},
8716      })
8717      c.x.domain([0, 1]); c.y.domain([0, ymax * 1.08])
8718      c.xAxis.tickValues([0, 0.5, 1]).tickFormat(v => v)
8719      c.yAxis.ticks(4)
8720      c.drawAxis(); util.ggPlot(c)
8721      if (li) c.svg.select('.y').selectAll('text').remove()
8722      c.svg.append('text.hm-title').text('L' + util.layerLabel(pub))
8723        .at({x: c.width / 2, y: -6, textAnchor: 'middle'})
8724      ;[['nj', DARK], ['j', ORANGE]].forEach(([k, col]) => {
8725        var counts = d.hist[k][li]
8726        var p = `M${c.x(bins[0])},${c.y(0)}`
8727        for (var b = 0; b < nb; b++)
8728          p += `V${c.y(counts[b])}H${c.x(bins[b + 1])}`
8729        p += `V${c.y(0)}`
8730        c.svg.append('path').at({d: p, fill: 'none', stroke: col, strokeWidth: 1.4})
8731      })
8732      if (li == 0) util.addAxisLabel(c, '', 'trials', '', 0, 0)
8733      if (li == 1) igLegend(c.svg, [c.width - 2, 6], SER)
8734    })
8735    host.append('div.hist-xlabel')
8736      .text('Relative share of activations across ambiguous inputs')
8737  }
8738
8739  // Shared D/E legend: line swatch on the left, two-line label on the
8740  // right, right-aligned block (matches the matplotlib loc='upper right').
8741  function igLegend(svg, at, ser){
8742    var leg = svg.append('g.ig-legend').translate(at)
8743    ser.forEach((s, i) => {
8744      var g = leg.append('g').translate([0, i * 28])
8745      // text first so we can place the swatch just to its left
8746      var w = 0
8747      s.label.forEach((t, j) => {
8748        var el = g.append('text').text(t)
8749          .at({x: 0, y: j * 12, dy: '.32em', textAnchor: 'end'})
8750        w = Math.max(w, el.node().getComputedTextLength())
8751      })
8752      g.append('line').at({x1: -w - 20, x2: -w - 6, y1: 5, y2: 5,
8753        stroke: s.color, strokeWidth: 2.2})
8754    })
8755  }
8756}
8757
8758if (window.init) window.init()
8759
8760// ─── public/eval-awareness-probe/init-eval-awareness-probe.js ───
8761window.initEvalAwarenessProbe = async function ({datapath}) {
8762  var sel = d3.select('.eval-awareness-probe').html('')
8763  var d = await util.getFile(datapath + 'data.json')
8764  sel.append('img')
8765    .at({src: 'data:image/png;base64,' + d.png})
8766    .st({width: '100%', height: 'auto'})
8767}
8768
8769if (window.init) window.init()
8770
8771// ─── public/dual-task-simple/init-dual-task-simple.js ───
8772!function(){
8773
8774var BLUE = '#2563eb', ORANGE = '#ff6b35', GREY = '#c7cdd4'
8775var TASK_A = '#16a34a', TASK_B = '#7c3aed'
8776
8777function markPhrases(sel, text, pa, pb){
8778  var rest = text
8779  ;
8779[[pa, 'ph-a'], [pb, 'ph-b']].forEach(() => {})
8780  // split on the two phrases, keeping order of first occurrence
8781  var parts = []
8782  var ia = rest.indexOf(pa), ib = rest.indexOf(pb)
8783  var order = ia <= ib ? [[ia, pa, 'ph-a'], [ib, pb, 'ph-b']] : [[ib, pb, 'ph-b'], [ia, pa, 'ph-a']]
8784  var pos = 0
8785  order.forEach(([i, p, cls]) => {
8786    if (i < 0) return
8787    parts.push([rest.slice(pos, i), null])
8788    parts.push([p, cls])
8789    pos = i + p.length
8790  })
8791  parts.push([rest.slice(pos), null])
8792  parts.forEach(([t, cls]) => {
8793    if (!t) return
8794    sel.append('span').text(t).at({class: cls || null})
8795  })
8796}
8797
8798function drawExamples(sel, d){
8799  var col = sel.append('div.panel.ex-panel')
8800  col.append('div.mlabel').text('example: the readout at two response tokens')
8801  d.blocks.forEach(b => {
8802    var blk = col.append('div.exblock')
8803    blk.append('div.exhead').text(b.head)
8804      .st({color: b.key == 'cc' ? BLUE : ORANGE})
8805    var instr = blk.append('div.prompt')
8806    instr.append('span').text(`Write "${d.sentence}" `)
8807    markPhrases(instr, b.instr, b.phrase_a, b.phrase_b)
8808    instr.append('span').text(" Don't write anything else.")
8809    var resp = blk.append('div.resp')
8810    resp.append('span.role').text('A: ')
8811    var hl = new Set(b.cells.map(c => c.pos_tok))
8812    d.sentence.split(/(crookedly|on)/).forEach(seg => {
8813      if (seg == 'crookedly') {
8814        resp.append('span').text('cr')
8815        resp.append('span.hl').text('ook')
8816        resp.append('span').text('edly')
8817      } else if (seg == 'on' && hl.has('on')) {
8818        resp.append('span.hl').text('on')
8819      } else {
8820        resp.append('span').text(seg)
8821      }
8822    })
8823    var lens = blk.append('div.lens-readout')
8824    b.cells.forEach(c => {
8825      var line = lens.append('div')
8826      line.append('span.label').text(`${c.pos_tok} · L${c.layer_lbl}: `)
8827      c.chips.forEach(ch => {
8828        if (ch.cls == 'other') {
8829          line.append('span.tok-g').text(ch.tok)
8830        } else {
8831          var s = line.append('span').at({class: ch.cls == 'a' ? 'tok-a' : 'tok-b'})
8832          s.append('span').text(ch.tok)
8833          s.append('span.tok-rk').text(ch.rank)
8834        }
8835        line.append('span').text(' ')
8836      })
8837    })
8838  })
8839}
8840
8841function drawCoocc(sel, d){
8842  var col = sel.append('div.panel')
8843  col.append('div.mlabel').text('co-occupancy at shared tokens')
8844  var c = d3.conventions({
8845    sel: col.append('div'),
8846    width: 240, height: 290,
8847    margin: {left: 64, right: 6, top: 16, bottom: 52},
8848    layers: 's',
8849  })
8850  var conds = d.coocc.plotted.map(i => d.coocc.conds[i])
8851  var cols = [BLUE, ORANGE]
8852  c.x.domain([-0.6, conds.length - 0.4])
8853  c.y.domain([0, 1])
8854  c.yAxis.ticks(5)
8855  c.drawAxis()
8856  c.svg.select('.x').remove()
8857  c.svg.selectAll('.domain').remove()
8858  c.svg.append('path.spine').at({
8859    d: `M0,0 V${c.height} H${c.width}`, stroke: '#888', strokeWidth: 0.7, fill: 'none'})
8860  var bw = 0.34
8861  conds.forEach((s, gi) => {
8862    var x1 = c.x(gi - (bw/2 + 0.02)), x2 = c.x(gi + (bw/2 + 0.02))
8863    var w = c.x(bw) - c.x(0)
8864    ;[[x1, s.observed, s.ci_lo, s.ci_hi, cols[gi], '#374151'],
8865      [x2, s.null_mean, s.null_lo, s.null_hi, GREY, '#6b7280']].forEach(([xc, v, lo, hi, fill, ec]) => {
8866      c.svg.append('rect').at({
8867        x: xc - w/2, width: w, y: c.y(v), height: c.height - c.y(v), fill})
8868      c.svg.append('path').at({
8869        d: `M${xc},${c.y(lo)} V${c.y(hi)} M${xc-3},${c.y(lo)} h6 M${xc-3},${c.y(hi)} h6`,
8870        stroke: ec, strokeWidth: 1.2, fill: 'none'})
8871      c.svg.append('text.vlabel').text(d3.format('.2f')(v))
8872        .at({x: xc, y: c.y(hi) - 4, textAnchor: 'middle'})
8873    })
8874    var lab = c.svg.append('text.gxlabel')
8875      .at({x: c.x(gi), y: c.height + 16, textAnchor: 'middle', fill: cols[gi]})
8876    s.label.split(' + ').length > 1
8877      ? ['concept', '+ math'].forEach((t, k) => lab.append('tspan').text(t).at({x: c.x(gi), dy: k ? '1.15em' : 0}))
8878      : ['two', 'concepts'].forEach((t, k) => lab.append('tspan').text(t).at({x: c.x(gi), dy: k ? '1.15em' : 0}))
8879  })
8880  // legend: shuffled control
8881  var leg = c.svg.append('g').translate([c.width - 4, 6])
8882  leg.append('rect').at({x: -110, width: 11, height: 11, y: -9, fill: GREY})
8883  leg.append('text').text('shuffled control').at({x: -95}).st({fontSize: 'var(--fs-small)'})
8884  var ylab = c.svg.select('.y').append('g').translate([-44, c.height/2])
8885    .append('text.axis-label').at({textAnchor: 'middle', transform: 'rotate(-90)'})
8886  ylab.append('tspan').text(`P(other task in lens top-${d.k_coocc})`).at({x: 0, dy: '-0.4em'})
8887  ylab.append('tspan').text(`at tokens where this task is in top-${d.k_coocc}`).at({x: 0, dy: '1.2em'})
8888}
8889
8890function drawReach(sel, d){
8891  var col = sel.append('div.panel')
8892  col.append('div.mlabel').text('reachability: single vs dual task')
8893  var c = d3.conventions({
8894    sel: col.append('div'),
8895    width: 290, height: 290,
8896    margin: {left: 56, right: 6, top: 16, bottom: 52},
8897    layers: 's',
8898  })
8899  c.svg.append('defs').append('pattern')
8900    .at({id: 'hatch-dual', width: 5, height: 5, patternUnits: 'userSpaceOnUse',
8901         patternTransform: 'rotate(45)'})
8902    .append('rect').at({width: 2, height: 5, fill: '#fff'})
8903  var cols = [BLUE, ORANGE, ORANGE]
8904  c.x.domain([-0.6, d.reach.length - 0.4])
8905  c.y.domain([0, 1.12])
8906  c.yAxis.ticks(5).tickFormat(d3.format('.0%'))
8907  c.drawAxis()
8908  c.svg.select('.x').remove()
8909  c.svg.selectAll('.domain').remove()
8910  c.svg.append('path.spine').at({
8911    d: `M0,0 V${c.height} H${c.width}`, stroke: '#888', strokeWidth: 0.7, fill: 'none'})
8912  var bw = 0.34
8913  d.reach.forEach((r, gi) => {
8914    var x1 = c.x(gi - (bw/2 + 0.02)), x2 = c.x(gi + (bw/2 + 0.02))
8915    var w = c.x(bw) - c.x(0)
8916    c.svg.append('rect').at({
8917      x: x1 - w/2, width: w, y: c.y(r.single), height: c.height - c.y(r.single),
8918      fill: cols[gi]})
8919    var g2 = c.svg.append('g')
8920    g2.append('rect').at({
8921      x: x2 - w/2, width: w, y: c.y(r.dual), height: c.height - c.y(r.dual),
8922      fill: cols[gi]})
8923    g2.append('rect').at({
8924      x: x2 - w/2, width: w, y: c.y(r.dual), height: c.height - c.y(r.dual),
8925      fill: 'url(#hatch-dual)'})
8926    ;[[x1, r.single], [x2, r.dual]].forEach(([xc, v]) => {
8927      c.svg.append('text.vlabel').text(d3.format('.0%')(v))
8928        .at({x: xc, y: c.y(v) - 4, textAnchor: 'middle'})
8929    })
8930    var lab = c.svg.append('text.gxlabel')
8931      .at({x: c.x(gi), y: c.height + 16, textAnchor: 'middle', fill: cols[gi]})
8932    r.label.split(' ').length > 1
8933      ? r.label.replace('(w/ math)', '(w/·math)').split(' ').forEach((t, k) =>
8934          lab.append('tspan').text(t.replace('·', ' ')).at({x: c.x(gi), dy: k ? '1.15em' : 0}))
8935      : lab.text(r.label)
8936  })
8937  // legend
8938  var leg = c.svg.append('g').translate([4, 6])
8939  leg.append('rect').at({width: 11, height: 11, y: -9, fill: '#555'})
8940  leg.append('text').text('single task').at({x: 15}).st({fontSize: 'var(--fs-small)'})
8941  var l2 = leg.append('g').translate([90, 0])
8942  l2.append('rect').at({width: 11, height: 11, y: -9, fill: '#555'})
8943  l2.append('rect').at({width: 11, height: 11, y: -9, fill: 'url(#hatch-dual)'})
8944  l2.append('text').text('dual task').at({x: 15}).st({fontSize: 'var(--fs-small)'})
8945  var ylab = c.svg.select('.y').append('g').translate([-40, c.height/2])
8946    .append('text.axis-label').at({textAnchor: 'middle', transform: 'rotate(-90)'})
8947  ylab.append('tspan').text(`reaches lens top-${d.topk}`).at({x: 0, dy: '-0.4em'})
8948  ylab.append('tspan').text(`(band ${d.band_lbl}, any position)`).at({x: 0, dy: '1.2em'})
8949}
8950
8951window.initDualTaskSimple = async function(opts){
8952  var sel = d3.select('.dual-task-simple').html('')
8953  var base = (opts?.datapath || './').replace(/\/?$/, '/')
8954  var d = await util.getFile(base + 'data.json')
8955  var row = sel.append('div.panel-row')
8956  drawExamples(row, d)
8957  drawCoocc(row, d)
8958  drawReach(row, d)
8959}
8960
8961}()
8962window.init?.()
8963
8964// ─── public/capacity-final-band/init-capacity-final-band.js ───
8965!function(){
8966
8967var ORANGE = util.tol.orange, BLUE = util.tol.blue, GREY = util.gray[500]
8968var BLK_COLS = [util.tol.orange, util.tol.magenta, util.tol.teal, util.tol.cyan]
8969
8970function chipBlock(col, hdr, sub, chips){
8971  var blk = col.append('div.chipblock')
8972  blk.append('div.chiphead').text(hdr)
8973  blk.append('div.chipsub').text(sub)
8974  var row = blk.append('div.chips')
8975  chips.forEach(ch => {
8976    row.append('span.chip').at({class: 'chip ' + ch.cls}).text(ch.tok)
8977    row.append('span').text(' ')
8978  })
8979}
8980
8981function drawA(sel, d){
8982  var col = sel.append('div.panel.chip-panel')
8983  col.append('div.mlabel').text('example: reading a single-family list')
8984  d.a.forEach(ex => {
8985    chipBlock(col,
8986      `after ${ex.n_read} word${ex.n_read > 1 ? 's' : ''} read`,
8987      'read: ' + ex.read_words.join(', '),
8988      ex.chips.map(ch => ({tok: ch.tok, cls: ch.cls == 'read' ? 'cur' : 'other'})))
8989  })
8990}
8991
8992function drawD(sel, d){
8993  var col = sel.append('div.panel.chip-panel')
8994  col.append('div.mlabel').text('example: color words after the animals')
8995  d.d.forEach(ex => {
8996    chipBlock(col,
8997      `${ex.n_colors} color word${ex.n_colors > 1 ? 's' : ''} after the 8 animals`,
8998      'colors read: ' + ex.read_words.join(', '),
8999      ex.chips)
9000  })
9001}
9002
9003function lineChart(sel, title, opts){
9004  var col = sel.append('div.panel')
9005  col.append('div.mlabel').text(title)
9006  var c = d3.conventions({
9007    sel: col.append('div'),
9008    width: 270, height: 280,
9009    margin: {left: 46, right: 10, top: 12, bottom: 40},
9010    layers: 's',
9011  })
9012  c.x.domain(opts.xdom)
9013  c.y.domain(opts.ydom)
9014  c.xAxis.ticks(5)
9015  c.yAxis.ticks(5)
9016  c.drawAxis()
9017  util.ggPlot(c)
9018  util.addAxisLabel(c, opts.xlabel, opts.ylabel, '', 6)
9019  return c
9020}
9021
9022function band(c, xs, q1, q3, color){
9023  var area = d3.area().x((v, i) => c.x(xs[i])).y0(v => c.y(v[0])).y1(v => c.y(v[1]))
9024  c.svg.append('path').at({
9025    d: area(q1.map((v, i) => [v, q3[i]])), fill: color, opacity: 0.10})
9026}
9027
9028function line(c, xs, ys, color, opts){
9029  var ln = d3.line().x((v, i) => c.x(xs[i])).y(v => c.y(v))
9030  c.svg.append('path').at({
9031    d: ln(ys), stroke: color, fill: 'none',
9032    strokeWidth: opts?.lw ?? 2, strokeDasharray: opts?.dash || null})
9033}
9034
9035function legend(c, entries, pos){
9036  var leg = c.svg.append('g.legend').translate(pos || [8, 8])
9037  entries.forEach((e, i) => {
9038    var g = leg.append('g').translate([0, i * 14])
9039    g.append('line').at({x1: 0, x2: 14, stroke: e.color,
9040      strokeWidth: e.lw ?? 2, strokeDasharray: e.dash || null})
9041    g.append('text').text(e.label).at({x: 18, dy: '0.32em'})
9042  })
9043}
9044
9045function drawB(sel, d){
9046  var xs = d3.range(d.ng)
9047  var c = lineChart(sel, 'words in the readout: family vs random lists', {
9048    xdom: [0, d.ng - 1], ydom: [0, d.ylim_top],
9049    xlabel: 'comma #', ylabel: `# list words in top-${d.k25} (band-min)`,
9050  })
9051  ;[['family', ORANGE], ['random', BLUE]].forEach(([lab, color]) => {
9052    var s = d.b[lab]
9053    band(c, xs, s.read_q1, s.read_q3, color)
9054    line(c, xs, s.read_med, color)
9055    line(c, xs, s.all_med, color, {lw: 1.5, dash: '4 2'})
9056  })
9057  legend(c, [
9058    {label: 'family: read', color: ORANGE},
9059    {label: 'family: all 80', color: ORANGE, dash: '4 2', lw: 1.5},
9060    {label: 'random: read', color: BLUE},
9061    {label: 'random: all 80', color: BLUE, dash: '4 2', lw: 1.5},
9062  ])
9063}
9064
9065function drawC(sel, d){
9066  var c = lineChart(sel, 'read words vs rank threshold', {
9067    xdom: [0, 100], ydom: [0, 100],
9068    xlabel: 'rank threshold K (band-min)', ylabel: '# READ words ≤ K (near end of list)',
9069  })
9070  c.svg.append('line').at({
9071    x1: 0, x2: c.width, y1: c.y(80), y2: c.y(80),
9072    stroke: GREY, strokeWidth: 1, strokeDasharray: '4 2'})
9073  c.svg.append('text').text('list length (80)')
9074    .at({x: c.width - 4, y: c.y(80) - 5, textAnchor: 'end', fontSize: 9, fill: 'var(--text-light)'})
9075  line(c, d.c.ks, d.c.family, ORANGE)
9076  line(c, d.c.ks, d.c.random, BLUE)
9077  line(c, d.c.ks, d.c.control, GREY, {lw: 1.2})
9078  legend(c, [
9079    {label: 'family', color: ORANGE},
9080    {label: 'random', color: BLUE},
9081    {label: 'control', color: GREY, lw: 1.2},
9082  ])
9083}
9084
9085function drawE(sel, d){
9086  var xs = d3.range(d.ng)
9087  var c = lineChart(sel, 'words in the readout: four-family block lists', {
9088    xdom: [0, d.ng - 1], ydom: [0, d.ylim_top],
9089    xlabel: 'comma #', ylabel: `# family words in top-${d.k25} (band-min)`,
9090  })
9091  d.e.blocks.forEach((blk, bi) => {
9092    band(c, xs, blk.q1, blk.q3, BLK_COLS[bi])
9093    line(c, xs, blk.med, BLK_COLS[bi], {lw: 1.8})
9094  })
9095  line(c, xs, d.e.random_med, GREY, {lw: 1.1})
9096  legend(c, [
9097    ...d.e.blocks.map((_, bi) => ({label: `block ${bi + 1}`, color: BLK_COLS[bi], lw: 1.8})),
9098    {label: 'random', color: GREY, lw: 1.1},
9099  ], [8, 8])
9100}
9101
9102function drawF(sel, d){
9103  var col = sel.append('div.panel')
9104  col.append('div.mlabel').text('per-word presence in block lists')
9105  var c = d3.conventions({
9106    sel: col.append('div'),
9107    width: 260, height: 280,
9108    margin: {left: 46, right: 56, top: 12, bottom: 40},
9109    layers: 'cs',
9110  })
9111  var nW = d.f.length, nG = d.f[0].length
9112  var x = d3.scaleLinear().domain([-0.5, nG - 0.5]).range([0, c.width])
9113  var y = d3.scaleLinear().domain([-0.5, nW - 0.5]).range([c.height, 0])
9114  var ctx = c.layers[0]
9115  d.f.forEach((row, wi) => row.forEach((v, gi) => {
9116    ctx.fillStyle = v == null ? '#f4f5f7' : d3.interpolateOranges(v)
9117    ctx.fillRect(
9118      Math.floor(x(gi - 0.5)), Math.floor(y(wi + 0.5)),
9119      Math.ceil(x(gi + 0.5)) - Math.floor(x(gi - 0.5)),
9120      Math.ceil(y(wi - 0.5)) - Math.floor(y(wi + 0.5)))
9121  }))
9122  ;[20, 40, 60].forEach(b => {
9123    c.svg.append('line').at({
9124      x1: x(b - 0.5), x2: x(b - 0.5), y1: 0, y2: c.height,
9125      stroke: '#1f2937', strokeWidth: 0.6, opacity: 0.5})
9126    c.svg.append('line').at({
9127      x1: 0, x2: c.width, y1: y(b - 0.5), y2: y(b - 0.5),
9128      stroke: '#1f2937', strokeWidth: 0.6, opacity: 0.5})
9129  })
9130  c.x = x; c.y = y
9131  c.xAxis.scale(x).ticks(4)
9132  c.yAxis.scale(y).ticks(4)
9133  c.drawAxis()
9134  c.svg.selectAll('.domain').remove()
9135  util.addAxisLabel(c, 'readout comma', 'word read at #', '', 6)
9136
9137  var cbH = 195, cbW = 9, n = 50
9138  var cb = c.svg.append('g').translate([c.width + 12, (c.height - cbH)/2])
9139  cb.appendMany('rect', d3.range(n)).at({
9140    y: i => cbH - (i+1)*cbH/n, width: cbW, height: cbH/n + 1,
9141    fill: i => d3.interpolateOranges(i/(n-1)),
9142  })
9143  var cy = d3.scaleLinear().domain([0, 1]).range([cbH, 0])
9144  cb.appendMany('text', [0, 0.5, 1]).text(v => d3.format('.1f')(v))
9145    .at({x: cbW + 4, y: v => cy(v) + 3, fontSize: 9, fill: '#555'})
9146  cb.append('text').text(`P(band-min rank ≤ ${d.k25})`)
9147    .at({transform: `translate(${cbW + 30},${cbH/2}) rotate(-90)`,
9148         textAnchor: 'middle', fontSize: 9, fill: '#555'})
9149}
9150
9151window.initCapacityFinalBand = async function(opts){
9152  var sel = d3.select('.capacity-final-band').html('')
9153  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9154  var d = await util.getFile(base + 'data.json')
9155  var r1 = sel.append('div.panel-row')
9156  drawA(r1, d)
9157  drawB(r1, d)
9158  drawC(r1, d)
9159  var r2 = sel.append('div.panel-row')
9160  drawD(r2, d)
9161  drawE(r2, d)
9162  drawF(r2, d)
9163}
9164
9165}()
9166window.init?.()
9167
9168// ─── public/flex-generalization-example/init-flex-generalization-example.js ───
9169window.initFlexGenExample = async function(opts){
9170  var sel = d3.select('.flex-generalization-example').html('')
9171  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9172  var d = await util.getFile(base + 'data.json')
9173
9174  var CLEAN = util.gray[500], SWAP = util.tol.orange
9175  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
9176
9177  var panels = sel.appendMany('div.fge-panel', d.blocks)
9178
9179  var left = panels.append('div.fge-left')
9180  left.append('div.fig-title').text(b => b.func.charAt(0).toUpperCase() + b.func.slice(1))
9181  var splitHit = b => b.prompt.match(/^([\s\S]*?)([A-Za-z0-9]+|.)$/).slice(1)
9182  var prompt = left.append('div.fge-prompt')
9183  prompt.append('span').text(b => splitHit(b)[0])
9184  prompt.append('span.tok-hit').text(b => splitHit(b)[1])
9185  left.append('div.fge-swap').html(
9186    `J-lens coordinate swap: ${esc(d.swap_from)} → <span style="color:${SWAP}">${esc(d.swap_to)}</span>`)
9187
9188  var right = panels.append('div.fge-right')
9189  function col(side, label, color, barBg){
9190    var c = right.append('div.fge-col.bar-table').classed(side, true)
9191    c.append('h6.mlabel').html((b, i) =>
9192      `<span class="swatch" style="background:${color}"></span> ${esc(label)}` +
9193      (i == 0 && side == 'clean' ? ` <span class="hdr-sub">(log prob)</span>` : ''))
9194    c.appendMany('div.bar-row', b => b[side].map(e => ({...e, w: Math.exp(e.lp)/d.max_prob*100})))
9195      .at({title: e => e.tok})
9196      .html(e => `<div class="bar" style="width:${e.w.toFixed(1)}%;background:${barBg}"></div>` +
9197                 `<span class="t">${esc(e.tok)}</span><span class="val">${e.lp.toFixed(2)}</span>`)
9198  }
9199  col('clean', 'Clean', CLEAN, util.gray[200])
9200  col('patched', 'Swapped', SWAP, util.tint(SWAP, 0.65))
9201}
9202
9203window.init?.()
9204
9205// ─── public/flex-generalization-systematic/init-flex-generalization-systematic.js ───
9206!function(){
9207
9208var INK = '#1f2937', GREY = '#9ca3af', LIGHT = '#d1d5db', MUTED = '#888'
9209
9210// Per-label nudges for scatter points that land on top of each other
9211// (mirrors the matplotlib figure).
9212// Points whose labels sit on top of a neighbour — drop the label, keep the dot.
9213var NO_LABEL = new Set(['class', 'square'])
9214
9215var NUDGE = {
9216  continent: [-7, -3, 'end'], holiday: [7, -3, 'start'],
9217  first_letter: [-7, 3, 'end'], square: [-7, -3, 'end'],
9218  legs: [-7, -3, 'end'], double: [7, -3, 'start'],
9219}
9220
9221function drawDots(sel, d){
9222  var col = sel.append('div.panel')
9223  col.append('div.mlabel').text('fraction of swaps at top-1, by function')
9224  var catColor = {}
9225  d.cats.forEach(c => catColor[c.name] = c.color)
9226  var rows = []
9227  d.cats.forEach(c => {
9228    var sub = d.funcs.filter(f => f.cat == c.name)
9229      .sort((a, b) => b.hit2 - a.hit2)
9230    rows.push(...sub)
9231  })
9232  var c = d3.conventions({
9233    sel: col.append('div'),
9234    width: 230, height: 300,
9235    margin: {left: 98, right: 12, top: 10, bottom: 42},
9236    layers: 's',
9237  })
9238  c.x.domain([0, 1])
9239  c.xAxis.tickValues([0, 0.5, 1])
9240  var y = i => (i + 0.5) * c.height / rows.length
9241  c.svg.append('g.x.axis').translate([0, c.height]).call(c.xAxis)
9242  c.svg.selectAll('.domain').remove()
9243  c.svg.append('path.spine').at({
9244    d: `M0,0 V${c.height} H${c.width}`, stroke: '#888', strokeWidth: 0.7, fill: 'none'})
9245  util.addAxisLabel(c, 'fraction at top-1', '', '', 6)
9246
9247  var catBreaks = []
9248  var n = 0
9249  d.cats.slice(0, -1).forEach(cat => {
9250    n += d.funcs.filter(f => f.cat == cat.name).length
9251    catBreaks.push(n)
9252  })
9253  catBreaks.forEach(b => {
9254    c.svg.append('line').at({
9255      x1: -96, x2: c.width, y1: y(b - 0.5), y2: y(b - 0.5),
9256      stroke: LIGHT, strokeWidth: 0.5})
9257  })
9258
9259  rows.forEach((f, i) => {
9260    var color = catColor[f.cat]
9261    var h1 = f.hit1 / f.n, h2 = f.hit2 / f.n
9262    c.svg.append('line').at({
9263      x1: 0, x2: c.x(h1), y1: y(i), y2: y(i),
9264      stroke: color, strokeWidth: 1.2, opacity: 0.35})
9265    c.svg.append('line').at({
9266      x1: 0, x2: c.x(h2), y1: y(i), y2: y(i),
9267      stroke: color, strokeWidth: 1, opacity: 0.35, strokeDasharray: '3 2'})
9268    c.svg.append('circle').at({
9269      cx: c.x(h1), cy: y(i), r: 4, fill: color, stroke: INK, strokeWidth: 0.4})
9270    var g = c.svg.append('g').translate([c.x(h2), y(i)])
9271    ;[[-3, -3, 3, 3], [-3, 3, 3, -3]].forEach(([a, b1, e, f2]) => {
9272      g.append('line').at({
9273        x1: a, y1: b1, x2: e, y2: f2, stroke: color, strokeWidth: 1.3})
9274    })
9275    c.svg.append('text.ytick').text(f.func)
9276      .at({x: -5, y: y(i), dy: '0.32em', textAnchor: 'end'})
9277  })
9278
9279  var leg = c.svg.append('g.legend').translate([c.width - 64, c.height - 14 * (d.cats.length + 2)])
9280  d.cats.forEach((cat, i) => {
9281    var g = leg.append('g').translate([0, i * 14])
9282    g.append('circle').at({r: 4, fill: cat.color, stroke: INK, strokeWidth: 0.4})
9283    g.append('text').text(cat.name).at({x: 9, dy: '0.32em'})
9284  })
9285  ;[['α=1', null, 'o'], ['α=2', '3 2', 'x']].forEach(([lab, dash, m], i) => {
9286    var g = leg.append('g').translate([0, (d.cats.length + i) * 14])
9287    g.append('line').at({x1: -5, x2: 5, stroke: MUTED, strokeDasharray: dash})
9288    m == 'o'
9289      ? g.append('circle').at({r: 3.5, fill: '#fff', stroke: MUTED})
9290      : [[-3, -3, 3, 3], [-3, 3, 3, -3]].forEach(([a, b1, e, f2]) =>
9291          g.append('line').at({x1: a, y1: b1, x2: e, y2: f2, stroke: MUTED, strokeWidth: 1.2}))
9292    g.append('text').text(lab).at({x: 9, dy: '0.32em'})
9293  })
9294}
9295
9296function drawScatter(sel, d){
9297  var col = sel.append('div.panel')
9298  col.append('div.mlabel').text('swap effect vs workspace loading')
9299  var catColor = {}
9300  d.cats.forEach(c => catColor[c.name] = c.color)
9301  var xs = d.funcs.map(f => f.x_cos), ys = d.funcs.map(f => f.dlpc)
9302  var xspan = d3.max(xs) - d3.min(xs), yspan = d3.max(ys) - d3.min(ys)
9303  var xlo = d3.min(xs) - 0.06 * xspan, xhi = d3.max(xs) + 0.18 * xspan
9304  var ylo = Math.min(d3.min(ys), 0) - 0.06 * yspan, yhi = d3.max(ys) + 0.08 * yspan
9305
9306  var c = d3.conventions({
9307    sel: col.append('div'),
9308    width: 330, height: 300,
9309    margin: {left: 52, right: 10, top: 10, bottom: 42},
9310    layers: 's',
9311  })
9312  c.x.domain([xlo, xhi])
9313  c.y.domain([ylo, yhi])
9314  c.xAxis.ticks(5)
9315  c.yAxis.ticks(5)
9316  c.drawAxis()
9317  c.svg.selectAll('.domain').remove()
9318  c.svg.append('path.spine').at({
9319    d: `M0,0 V${c.height} H${c.width}`, stroke: '#888', strokeWidth: 0.7, fill: 'none'})
9320  util.addAxisLabel(c,
9321    'workspace loading of the argument (cosine sim)',
9322    `log-prob shift toward the swapped-in answer (α=${d.scatter_alpha})`, '', 6)
9323
9324  c.svg.append('line').at({
9325    x1: 0, x2: c.width, y1: c.y(0), y2: c.y(0), stroke: LIGHT, strokeWidth: 0.7})
9326  var fx = [xlo, xhi]
9327  c.svg.append('line').at({
9328    x1: c.x(fx[0]), y1: c.y(d.fit.slope * fx[0] + d.fit.intercept),
9329    x2: c.x(fx[1]), y2: c.y(d.fit.slope * fx[1] + d.fit.intercept),
9330    stroke: MUTED, strokeWidth: 1.1, strokeDasharray: '4 3', opacity: 0.55})
9331  c.svg.append('text.rlabel').text(`r = ${d.r > 0 ? '+' : ''}${d3.format('.2f')(d.r)}`)
9332    .at({x: 8, y: 14})
9333
9334  d.funcs.forEach(f => {
9335    c.svg.append('circle').at({
9336      cx: c.x(f.x_cos), cy: c.y(f.dlpc), r: 4.5,
9337      fill: catColor[f.cat], stroke: INK, strokeWidth: 0.4, opacity: 0.9})
9338    if (NO_LABEL.has(f.func)) return
9339    var [dx, dy, anchor] = NUDGE[f.func] || [5, -2, 'start']
9340    c.svg.append('text.flabel').text(f.func)
9341      .at({x: c.x(f.x_cos) + dx, y: c.y(f.dlpc) + dy, dy: '0.32em', textAnchor: anchor})
9342  })
9343}
9344
9345window.initFlexGeneralizationSystematic = async function(opts){
9346  var sel = d3.select('.flex-generalization-systematic').html('')
9347  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9348  var d = await util.getFile(base + 'data.json')
9349  var row = sel.append('div.panel-row')
9350  drawDots(row, d)
9351  drawScatter(row, d)
9352}
9353
9354}()
9355window.init?.()
9356
9357// ─── public/capacity-band-vs-single/init-capacity-band-vs-single.js ───
9358!function(){
9359
9360var ORANGE = '#ff6b35', BLUE = '#2563eb', GREY = '#9ca3af'
9361
9362function lineChart(sel, title, opts){
9363  var col = sel.append('div.panel')
9364  col.append('div.mlabel').text(title)
9365  var c = d3.conventions({
9366    sel: col.append('div'),
9367    width: 270, height: 250,
9368    margin: {left: 46, right: 10, top: 12, bottom: 40},
9369    layers: 's',
9370  })
9371  c.x.domain(opts.xdom)
9372  c.y.domain(opts.ydom)
9373  c.xAxis.ticks(5)
9374  c.yAxis.ticks(5)
9375  c.drawAxis()
9376  c.svg.selectAll('.domain').remove()
9377  c.svg.append('path.spine').at({
9378    d: `M0,0 V${c.height} H${c.width}`, stroke: '#888', strokeWidth: 0.7, fill: 'none'})
9379  util.addAxisLabel(c, opts.xlabel, opts.ylabel, '', 6)
9380  return c
9381}
9382
9383function band(c, xs, q1, q3, color){
9384  var area = d3.area().x((v, i) => c.x(xs[i])).y0(v => c.y(v[0])).y1(v => c.y(v[1]))
9385  c.svg.append('path').at({
9386    d: area(q1.map((v, i) => [v, q3[i]])), fill: color, opacity: 0.10})
9387}
9388
9389function line(c, xs, ys, color, opts){
9390  var ln = d3.line().x((v, i) => c.x(xs[i])).y(v => c.y(v))
9391  c.svg.append('path').at({
9392    d: ln(ys), stroke: color, fill: 'none',
9393    strokeWidth: opts?.lw ?? 2, strokeDasharray: opts?.dash || null})
9394}
9395
9396function legend(c, entries){
9397  var leg = c.svg.append('g.legend').translate([8, 8])
9398  entries.forEach((e, i) => {
9399    var g = leg.append('g').translate([0, i * 14])
9400    g.append('line').at({x1: 0, x2: 14, stroke: e.color,
9401      strokeWidth: e.lw ?? 2, strokeDasharray: e.dash || null})
9402    g.append('text').text(e.label).at({x: 18, dy: '0.32em'})
9403  })
9404}
9405
9406function drawOcc(sel, d, src, title, ylabel, ylimTop){
9407  var xs = d3.range(d.ng)
9408  var c = lineChart(sel, title, {
9409    xdom: [0, d.ng - 1], ydom: [0, ylimTop],
9410    xlabel: 'comma #', ylabel,
9411  })
9412  ;[['family', ORANGE], ['random', BLUE]].forEach(([lab, color]) => {
9413    var s = src.b[lab]
9414    band(c, xs, s.read_q1, s.read_q3, color)
9415    line(c, xs, s.read_med, color)
9416    line(c, xs, s.all_med, color, {lw: 1.5, dash: '4 2'})
9417  })
9418  legend(c, [
9419    {label: 'family: read', color: ORANGE},
9420    {label: 'family: all 80', color: ORANGE, dash: '4 2', lw: 1.5},
9421    {label: 'random: read', color: BLUE},
9422    {label: 'random: all 80', color: BLUE, dash: '4 2', lw: 1.5},
9423  ])
9424}
9425
9426function drawThresh(sel, d, src, title, xlabel){
9427  var c = lineChart(sel, title, {
9428    xdom: [0, 100], ydom: [0, 100],
9429    xlabel, ylabel: '# read words ≤ K (near end of list)',
9430  })
9431  c.svg.append('line').at({
9432    x1: 0, x2: c.width, y1: c.y(80), y2: c.y(80),
9433    stroke: GREY, strokeWidth: 1, strokeDasharray: '4 2'})
9434  c.svg.append('text').text('list length (80)')
9435    .at({x: c.width - 4, y: c.y(80) - 5, textAnchor: 'end', fontSize: 9, fill: '#6b7280'})
9436  line(c, src.c.ks, src.c.family, ORANGE)
9437  line(c, src.c.ks, src.c.random, BLUE)
9438  line(c, src.c.ks, src.c.control, GREY, {lw: 1.2})
9439  legend(c, [
9440    {label: 'family', color: ORANGE},
9441    {label: 'random', color: BLUE},
9442    {label: 'control', color: GREY, lw: 1.2},
9443  ])
9444}
9445
9446window.initCapacityBandVsSingle = async function(opts){
9447  var sel = d3.select('.capacity-band-vs-single').html('')
9448  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9449  var d = await util.getFile(base + 'data.json')
9450  // Shared, fixed y-limit for the occupancy panels — matches the main-text
9451  // capacity figure and keeps the legend clear of the curves.
9452  var ylimTop = 50
9453
9454  var r1 = sel.append('div.panel-row')
9455  drawOcc(r1, d, d.band, `words in the readout — any layer of the workspace (${d.band_lbl})`,
9456    `# list words in top-${d.k25} (band-min)`, ylimTop)
9457  drawThresh(r1, d, d.band, 'read words vs rank threshold — any workspace layer',
9458    'rank threshold K (band-min)')
9459
9460  var r2 = sel.append('div.panel-row')
9461  drawOcc(r2, d, d.single, `words in the readout — single layer (${d.single_lbl})`,
9462    `# list words in top-${d.k25}`, ylimTop)
9463  drawThresh(r2, d, d.single, `read words vs rank threshold — single layer (${d.single_lbl})`,
9464    'rank threshold K')
9465}
9466
9467}()
9468window.init?.()
9469
9470// ─── public/app-mt-examples/init-app-mt-examples.js ───
9471window.initAppMtExamples = async function(opts){
9472  var sel = d3.select('.app-mt-examples').html('')
9473  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9474  var d = await util.getFile(base + 'data.json')
9475
9476  var JL = util.lensColors.jacobian, TPL = util.tol.orange
9477  var esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
9478
9479  var row = sel.append('div.amx-row')
9480
9481  function barTable(host, label, color, entries){
9482    var t = host.append('div.amx-bars.bar-table')
9483    var h = t.append('h6')
9484    h.append('span.swatch').st({background: color, marginRight: 5})
9485    h.append('span').text(label)
9486    var max = d3.max(entries, e => Math.exp(e.lp))
9487    var bg = util.tint(color, 0.7)
9488    t.appendMany('div.bar-row', entries)
9489      .at({title: e => util.ppCell(e.tok).trim()})
9490      .html(e =>
9491        `<div class="bar" style="width:${(Math.exp(e.lp)/max*100).toFixed(1)}%;background:${bg}"></div>` +
9492        `<span class="t${e.italic ? ' it' : ''}">${esc(util.ppCell(e.tok).trim())}</span>` +
9493        `<span class="val">${e.lp.toFixed(1)}</span>`)
9494  }
9495
9496  // ── A: blackmail ──
9497  var bm = d.blackmail
9498  var colA = row.append('div.amx-col')
9499  colA.append('div.fig-title').text('A. Reading out “blackmail”')
9500  var prA = colA.append('div.amx-prompt.prompt-block')
9501  prA.append('span.ctx').text(
9502    '[in the middle of the blackmail evaluation scenario; ' +
9503    'both lenses are read at the highlighted token]\n')
9504  prA.append('span').text(bm.excerpt.pre)
9505  prA.append('span.tok-hit').text(bm.excerpt.hit)
9506  prA.append('span').text(bm.excerpt.post)
9507  var cA = colA.append('div.amx-cols')
9508  barTable(cA, `Jacobian lens (${bm.layer_label})`, JL, bm.jlens)
9509  barTable(cA, `template lens (${bm.layer_label})`, TPL, bm.template)
9510
9511  // ── B: photosynthesis ──
9512  var ph = d.photosynthesis
9513  var colB = row.append('div.amx-col')
9514  colB.append('div.fig-title').text('B. Reading out “photosynthesis”')
9515  var prB = colB.append('div.amx-prompt.prompt-block')
9516  prB.append('span').text(ph.prompt_pre)
9517  prB.append('span.tok-hit').text(ph.prompt_hit)
9518  prB.append('span').text(ph.prompt_post)
9519  var cB = colB.append('div.amx-cols')
9520  barTable(cB, `Jacobian lens (${ph.layer_label})`, JL, ph.jlens)
9521  barTable(cB, `template lens (${ph.layer_label})`, TPL, ph.template)
9522
9523  // ── C: Tchaikovsky↔Beethoven swap ──
9524  var colC = row.append('div.amx-col')
9525  colC.append('div.fig-title').text('C. Causal interventions')
9526  d.swaps.forEach(s => {
9527    colC.append('div.amx-prompt.prompt-block').text(s.prompt)
9528    var sw = colC.append('div.amx-swp')
9529    var r1 = sw.append('div.amx-row2')
9530    r1.append('span.lab').text("model's answer:")
9531    r1.append('span').append('span.out').append('b').text(s.ans)
9532    var r2 = sw.append('div.amx-row2')
9533    r2.append('span.lab').st({color: TPL})
9534      .html(`swap <i>${esc(s.src)}</i>&rarr;<i>${esc(s.dst)}</i> templates:`)
9535    r2.append('span').append('span.out.hi').text(s.swapped)
9536  })
9537}
9538
9539window.init?.()
9540
9541// ─── public/oracle-lens/init-oracle-lens.js ───
9542window.initOracleLens = async function(opts){
9543  var sel = d3.select('.oracle-lens').html('')
9544  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9545  var d = await util.getFile(base + 'data.json')
9546
9547  var grid = sel.append('div.ol-grid.figgrid')
9548  d.panels.forEach(p => {
9549    var card = grid.append('div.cell.ol-card')
9550    var hd = card.append('div.ol-hd')
9551    hd.append('span.ol-id').text(p.id)
9552    hd.append('span.fig-title').text(p.title)
9553
9554    var body = card.append('div.ol-body')
9555    var pb = body.append('div.prompt-block' + (p.nowrap ? '.ol-nowrap' : ''))
9556    if (p.ctx) pb.append('span.ctx').text(p.ctx + '\n')
9557    if (p.prompt.elide_lo && !p.suppress_lead_elision)
9558      pb.append('span.ctx').text('[…]\n')
9559    pb.append('span').text(p.prompt.pre)
9560    pb.append('span.tok-hit').text(p.prompt.hit)
9561    pb.append('span').text(p.prompt.post)
9562    if (p.prompt.elide_hi) pb.append('span.ctx').text('\n[…]')
9563
9564    var pcol = body.append('div.ol-pcol')
9565    pcol.append('div.mlabel')
9566      .html('Oracle lens <span class="ol-sub">(N=4, K=10)</span>')
9567    var bold = new Set(p.bold)
9568    pcol.append('ol.ol-phrases')
9569      .appendMany('li', p.phrases)
9570      .classed('bold', (_, i) => bold.has(i))
9571      .text(ph => ph)
9572  })
9573}
9574
9575window.init?.()
9576
9577// ─── public/app-mt-bytok/init-app-mt-bytok.js ───
9578window.initAppMtBytok = async function(opts){
9579  var sel = d3.select('.app-mt-bytok').html('')
9580  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9581  var d = await util.getFile(base + 'data.json')
9582
9583  var styleOf = {
9584    'jlens':      {color: util.lensColors.jacobian, dash: null,  marker: 'o'},
9585    'jlens-dash': {color: util.lensColors.jacobian, dash: '4,3', marker: 'o-open'},
9586    'template':   {color: util.tol.orange,          dash: null,  marker: 's'},
9587    'raw':        {color: util.gray[500],           dash: null,  marker: 'd'},
9588  }
9589
9590  var row = sel.append('div.panel-row').st({flexDirection: 'row'})
9591  drawPanel(row.append('div.app-mt-panel'), d.readout, d.n_readout,
9592            'Decoding the intermediate concept', 'fraction in top 10',
9593            'tokens in the latent word')
9594  drawPanel(row.append('div.app-mt-panel'), d.swap, d.n_swap,
9595            'Swapping the intermediate concept',
9596            'P(answer follows the swap) at α = 1',
9597            'tokens in the anchor word')
9598
9599  var leg = sel.append('div.app-mt-legend')
9600  d.readout.forEach(s => {
9601    var st = styleOf[s.style]
9602    var item = leg.append('span.leg-item')
9603    item.append('svg').at({width: 24, height: 12}).append('line')
9604      .at({x1: 0, x2: 24, y1: 6, y2: 6, stroke: st.color, strokeWidth: 1.6,
9605           strokeDasharray: st.dash || null})
9606    item.append('span').text(s.label)
9607  })
9608
9609  function drawPanel(sel, series, ns, title, ylab, xlab){
9610    var c = d3.conventions({
9611      sel, width: 340, height: 240,
9612      margin: {left: 56, right: 12, top: 30, bottom: 44},
9613    })
9614    var x = d3.scalePoint().domain(d.buckets).range([0, c.width]).padding(0.2)
9615    c.y.domain([-0.03, 1.04])
9616    c.yAxis.ticks(5).tickFormat(d3.format('.0%'))
9617    c.drawAxis()
9618    c.svg.select('.x').remove()
9619    util.ggPlot(c)
9620    util.addAxisLabel(c, xlab, ylab, title, 0, -8)
9621
9622    c.svg.appendMany('text.xlab', d.buckets)
9623      .translate((b, i) => [x(b), c.height + 14])
9624      .at({textAnchor: 'middle'}).text(b => b)
9625    c.svg.appendMany('text.xlab-n', d.buckets)
9626      .translate((b, i) => [x(b), c.height + 26])
9627      .at({textAnchor: 'middle', fill: util.gray[500]})
9628      .style('font-size', '10px')
9629      .text((b, i) => '(n=' + ns[i] + ')')
9630
9631    var nM = series.length
9632    var off = i => (i - (nM - 1)/2) * 6
9633    series.forEach((s, i) => {
9634      var st = styleOf[s.style]
9635      var pts = s.points.map((p, j) => ({...p, xi: x(p.bucket) + off(i)}))
9636      c.svg.append('path')
9637        .at({d: d3.line().x(p => p.xi).y(p => c.y(p.p))(pts),
9638             stroke: st.color, strokeWidth: 1.6, fill: 'none',
9639             strokeDasharray: st.dash || null})
9640      var g = c.svg.appendMany('g.pt', pts).translate(p => [p.xi, 0])
9641      g.append('line')
9642        .at({y1: p => c.y(p.lo), y2: p => c.y(p.hi),
9643             stroke: st.color, strokeWidth: 1.0})
9644      g.append('circle')
9645        .at({cy: p => c.y(p.p), r: 3.2,
9646             fill: st.marker == 'o-open' ? '#fff' : st.color,
9647             stroke: st.color, strokeWidth: 1.2})
9648      g.append('title').text(p => `${s.label}\n${p.bucket} tok: ${p.k}/${p.n} (${d3.format('.0%')(p.p)})`)
9649    })
9650  }
9651}
9652
9653// ─── public/app-mt-paper/init-app-mt-paper.js ───
9654window.initAppMtPaper = async function(opts){
9655  var sel = d3.select('.app-mt-paper').html('')
9656  var base = (opts?.datapath || './').replace(/\/?$/, '/')
9657  var d = await util.getFile(base + 'data.json')
9658
9659  var colorOf = {
9660    'jlens':      util.lensColors.jacobian,
9661    'jlens-dash': util.lensColors.jacobian,
9662    'template':   util.tol.orange,
9663    'raw':        util.gray[500],
9664  }
9665
9666  var row = sel.append('div.panel-row').st({flexDirection: 'row'})
9667  drawBars(row.append('div.app-mt-panel'), d.readout,
9668           'Decoding the intermediate concept', 'fraction in top 10')
9669  drawBars(row.append('div.app-mt-panel'), d.swap,
9670           'Swapping the intermediate concept',
9671           'P(answer follows the swap) at α = 1')
9672  sel.append('div.app-mt-n').text('n = ' + d.n)
9673
9674  function drawBars(sel, series, title, ylab){
9675    var c = d3.conventions({
9676      sel, width: 240, height: 220,
9677      margin: {left: 56, right: 12, top: 30, bottom: 18},
9678    })
9679    var x = d3.scaleBand().domain(series.map(s => s.key))
9680      .range([0, c.width]).paddingInner(0.30).paddingOuter(0.18)
9681    c.y.domain([-0.03, 1.04])
9682    c.yAxis.ticks(5).tickFormat(d3.format('.0%'))
9683    c.drawAxis()
9684    c.svg.select('.x').remove()
9685    util.ggPlot(c)
9686    util.addAxisLabel(c, '', ylab, title, 0, -8)
9687
9688    var g = c.svg.appendMany('g.mbar', series).translate(s => [x(s.key), 0])
9689    g.append('rect').at({
9690      width: x.bandwidth(), y: s => c.y(s.p), height: s => c.height - c.y(s.p),
9691      fill: s => colorOf[s.style], fillOpacity: 0.30,
9692    })
9693    g.each(function(s){
9694      util.barWhisker(d3.select(this), {x: x.bandwidth()/2, lo: c.y(s.lo), hi: c.y(s.hi)})
9695    })
9696    g.append('title').text(s => `${s.label}: ${s.k}/${s.n} (${d3.format('.0%')(s.p)})`)
9697  }
9698
9699  var leg = sel.append('div.app-mt-legend')
9700  d.swap.forEach(s => {
9701    var item = leg.append('span.leg-item')
9702    item.append('span.swatch').st({background: colorOf[s.style], opacity: 0.30})
9703    item.append('span').text(s.label)
9704  })
9705}
9706
9707// ─── public/init.js ───
9708addEventListener('scroll', function(){
9709  var b = document.querySelector('.draft-banner')
9710  if (b) b.classList.toggle('scrolled', scrollY > 120)
9711  // Floating TOC dropdown appears once the in-page contents has scrolled
9712  // off the top.
9713  var dc = document.querySelector('d-contents')
9714  var t = document.querySelector('.toc-float')
9715  if (t) t.classList.toggle('show', !dc || !dc.offsetHeight || dc.getBoundingClientRect().bottom < 0)
9716  window.updateTocFloatLabel()
9717}, {passive: true})
9718
9719// Floating-TOC button label tracks the current section — the last TOC-listed
9720// heading above the viewport top, §-numbered like the d-contents entries.
9721// 'Contents' before the first section.
9722window.updateTocFloatLabel = function(){
9723  var lbl = document.querySelector('.toc-float-btn .lbl')
9724  if (!lbl) return
9725  var cur = null, heads = window.__tocHeadings || []
9726  for (var i = 0; i < heads.length && heads[i].getBoundingClientRect().top <= 80; i++) cur = heads[i]
9727  if (!cur){ lbl.textContent = 'Contents'; return }
9728  lbl.textContent = ''
9729  var num = lbl.appendChild(document.createElement('span'))
9730  num.className = 'num'
9731  num.textContent = '§ ' + cur.dataset.secnum + ' '
9732  lbl.appendChild(document.createTextNode(cur.querySelector('a[id]').textContent))
9733}
9734
9735window.buildToc = function(){
9736  var nav = d3.select('d-contents').html('').append('nav')
9737  if (!nav.size()) return
9738  nav.append('h3').text('Contents')
9739  var ul, n = [0, 0, 0], h2 = 0, appx = 0
9740  window.__secNum = {}
9741  function entry(parent, num, id, text){
9742    var a = parent.append('a').at({href: '#' + id})
9743    a.append('span.toc-num').text(num)
9744    a.append('span').text(text)
9745  }
9746  // gdoc H1/H2/H3 → html h2/h3/h4 (process_doc remaps down one). TOC lists
9747  // h2+h3; h4 is numbered (N.M.K, \ref{}-able via __secNum) but not listed.
9748  d3.selectAll('d-article h2, d-article h3, d-article h4').each(function(){
9749    var a = this.querySelector('a[id]')
9750    if (!a) return
9751    var num
9752    if (this.tagName == 'H2'){
9753      // First H2 with id 'appendix' (and any later H2s) switch to letter
9754      // numbering: A, A.1, A.1.2. n[0] becomes a string; the '.'-concat
9755      // and n.join('.') below both handle that without further changes.
9756      n = (a.id == 'appendix' || appx)
9757        ? [String.fromCharCode(65 + appx++), 0, 0]
9758        : [++h2, 0, 0]
9759      num = window.__secNum[a.id] = '' + n[0]
9760      entry(nav.append('div'), num, a.id, a.textContent)
9761      ul = nav.append('ul')
9762    } else if (this.tagName == 'H3' && n[0]){
9763      n[1] += 1; n[2] = 0
9764      num = window.__secNum[a.id] = n[0] + '.' + n[1]
9765      entry(ul.append('li'), num, a.id, a.textContent)
9766    } else if (this.tagName == 'H4' && n[1]){
9767      n[2] += 1
9768      num = window.__secNum[a.id] = n.join('.')
9769    }
9770    // Prefix the number as a real <a> so it's clickable (scrolls + sets hash);
9771    // sits before the existing a[id] so a.textContent stays number-free for the
9772    // TOC entry above.
9773    if (num){
9774      this.dataset.secnum = num
9775      d3.select(this).selectAll('a.secnum, a\\.secnum').remove()
9776      d3.select(this).insert('a', ':first-child')
9777        .at({class: 'secnum', href: '#' + a.id}).text(num)
9778    }
9779  })
9780
9781  // Visual-TOC tiles (server-side from [[VISUAL_TOC]]) get the same §-number
9782  // via __secNum so the grid stays in sync with d-contents numbering.
9783  d3.selectAll('.visual-toc a.visual-toc-top').each(function(){
9784    var slug = (this.getAttribute('href') || '').slice(1)
9785    var num = window.__secNum[slug]
9786    if (num) d3.select(this).select('.toc-num').text('§ ' + num)
9787  })
9788
9789  // Floating top-left TOC dropdown — same entries as <d-contents>, shown
9790  // (via the scroll handler above) once the in-page contents has scrolled
9791  // away. The clone keeps the toc-num spans so numbering matches.
9792  d3.select('.toc-float').remove()
9793  var float = d3.select('body').append('div.toc-float')
9794  float.append('button.toc-float-btn')
9795    .at({'aria-label': 'Table of contents'})
9796    .html('<span class="ico">☰</span><span class="lbl">Contents</span>')
9797    .on('click', function(){ float.classed('open', !float.classed('open')) })
9798  var menu = float.append('div.toc-float-menu')
9799  menu.node().appendChild(nav.node().cloneNode(true))
9800  menu.on('click', function(e){
9801    if (e.target.closest('a')) float.classed('open', false)
9802  })
9803  d3.select(document).on('click.tocfloat', function(e){
9804    if (!float.node().contains(e.target)) float.classed('open', false)
9805  })
9806  window.__tocHeadings = d3.selectAll('d-article h2[data-secnum], d-article h3[data-secnum]').nodes()
9807  window.updateTocFloatLabel()
9808}
9809
9810window.init = async function(){
9811  var token = window.__initToken = {}
9812  if (!d3.select('.tooltip').size()) d3.select('body').append('div.tooltip.tooltip-hidden')
9813  window.buildToc()
9814  // figure data-fignum is server-side and __secNum is built above — resolve now
9815  // so refs don't sit at "??" through the (slow) figure data fetches below.
9816  util.resolveFigRefs()
9817  d3.selectAll('figure[data-fignum]').each(function(){
9818    var m = this.firstElementChild
9819    if (m && m.offsetWidth) this.style.setProperty('--mount-w', m.offsetWidth + 'px')
9820  })
9821  util.fitFigures()
9822  await Promise.all([
9823    [window.initIntroFunctional, 'intro-functional'],
9824    // The first panel caption (Multihop recall) carries "Figure N:"; the init
9825    // hides the gdoc figcaption's number so it appears exactly once.
9826    [window.initLensInline, 'lens-inline'],
9827    // No figIntro preamble — the first panel caption stays as short as its siblings
9828    // ("Figure N:" prefix + the spider/legs/8 sentence only).
9829    [opts => window.initLensInline({...opts, sel: '.lens-inline-reasoning', sliceLink: false}), 'lens-inline-reasoning'],
9830    [opts => window.initSliceStack({...opts, sel: '.slice-stack-count', slugs: 'count_introspect'}), 'slice-stack-count', 'lens-slice-ranks-s2'],
9831    [opts => window.initSliceInline({...opts, sel: '.slice-inline-count', slugs: 'count_five'}), 'slice-inline-count', 'lens-slice-ranks-s2'],
9832    [opts => window.initSliceInline({...opts, sel: '.slice-inline-introspection', slugs: 'introspection'}), 'slice-inline-introspection', 'lens-slice-ranks-s2'],
9833    [window.initLensSlice, 'lens-slice', 'lens-slice-ranks-s2'],
9834    [window.initOooAbcd, 'ooo-abcd'],
9835    [window.initOooPatch, 'ooo-patch'],
9836    [window.initFeatureExamples, 'feature-examples'],
9837    [window.initFeatureCoverage, 'feature-coverage'],
9838    [window.initCompLines, 'comp-lines-attn-adj', 'comp-lines'],
9839    [opts => window.initCompStrata({...opts, variant: 'sae6'}),  'comp-strata',       'comp-strata'],
9840    [opts => window.initCompStrata({...opts, variant: 'nosae'}), 'comp-strata-nosae', 'comp-strata'],
9841    [window.initCompTailEnergy, 'comp-tail-energy'],
9842    [window.initMlpGain, 'mlp-gain'],
9843    [window.initLensSimilarity, 'lens-similarity'],
9844    [window.initTranscoderArith, 'transcoder-arith'],
9845    [window.initTranscoderTranslation, 'transcoder-translation'],
9846    [window.initJlensCircuitGraph, 'jlens-circuit-graph'],
9847    [window.initAttnPanel, 'attn-panel'],
9848    [window.initAttnPanelAppendix, 'attn-panel-appendix'],
9849    [window.initAttnBroadcastReselect, 'attn-broadcast-reselect'],
9850    [window.initAblationBars, 'ablation-bars'],
9851    [window.initAblationExamples, 'ablation-examples'],
9852    [window.initAblationStrength, 'ablation-strength'],
9853    [window.initBlackmailClamp, 'blackmail-clamp'],
9854    [opts => window.initLensCalloutMain({...opts, sel: '.lens-callout-blackmail', fig: 'blackmail'}),
9855      'lens-callout-blackmail', 'lens-callout-main/sonnet45'],
9856    [opts => window.initLensCalloutMain({...opts, sel: '.lens-callout-evil-anthropic', fig: 'evil_anthropic'}),
9857      'lens-callout-evil-anthropic', 'lens-callout-main/opus45'],
9858    [opts => window.initLensappPanel({...opts, slug: 'eval_aware'}), 'lensapp-eval-aware', 'lensapp-panel'],
9859    [window.initMisalignLens, 'misalign-lens'],
9860    [window.initMisalignLensExample, 'misalign-lens-example', 'misalign-lens'],
9861    [window.initRewardHackReadout, 'reward-hack-readout', 'misalign-lens'],
9862    [window.initRewardHackQuant, 'reward-hack-quant', 'misalign-lens'],
9863    [window.initRoleplayLens, 'roleplay-lens'],
9864    [window.initRoleplayLensExample, 'roleplay-lens-example', 'roleplay-lens'],
9865    [window.initJlensRmBias, 'jlens-rm-bias'],
9866    [window.initJlensAudit, 'jlens-audit'],
9867    [window.initJlensRmBiasExamples, 'jlens-rm-bias-examples'],
9868    [window.initPrefViolationLens, 'pref-violation-lens'],
9869    [window.initReflectionTraining, 'reflection-training'],
9870    [window.initDnTracecond, 'dn-tracecond'],
9871    [window.initDnExclusion, 'dn-exclusion'],
9872    [opts => window.initReflectionTraining({...opts, sel: '.reflection-fabrication',
9873      figTitle: 'Fabrication admission eval'}), 'reflection-fabrication'],
9874    [opts => window.initReflectionTraining({...opts, sel: '.reflection-deception',
9875      figTitle: 'Deception refusal eval'}), 'reflection-deception'],
9876    [window.initReflTrainingExamples, 'refl-training-examples'],
9877    [window.initModulationReadout, 'modulation-readout'],
9878    [window.initModulationLines, 'modulation-lines'],
9879    [window.initModulationPrompts, 'modulation-prompts'],
9880    [window.initMethodsQualitative, 'methods-qualitative'],
9881    [window.initLatentPatching, 'latent-patching'],
9882    [opts => window.initLatentPatching({...opts, sel: '.flex-gen-example'}), 'flex-gen-example'],
9883    [window.initMultihopSwapSuccess, 'multihop-swap-success'],
9884    [window.initRepeatSwitch, 'repeat-switch'],
9885    [window.initVerbalReport, 'verbal-report'],
9886    [window.initVerbalIntrospection, 'verbal-introspection'],
9887    [window.initTopDownSummoning, 'top-down-summoning'],
9888    [opts => window.initTopDownSummoning({...opts, sel: '.top-down-summoning-appendix', spec: 'appendix'}), 'top-down-summoning-appendix', 'top-down-summoning'],
9889    [window.initFlexGenSystematic, 'flex-gen-systematic'],
9890    [opts => window.initFlexGenSystematic({...opts, sel: '.flex-gen-appendix', spec: 'appendix'}), 'flex-gen-appendix', 'flex-gen-systematic'],
9891    [window.initSelectivityLinecount, 'selectivity-linecount'],
9892    [window.initSelectivityLanguage, 'selectivity-language'],
9893    [opts => window.initPostTraining({...opts, sel: '.post-training-tylenol', spec: 'tylenol'}), 'post-training-tylenol', 'post-training'],
9894    [opts => window.initPostTraining({...opts, sel: '.post-training-danger', spec: 'danger'}), 'post-training-danger', 'post-training'],
9895    [opts => window.initPostTraining({...opts, sel: '.post-training-condolences', spec: 'condolences'}), 'post-training-condolences', 'post-training'],
9896    [opts => window.initPostTraining({...opts, sel: '.post-training-introspection', spec: 'introspection'}), 'post-training-introspection', 'post-training'],
9897    [window.initIntroStructural, 'intro-structural'],
9898    [window.initLayerDiagram, 'layer-diagram'],
9899    [opts => window.initLinePanels({...opts, spec: 'main'}),     'layer-lines-main',     'layer-lines'],
9900    [opts => window.initLinePanels({...opts, spec: 'appendix'}), 'layer-lines-appendix', 'layer-lines'],
9901    [window.initCapacityFveOccupancy, 'capacity-fve-occupancy'],
9902    [window.initBroadcastAblation, 'broadcast-ablation'],
9903    [window.initModulationProbe, 'modulation-probe'],
9904    [window.initProbeSwap, 'probe-swap'],
9905    [window.initVerbalReportDecompositionMerged, 'verbal-report-decomposition-merged'],
9906    // The six selfreport figures share one data dir.
9907    [window.initSelfreportSoc, 'selfreport-soc', 'selfreport'],
9908    [window.initSelfreportOther, 'selfreport-other', 'selfreport'],
9909    [window.initSelfreportQuestions, 'selfreport-questions', 'selfreport'],
9910    [window.initSelfreportStory, 'selfreport-story', 'selfreport'],
9911    [window.initSelfreportExamples, 'selfreport-examples', 'selfreport'],
9912    [window.initSelfreportDose, 'selfreport-dose', 'selfreport'],
9913    [window.initSelfreportControls, 'selfreport-controls', 'selfreport'],
9914    [window.initMetacogAlarm, 'metacog-alarm'],
9915    [window.initIgnition, 'ignition'],
9916    [opts => window.initIgnition({...opts, sel: '.ignition-d', show: 'd'}), 'ignition-d', 'ignition'],
9917    [opts => window.initIgnition({...opts, sel: '.ignition-e', show: 'e'}), 'ignition-e', 'ignition'],
9918    [window.initEvalAwarenessProbe, 'eval-awareness-probe'],
9919    [window.initDualTaskSimple, 'dual-task-simple'],
9920    [window.initCapacityFinalBand, 'capacity-final-band'],
9921    [window.initFlexGenExample, 'flex-generalization-example'],
9922    [window.initFlexGeneralizationSystematic, 'flex-generalization-systematic'],
9923    [window.initCapacityBandVsSingle, 'capacity-band-vs-single'],
9924    [window.initAppMtExamples, 'app-mt-examples'],
9925    [window.initAppMtBytok, 'app-mt-bytok'],
9926    [window.initAppMtPaper, 'app-mt-paper'],
9927    [window.initOracleLens, 'oracle-lens'],
9928  ].map(async ([fn, dp, dataDp]) => {
9929    var mount = document.querySelector('.' + dp)
9930    if (!mount || !fn) return
9931    var figNum = +mount.closest('figure')?.dataset.fignum || null
9932    try { await fn({datapath: `data/${dataDp || dp}/`, figNum}) } catch(e){ console.warn(dp, e) }
9933    if (token !== window.__initToken) return
9934    util.fitMount(mount)
9935  }))
9936  if (token !== window.__initToken) return
9937  util.resolveFigRefs()
9938  util.fitFigures()
9939}
9940window.init()

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.