File size: 23,975 Bytes
42d3c8f
e46cb4b
42d3c8f
e46cb4b
42d3c8f
e46cb4b
42d3c8f
 
 
 
e46cb4b
 
 
 
 
42d3c8f
e46cb4b
 
 
 
42d3c8f
e46cb4b
42d3c8f
e46cb4b
42d3c8f
e46cb4b
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
 
42d3c8f
 
e46cb4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42d3c8f
 
e46cb4b
 
 
 
 
 
 
 
 
 
 
 
42d3c8f
e46cb4b
42d3c8f
e46cb4b
42d3c8f
e46cb4b
 
 
 
42d3c8f
e46cb4b
 
42d3c8f
e46cb4b
 
 
42d3c8f
e46cb4b
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
 
 
42d3c8f
e46cb4b
 
 
42d3c8f
 
e46cb4b
 
 
42d3c8f
e46cb4b
 
 
42d3c8f
e46cb4b
 
42d3c8f
 
e46cb4b
 
 
 
 
 
42d3c8f
e46cb4b
 
 
 
 
 
 
 
 
 
42d3c8f
 
 
 
 
e46cb4b
42d3c8f
 
e46cb4b
42d3c8f
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
/* FinDendro leaderboard: every number comes from data.js. */
document.documentElement.classList.add('js');
const D = window.FD, M = D.models, CH = D.meta.chance;
const FRONT = M.filter(m => !m.small);
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const f2 = v => v.toFixed(2).replace('-', '−');
const f3 = v => (v > 0 ? '+' : '') + v.toFixed(3).replace('-', '−');
const pct = v => Math.round(v * 100) + '%';
const by = name => M.find(m => m.model === name);
const qn = s => +s.slice(0, 4) * 4 + +s.slice(5);
const quarters = (a, b) => qn(b) - qn(a);
const qX = s => +s.slice(0, 4) + (+s.slice(5) - .5) / 4;
const ymX = s => { const [y, m] = s.split('-').map(Number); return y + (m - .5) / 12; };
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const NS = 'http://www.w3.org/2000/svg';
const add = (p, tag, attrs, text) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; p.appendChild(e); return e; };
const svg = (w, h, label) => { const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', `0 0 ${w} ${h}`); s.setAttribute('width', '100%'); s.setAttribute('role', 'img'); s.setAttribute('aria-label', label); s.style.display = 'block'; return s; };
const mix = (a, b, t) => '#' + a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t))).toString(16).padStart(2, '0')).join('');
const WOOD = [243, 236, 220], TEAL = [8, 96, 88], RUST = [201, 82, 28], PALE = [241, 244, 238], HEAT = [10, 125, 116];

/* tooltip */
const tip = $('#tip');
const showTip = (html, e) => { tip.innerHTML = html; tip.classList.add('show'); const w = tip.offsetWidth, h = tip.offsetHeight; tip.style.transform = `translate(${Math.max(8, Math.min(innerWidth - w - 8, e.clientX + 12))}px,${e.clientY - h - 12 < 8 ? e.clientY + 16 : e.clientY - h - 12}px)`; };
const hideTip = () => tip.classList.remove('show');
document.addEventListener('pointermove', e => { const t = e.target.closest && e.target.closest('[data-tip]'); if (t) showTip(t.dataset.tip, e); else if (!(e.target.closest && e.target.closest('.live'))) hideTip(); });

/* scroll, reveal, nav */
const prog = $('#progress'), head = $('#header');
const onScroll = () => { const h = document.documentElement; prog.style.transform = `scaleX(${h.scrollTop / Math.max(1, h.scrollHeight - h.clientHeight)})`; head.classList.toggle('scrolled', h.scrollTop > 12); };
addEventListener('scroll', onScroll, { passive: true }); onScroll();
const io = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }), { rootMargin: '0px 0px -6% 0px' });
$$('.reveal').forEach(el => io.observe(el));
setTimeout(() => $$('.reveal').forEach(el => el.classList.add('in')), 2200);
const nav = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) $$('#navLinks a').forEach(a => a.classList.toggle('active', a.dataset.sec === e.target.id)); }), { rootMargin: '-40% 0px -55% 0px' });
$$('#navLinks a').forEach(a => { const s = document.getElementById(a.dataset.sec); if (s) nav.observe(s); });

/* pixel woodpecker, tapping at the rings */
(function bird() {
  const P = { k: '#10201c', r: '#c9521c', w: '#f4f1e6', t: '#0a7d74', y: '#d9962b' };
  const A = ['....rr......', '...rrrr.....', '..kkkkkk....', '.kkwkkkk....', 'yykkkkkkk...', '..kkttkkkk..', '...kttttkkk.', '....tttttkk.', '.....kk.ttkk', '.....y.y...k'];
  const B = A.map((r, i) => i < 5 ? '.' + r.slice(0, 11) : r);
  const c = document.createElement('canvas'); c.width = 12; c.height = 12; $('#bird').appendChild(c);
  const g = c.getContext('2d');
  const draw = f => { g.clearRect(0, 0, 12, 12); f.forEach((r, y) => [...r].forEach((p, x) => { if (P[p]) { g.fillStyle = P[p]; g.fillRect(x, y + 1, 1, 1); } })); };
  draw(A);
  if (!reduce) { let n = 0; setInterval(() => { n = (n + 1) % 8; draw(n < 4 && n % 2 ? B : A); }, 170); }
})();

/* hero */
(function stats() {
  const items = [[D.meta.facts, 'dated facts', v => v.toLocaleString('en-US')], [D.meta.prompts, 'prompts per model', v => v.toLocaleString('en-US')], [D.meta.to - D.meta.from + 1, 'years of markets', v => v], [M.length + D.pit.length, 'models and vintages', v => v]];
  $('#stats').innerHTML = items.map(([v, s, f], i) => `<div class="stat"><b data-i="${i}">${f(v)}</b><span>${s}</span></div>`).join('');
  if (reduce) return;
  items.forEach(([v, , f], i) => { const el = $(`.stat b[data-i="${i}"]`), t0 = performance.now(); (function step(t) { const p = Math.min(1, (t - t0) / 1100), e = 1 - Math.pow(1 - p, 3); el.textContent = f(Math.round(v * e)); if (p < 1) requestAnimationFrame(step); })(t0); });
})();
(function rotator() {
  const el = $('#rotator'), names = M.map(m => m.model); let i = 0; el.textContent = names[0];
  if (reduce) return;
  setInterval(() => { el.classList.add('off'); setTimeout(() => { i = (i + 1) % names.length; el.textContent = names[i]; el.classList.remove('off'); }, 260); }, 2200);
})();
(function ticker() {
  const items = [...M].sort((a, b) => b.knows - a.knows).map((m, i) => `<span class="tk"><i>#${i + 1}</i><b>${m.model}</b>knows <u>${f2(m.knows)}</u> · says <s>${f2(m.says)}</s></span>`).join('');
  $('#tickerTrack').innerHTML = items + items + items + items;
})();

/* cross-section: right half is what the model knows, left half what it says it knows */
let xsAuto = null;
function arc(r, a0, a1) { const p = a => [(r * Math.cos(a)).toFixed(2), (r * Math.sin(a)).toFixed(2)]; const [x0, y0] = p(a0), [x1, y1] = p(a1); return `M${x0},${y0}A${r},${r} 0 0 1 ${x1},${y1}`; }
function drawXS(name) {
  const m = by(name), S = D.timeline[name], s = svg(500, 500, `${name} cross-section. Knowledge ends ${m.know_end}, claims end ${m.claim_end}.`);
  s.setAttribute('viewBox', '-250 -250 500 500'); s.classList.add('live');
  let r = 20; add(s, 'circle', { r: 16, fill: mix(WOOD, TEAL, .12) });
  const after = (p, end) => p.length > 4 && qn(p) > qn(end);
  S.forEach((p, i) => {
    const w = p.p.length === 4 ? 6.4 : 3.1, rr = r + w / 2;
    const kCol = after(p.p, m.know_end) ? mix(WOOD, TEAL, .05) : mix(WOOD, TEAL, (p.acc - CH) / (1 - CH) * .94 + .06);
    const cCol = after(p.p, m.claim_end) ? mix(WOOD, RUST, .05) : mix(WOOD, RUST, p.claim * .94 + .06);
    const g = add(s, 'g', { class: 'grow' }); g.style.animationDelay = (i * 15) + 'ms';
    add(g, 'path', { d: arc(rr, -Math.PI / 2, Math.PI / 2), fill: 'none', stroke: kCol, 'stroke-width': w - .45 });
    add(g, 'path', { d: arc(rr, Math.PI / 2, Math.PI * 1.5), fill: 'none', stroke: cCol, 'stroke-width': w - .45 });
    p._r0 = r; p._r1 = r + w; r += w;
  });
  const ring = (p, a0, a1, col) => { if (p) add(s, 'path', { d: arc(p._r1, a0, a1), fill: 'none', stroke: col, 'stroke-width': 2.2 }); };
  ring(S.find(p => p.p === m.know_end), -Math.PI / 2, Math.PI / 2, '#10201c');
  ring(S.find(p => p.p === m.claim_end), Math.PI / 2, Math.PI * 1.5, '#10201c');
  add(s, 'line', { x1: 0, x2: 0, y1: -r - 2, y2: r + 2, stroke: '#fff', 'stroke-width': 2.5 });
  s.addEventListener('pointermove', e => {
    const b = s.getBoundingClientRect(), dx = (e.clientX - b.left) / b.width * 500 - 250, dy = (e.clientY - b.top) / b.height * 500 - 250, d = Math.hypot(dx, dy);
    const p = S.find(q => d >= q._r0 && d < q._r1);
    if (p) showTip(`<b>${p.p}</b><br>Knows ${f2(p.acc)}<br>Says ${f2(p.claim)}`, e); else hideTip();
  });
  s.addEventListener('pointerleave', hideTip);
  $('#xs').replaceChildren(s);
  $('#xsEnds').innerHTML = [['Knows', f2(m.knows)], ['Says', f2(m.says)], ['Knowledge ends', m.know_end], ['Claims end', m.claim_end]].map(([a, b]) => `<div><b>${b}</b>${a}</div>`).join('');
  $$('#xsChips .chip').forEach(c => c.setAttribute('aria-selected', c.dataset.m === name));
}
$('#xsChips').innerHTML = FRONT.map(m => `<button class="chip" role="tab" data-m="${m.model}" type="button">${m.model}</button>`).join('');
$$('#xsChips .chip').forEach(c => c.onclick = () => { clearInterval(xsAuto); drawXS(c.dataset.m); });
drawXS(FRONT[0].model);
if (!reduce) { let i = 0; xsAuto = setInterval(() => { if (document.hidden) return; i = (i + 1) % FRONT.length; drawXS(FRONT[i].model); }, 6000); }

/* replay: the reader answers, then the recorded answers appear */
(function replay() {
  const P = D.pool, tally = { You: [0, 0] }; FRONT.forEach(m => tally[m.model] = [0, 0]);
  let i = 0, done = false;
  const val = (f, r) => (f.opts.find(o => o[0] === r) || [, ''])[1].replace(/ USD$| JPY per USD$/, '');
  const months = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'];
  function score() { const [a, b] = tally.You; $('#aScore').textContent = b ? `You ${a} of ${b}` : ''; }
  function rows(f, show) {
    $('#aRows').innerHTML = FRONT.map(m => {
      const a = f.ans[m.model].r, t = tally[m.model];
      if (!show) return `<div class="a-row"><span class="a-m">${m.model}</span><span class="a-v wait">?</span><span class="a-s wait">waiting for your answer</span><span class="a-tally"></span></div>`;
      if (!a) return `<div class="a-row"><span class="a-m">${m.model}</span><span class="a-v wait">n/a</span><span class="a-s wait">gave no usable answer</span><span class="a-tally">${t[0]} of ${t[1]}</span></div>`;
      const ok = a[1] === f.true;
      return `<div class="a-row"><span class="a-m">${m.model}</span><span class="a-v ${ok ? 'ok' : 'no'}">${val(f, a[1])}</span><span class="a-s ${a[0] ? 'know' : 'deny'}">${a[0] ? '“I know this value.”' : '“I do not know this value.”'}</span><span class="a-tally">${t[0]} of ${t[1]}</span></div>`;
    }).join('');
  }
  function load() {
    const f = P[i]; done = false;
    const [y, mo] = f.period.split('-');
    $('#qKick').textContent = `${f.family} · ${months[+mo - 1]} ${y}`;
    $('#qN').textContent = `Fact ${i + 1} of ${P.length}`;
    $('#qText').textContent = f.q.replace(/ Use FRED series.*$| Round to .*$/, '');
    const box = $('#qOpts'); box.replaceChildren();
    [...f.opts].sort((a, b) => (+a[1].replace(/[^\d.-]/g, '') || 0) - (+b[1].replace(/[^\d.-]/g, '') || 0)).forEach(o => {
      const b = document.createElement('button'); b.type = 'button'; b.className = 'q-opt'; b.dataset.r = o[0];
      const t = document.createElement('span'); t.textContent = o[1]; b.appendChild(t);
      b.onclick = () => finish(o[0]); box.appendChild(b);
    });
    $('#qReveal').hidden = false; $('#qNext').hidden = true; $('#aPunch').hidden = true;
    rows(f, false); score();
  }
  function finish(pick) {
    if (done) return; done = true;
    const f = P[i];
    if (pick != null) { tally.You[1]++; if (pick === f.true) tally.You[0]++; }
    FRONT.forEach(m => { const a = f.ans[m.model].r; if (a) { tally[m.model][1]++; if (a[1] === f.true) tally[m.model][0]++; } });
    $$('.q-opt').forEach(b => { b.disabled = true; const r = +b.dataset.r; if (r === f.true) b.classList.add('right'); if (r === pick) b.classList.add('you'); });
    rows(f, true); score();
    const leak = FRONT.filter(m => { const a = f.ans[m.model].r; return a && !a[0] && a[1] === f.true; }).map(m => m.model);
    const p = $('#aPunch');
    if (leak.length) { p.textContent = `${leak.join(', ')} denied knowing it and picked the published value anyway.`; p.hidden = false; }
    $('#qReveal').hidden = true; $('#qNext').hidden = false;
  }
  $('#qReveal').onclick = () => finish(null);
  $('#qNext').onclick = () => { i = (i + 1) % P.length; load(); };
  load();
})();

/* leaderboard */
const COLS = [['model', 'Model', 'left'], ['knows', 'Knows', 'heat'], ['says', 'Says', 'heat'], ['acc_says', 'Right when claiming', 'heat'], ['acc_denies', 'Right when denying', 'heat'], ['gap', 'Gap [95% CI]'], ['know_end', 'Knowledge ends'], ['claim_end', 'Claims end']];
let sortK = 'knows', sortDir = -1, openM = null;
const heat = v => { const t = Math.max(0, Math.min(1, (v - .15) / .8)); return `background:${mix(PALE, HEAT, t)};color:${t > .55 ? '#fff' : 'var(--ink)'}`; };
function detail(m) {
  const fam = D.family[m.model];
  const lines = fam ? Object.keys(fam).map(k => `<div class="dt-line"><span>${k}</span><b>knows ${f2(fam[k].know)} · says ${f2(fam[k].says)}</b></div>`).join('') : '<p>Not broken down for the small open models.</p>';
  const lag = m.know_end ? quarters(m.know_end, m.claim_end) : null;
  const read = m.know_end ? (lag > 1 ? `Keeps claiming knowledge for ${lag} quarters after it stops knowing.` : 'Its claims stop within a quarter of its knowledge.') : 'Accuracy sits too close to chance for the rule to locate an end.';
  return `<div class="dt"><div><h4>Reading</h4><p>${read}</p><div class="dt-line" style="margin-top:8px"><span>Says when asked</span><b>${m.self_report || 'n/a'}</b></div><div class="dt-line"><span>Vendor cutoff</span><b>${m.vendor_cutoff || 'n/a'}</b></div></div>
    <div><h4>By fact family</h4>${lines}</div>
    <div><h4>With a declared date</h4><div class="dt-line"><span>Claims to already know</span><b>${f2(m.claims)} → ${f2(m.claims_declared)}</b></div><div class="dt-line"><span>Accuracy</span><b>${f2(m.acc)} → ${f2(m.acc_declared)}</b></div></div></div>`;
}
function renderLB() {
  const rows = [...M].sort((a, b) => { const x = a[sortK], y = b[sortK]; if (x == null && y == null) return 0; if (x == null) return 1; if (y == null) return -1; return (x < y ? -1 : x > y ? 1 : 0) * sortDir; });
  const na = '<span class="na">n/a</span>';
  const head = `<thead><tr><th>#</th>${COLS.map(([k, t, c]) => `<th data-k="${k}" tabindex="0" role="button" class="${c === 'left' ? 'left' : ''}${k === sortK ? ' on' : ''}">${t}${k === sortK ? (sortDir < 0 ? ' ↓' : ' ↑') : ''}</th>`).join('')}<th></th></tr></thead>`;
  const body = rows.map((m, i) => {
    const lag = m.know_end && quarters(m.know_end, m.claim_end) > 1, open = openM === m.model;
    return `<tr class="row${open ? ' open' : ''}" data-m="${m.model}" tabindex="0" aria-expanded="${open}"><td><span class="pos ${i < 3 ? 'p' + (i + 1) : ''}">${i + 1}</span></td>
      <td class="left"><span class="mname">${m.model}</span><span class="mv">${m.vendor}</span>${m.small ? '<span class="tag">small</span>' : ''}</td>
      ${['knows', 'says', 'acc_says', 'acc_denies'].map(k => `<td><span class="heat" style="${heat(m[k])}">${f2(m[k])}</span></td>`).join('')}
      <td>${f2(m.gap)}<span class="ci">[${f2(m.gap_lo)}, ${f2(m.gap_hi)}]</span></td>
      <td>${m.know_end || na}</td><td class="${lag ? 'late' : ''}">${m.claim_end || na}</td><td><span class="chev">›</span></td></tr>` +
      (open ? `<tr class="detail"><td colspan="${COLS.length + 2}">${detail(m)}</td></tr>` : '');
  }).join('');
  const t = $('#lb'); t.innerHTML = head + `<tbody>${body}</tbody>`;
  $$('th[data-k]', t).forEach(th => { const go = () => { const k = th.dataset.k; if (k === sortK) sortDir = -sortDir; else { sortK = k; sortDir = k === 'model' ? 1 : -1; } renderLB(); }; th.onclick = go; th.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }; });
  $$('tr.row', t).forEach(r => { const go = () => { openM = openM === r.dataset.m ? null : r.dataset.m; renderLB(); }; r.onclick = go; r.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }; });
}
openM = FRONT[0].model; renderLB();

/* cutoff calendar: one row per model */
function drawCalendar() {
  const el = $('#calendar'), W = Math.max(340, el.clientWidth || 900), small = W < 560, L = small ? 76 : 118, R = 54, T = 8, rowH = 58, x0 = 2021, x1 = 2025.25, H = T + rowH * FRONT.length + 24;
  const s = svg(W, H, 'Knowledge end, claims end, self-reported and vendor cutoffs by model'); const x = v => L + (v - x0) / (x1 - x0) * (W - L - R);
  for (let y = 2021; y <= 2025; y++) { add(s, 'line', { x1: x(y), x2: x(y), y1: T, y2: H - 22, stroke: 'rgba(14,84,70,.09)' }); add(s, 'text', { x: x(y), y: H - 6, 'text-anchor': 'middle', 'font-size': 11.5, fill: 'var(--soft)' }, y); }
  FRONT.forEach((m, i) => {
    const y = T + rowH * (i + .5), kx = x(qX(m.know_end)), cx = x(qX(m.claim_end));
    add(s, 'text', { x: L - 14, y: y + 4, 'text-anchor': 'end', 'font-size': small ? 11 : 13, 'font-weight': 500, fill: 'var(--ink)' }, small ? m.model.replace('-70B', '').replace('DeepSeek-', 'DS-') : m.model);
    add(s, 'line', { x1: L, x2: W - R, y1: y + rowH / 2, y2: y + rowH / 2, stroke: 'rgba(14,84,70,.07)' });
    if (cx > kx) add(s, 'rect', { x: kx, y: y - 18, width: cx - kx, height: 36, style: 'fill:var(--says);opacity:.16', rx: 3 });
    const lag = quarters(m.know_end, m.claim_end);
    if (lag > 1) add(s, 'text', { x: (kx + cx) / 2, y: y + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': 500, fill: '#b34a17' }, `${lag} quarters`);
    const hit = (cxv, label, val, draw) => { draw(); const r = add(s, 'circle', { cx: cxv, cy: y, r: 16, fill: 'transparent' }); r.dataset.tip = `<b>${m.model}</b><br>${label}: ${val}`; };
    if (m.vendor_cutoff) { const v = x(ymX(m.vendor_cutoff)); hit(v, 'Vendor cutoff', m.vendor_cutoff, () => add(s, 'rect', { x: v - 5, y: y + 8, width: 10, height: 10, style: 'fill:#fff;stroke:var(--vendor);stroke-width:2' })); }
    const sv = x(ymX(m.self_report)); hit(sv, 'Says when asked', m.self_report, () => add(s, 'path', { d: `M${sv},${y - 19}l6,11h-12z`, style: 'fill:var(--ink)' }));
    hit(cx, 'Claims end', m.claim_end, () => add(s, 'rect', { x: cx - 5.5, y: y - 5.5, width: 11, height: 11, transform: `rotate(45 ${cx} ${y})`, style: 'fill:var(--says);stroke:#fff;stroke-width:1.5' }));
    hit(kx, 'Knowledge ends', m.know_end, () => add(s, 'circle', { cx: kx, cy: kx === cx ? y - 11 : y, r: 6.5, style: 'fill:var(--knows);stroke:#fff;stroke-width:1.5' }));
  });
  el.replaceChildren(s);
}

/* core chart */
let coreM = FRONT[0].model;
function drawCore() {
  const el = $('#coreChart'), m = by(coreM), W = Math.max(320, el.clientWidth || 900), H = W < 560 ? 240 : 310, L = 36, R = 12, T = 12, B = 26, x0 = 2015, x1 = 2026.9;
  const pts = D.timeline[coreM].filter(r => r.x >= x0);
  const s = svg(W, H, `${coreM}: accuracy and claim rate from ${x0}`); s.classList.add('live');
  const x = v => L + (v - x0) / (x1 - x0) * (W - L - R), y = v => T + (1 - v) * (H - T - B);
  [0, .25, .5, .75, 1].forEach(v => { add(s, 'line', { x1: L, x2: W - R, y1: y(v), y2: y(v), stroke: 'rgba(14,84,70,.09)' }); add(s, 'text', { x: L - 7, y: y(v) + 4, 'text-anchor': 'end', 'font-size': 11, fill: 'var(--soft)' }, v.toFixed(2)); });
  for (let yr = 2016; yr <= 2026; yr += W < 560 ? 2 : 1) add(s, 'text', { x: x(yr + .5), y: H - 7, 'text-anchor': 'middle', 'font-size': 11, fill: 'var(--soft)' }, yr);
  add(s, 'line', { x1: L, x2: W - R, y1: y(CH), y2: y(CH), style: 'stroke:var(--chance)', 'stroke-width': 1.3, 'stroke-dasharray': '3 3' });
  const kx = x(qX(m.know_end)), cx = x(qX(m.claim_end));
  if (cx > kx) add(s, 'rect', { x: kx, y: T, width: cx - kx, height: H - T - B, style: 'fill:var(--says);opacity:.1' });
  add(s, 'line', { x1: kx, x2: kx, y1: T, y2: H - B, style: 'stroke:var(--knows)', 'stroke-width': 2 });
  add(s, 'line', { x1: cx, x2: cx, y1: T, y2: H - B, style: 'stroke:var(--says)', 'stroke-width': 2, 'stroke-dasharray': m.know_end === m.claim_end ? '5 5' : 'none' });
  const path = k => pts.map((r, i) => `${i ? 'L' : 'M'}${x(r.x).toFixed(1)},${y(r[k]).toFixed(1)}`).join('');
  [['claim', 'var(--says)'], ['acc', 'var(--knows)']].forEach(([k, c]) => {
    const p = add(s, 'path', { d: path(k), fill: 'none', style: `stroke:${c}`, 'stroke-width': 2.4, 'stroke-linejoin': 'round' });
    if (!reduce && drawCore.anim) { const n = 2600; p.style.strokeDasharray = n; p.style.strokeDashoffset = n; requestAnimationFrame(() => { p.style.transition = 'stroke-dashoffset 1.1s ease-out'; p.style.strokeDashoffset = 0; }); }
  });
  drawCore.anim = false;
  const cross = add(s, 'line', { y1: T, y2: H - B, stroke: 'rgba(16,32,28,.35)', visibility: 'hidden' });
  const dA = add(s, 'circle', { r: 4.5, style: 'fill:var(--knows);stroke:#fff;stroke-width:1.5', visibility: 'hidden' }), dC = add(s, 'circle', { r: 4.5, style: 'fill:var(--says);stroke:#fff;stroke-width:1.5', visibility: 'hidden' });
  el.replaceChildren(s);
  s.addEventListener('pointermove', e => {
    const b = s.getBoundingClientRect(), mx = (e.clientX - b.left) / b.width * W; let r = pts[0]; pts.forEach(p => { if (Math.abs(x(p.x) - mx) < Math.abs(x(r.x) - mx)) r = p; });
    cross.setAttribute('x1', x(r.x)); cross.setAttribute('x2', x(r.x)); dA.setAttribute('cx', x(r.x)); dA.setAttribute('cy', y(r.acc)); dC.setAttribute('cx', x(r.x)); dC.setAttribute('cy', y(r.claim));
    [cross, dA, dC].forEach(n => n.setAttribute('visibility', 'visible')); showTip(`<b>${r.p}</b> · ${r.n} facts<br>Accuracy ${f2(r.acc)}<br>Claim rate ${f2(r.claim)}`, e);
  });
  s.addEventListener('pointerleave', () => { [cross, dA, dC].forEach(n => n.setAttribute('visibility', 'hidden')); hideTip(); });
  $('#coreNote').textContent = `Yearly up to 2020 and quarterly after. ${coreM} knowledge ends ${m.know_end} and claims end ${m.claim_end}.`;
  $$('#coreChips .chip').forEach(c => c.setAttribute('aria-selected', c.dataset.m === coreM));
}
$('#coreChips').innerHTML = FRONT.map(m => `<button class="chip" role="tab" data-m="${m.model}" type="button">${m.model}</button>`).join('');
$$('#coreChips .chip').forEach(c => c.onclick = () => { coreM = c.dataset.m; drawCore.anim = true; drawCore(); });

/* fact families */
(function fam() {
  const names = Object.keys(D.family), order = ['Fed decisions', 'Macroeconomic', 'Stock prices'];
  $('#fam').innerHTML = order.map(f => `<div class="card"><h3>${f}</h3><div class="fam-sub"><span><i style="background:var(--knows)"></i>Knows</span><span><i style="background:var(--says)"></i>Says</span></div>${names.map(m => {
    const v = D.family[m][f]; return `<div class="fr"><span>${m}</span><div class="fr-b"><div class="fb k" data-tip="<b>${m}</b><br>Knows ${f2(v.know)}"><i style="width:${v.know * 100}%"></i>${f2(v.know)}</div><div class="fb c" data-tip="<b>${m}</b><br>Says ${f2(v.says)}"><i style="width:${v.says * 100}%"></i>${f2(v.says)}</div></div></div>`;
  }).join('')}</div>`).join('');
})();

/* declared date toggle */
(function declared() {
  const row = (m, k, c) => `<div class="hb-r"><span>${m.model}</span><div class="hb-t"><i data-a="${m[k]}" data-b="${m[k + '_declared']}" style="background:${c}"></i></div><span class="hb-v" data-a="${f2(m[k])}" data-b="${f2(m[k + '_declared'])}"></span></div>`;
  $('#declC').innerHTML = M.map(m => row(m, 'claims', 'var(--says)')).join('');
  $('#declA').innerHTML = M.map(m => row(m, 'acc', 'var(--knows)')).join('');
  const sw = $('#dateSw');
  const set = on => { sw.setAttribute('aria-checked', on); $('#dateCode').classList.toggle('on', on); $$('.hb-t i').forEach(i => i.style.width = (on ? i.dataset.b : i.dataset.a) * 100 + '%'); $$('.hb-v').forEach(v => v.textContent = on ? v.dataset.b : v.dataset.a); };
  sw.onclick = () => set(sw.getAttribute('aria-checked') !== 'true');
  set(false);
})();

/* point-in-time */
(function pit() {
  const lo = -.08, hi = .12, x = v => (v - lo) / (hi - lo) * 100;
  $('#pitT').innerHTML = `<thead><tr><th class="left">Model</th><th>Cutoff</th><th>Up to cutoff</th><th>After</th><th>Difference [95% CI]</th><th class="left">Interval around zero</th></tr></thead><tbody>` +
    D.pit.map(r => `<tr><td class="left mname">${r.model}</td><td>${r.cutoff}</td><td>${f2(r.before)}</td><td>${f2(r.after)}</td><td>${f3(r.diff)}<span class="ci">[${f3(r.lo)}, ${f3(r.hi)}]</span></td><td class="left"><span class="forest" data-tip="<b>${r.model} ${r.cutoff}</b><br>Difference ${f3(r.diff)}"><s style="left:${x(0)}%"></s><i style="left:${x(r.lo)}%;width:${x(r.hi) - x(r.lo)}%"></i><b style="left:${x(r.diff)}%"></b></span></td></tr>`).join('') + '</tbody>';
})();

const drawAll = () => { drawCalendar(); drawCore(); };
drawAll();
let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(drawAll, 120); });