-
Notifications
You must be signed in to change notification settings - Fork 553
Expand file tree
/
Copy pathgen-context-chart.ts
More file actions
432 lines (401 loc) · 15.5 KB
/
Copy pathgen-context-chart.ts
File metadata and controls
432 lines (401 loc) · 15.5 KB
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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
/**
* gen-context-chart.ts — README chart: how many *characters* a frontier context
* window has held over time, GPT-1 (2018) → Fable 5 / Grok 4.5 (2026), as a
* line chart on a real year axis. One pxpipe overlay is measured live for
* Fable (the default reader); Grok appears as a plain text-window series only
* (opt-in, not Fable-level pure-image quality).
*
* npx tsx scripts/gen-context-chart.ts
*
* Writes docs/assets/context-window-chars.png and prints the data table.
*
* Method
* - Text points: window tokens × 4 chars/token (the standard English-prose
* rule of thumb; token-dense content like code/JSON tokenizes *worse*,
* ~2-2.5, so 4 is the generous assumption for the text series).
* - Fable pxpipe point: window tokens × measured chars-per-vision-token via
* Claude dense geometry + Anthropic 750 px/token pricing.
*
* Window sizes (announcement-era frontier defaults), with release dates used
* for x-axis placement:
* GPT-1 512 (Jun 2018, Radford et al.) · GPT-2 1,024 (Feb 2019) · GPT-3
* 2,048 (May 2020, Brown et al.) · GPT-3.5/ChatGPT 4,096 (Nov 2022) · GPT-4
* 8,192 base (Mar 2023) · Claude 2 100K (Jul 2023) · GPT-4 Turbo 128K (Nov
* 2023) · Claude 2.1 200K (Nov 2023) · Gemini 1.5 Pro 1M (Feb 2024) ·
* GPT-4.1 1,047,576 (Apr 2025) · Fable 5 200K standard, 1M as
* claude-fable-5[1m] (2026) · Grok 4.5 500K (2026, xAI maxPromptLength).
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createCanvas } from '@napi-rs/canvas';
import { renderTextToImages } from '../src/core/library.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'docs/assets/context-window-chars.png');
const README_EXAMPLE = join(ROOT, 'docs/assets/example-render.png');
const TEXT_CPT = 4; // chars per text token, prose rule of thumb
const PX_PER_VISION_TOKEN = 750; // Anthropic image pricing: tokens = w*h/750
interface Density {
/** chars per vision-token for this family's render+billing profile */
cpt: number;
pages: number;
pixels: number;
visionTokens: number;
}
// ---------------------------------------------------------------------------
// 1. Measure chars-per-vision-token through the real pipeline.
// ---------------------------------------------------------------------------
function loadFixture(): string {
// Representative token-dense context — the kinds of content pxpipe actually
// images (markdown docs, TS source, JSON), taken from this repo itself.
// Literal ↵ sentinels are stripped: these files *document* the sentinel, and
// reflow() deliberately bails on source that already contains it.
return [
readFileSync(join(ROOT, 'README.md'), 'utf8'),
readFileSync(join(ROOT, 'docs/CACHING_AND_SAVINGS.md'), 'utf8'),
readFileSync(join(ROOT, 'src/core/library.ts'), 'utf8'),
readFileSync(join(ROOT, 'package.json'), 'utf8'),
]
.join('\n\n')
.replaceAll('↵', '');
}
async function measureFableDensity(fixture: string): Promise<Density> {
// Claude dense geometry (default library path): 312-col Spleen, Anthropic height.
const r = await renderTextToImages(fixture, { reflow: true });
if (r.droppedChars > 0) {
throw new Error(`fable fixture dropped ${r.droppedChars} chars — atlas gap, fix before charting`);
}
const visionTokens = Math.ceil(r.pixels / PX_PER_VISION_TOKEN);
const cpt = fixture.length / visionTokens;
console.log(
`Fable density: ${fixture.length} chars → ${r.pages.length} pages, ` +
`${r.pixels} px = ${visionTokens} vision tokens → ${cpt.toFixed(2)} chars/vision-token`,
);
return { cpt, pages: r.pages.length, pixels: r.pixels, visionTokens };
}
// ---------------------------------------------------------------------------
// 2. Data
// ---------------------------------------------------------------------------
type LabelSide = 'above' | 'below' | 'left' | 'right';
interface Point {
name: string;
/** Release date as fractional year — x position. */
x: number;
/** Model context window, in tokens. */
tokens: number;
chars: number;
/** Vendor series — each gets its own line. 'pxpipe' is a measured overlay. */
kind: 'openai' | 'gemini' | 'claude' | 'grok' | 'pxpipe';
/** Where to hang the label so the cluttered 2023 cluster stays readable. */
label: LabelSide;
/** Extra vertical label offset (px) — staggers the linear-scale floor pileup. */
dy?: number;
}
function points(fableCpt: number): Point[] {
const t = (
kind: Point['kind'],
name: string,
x: number,
tokens: number,
label: LabelSide,
dy?: number,
): Point => ({ name, x, tokens, chars: tokens * TEXT_CPT, kind, label, dy });
return [
t('openai', 'GPT-1', 2018.45, 512, 'above'),
t('openai', 'GPT-2', 2019.12, 1_024, 'above', -36),
t('openai', 'GPT-3', 2020.4, 2_048, 'above'),
t('openai', 'GPT-3.5', 2022.91, 4_096, 'above', -36),
t('openai', 'GPT-4', 2023.2, 8_192, 'right'),
t('claude', 'Claude 2', 2023.53, 100_000, 'left', -14),
t('openai', 'GPT-4 Turbo', 2023.85, 128_000, 'right', -26),
t('claude', 'Claude 2.1', 2023.89, 200_000, 'above', -30),
t('gemini', 'Gemini 1.0 Pro', 2023.93, 32_768, 'right', 4),
// Feb '24: 128K standard / 1M limited preview; 1M GA May 23, 2024; 2M opened
// to all developers June 27, 2024 — plotted at its 2M peak. The 2M window
// retired with 1.5's deprecation (2.5 Pro shipped 1M), so the line dips.
t('gemini', 'Gemini 1.5 Pro', 2024.49, 2_097_152, 'left'),
t('openai', 'GPT-4.1', 2025.28, 1_047_576, 'below'),
// Gemini 2.5 Pro: 1M window, GA at Google I/O May 20, 2025
t('gemini', 'Gemini 2.5 Pro', 2025.38, 1_000_000, 'above'),
// Sonnet 4: 1M public beta (Tier 4) Aug 12, 2025 — Anthropic's first 1M;
// beta retired Apr 30, 2026 as 1M became standard on newer models
t('claude', 'Sonnet 4 [1m]', 2025.61, 1_000_000, 'below', 36),
// GPT-5.6 (Sol/Terra/Luna): GA July 9, 2026 — API docs list 1.05M ctx (1.5M was a pre-launch leak)
t('openai', 'GPT-5.6', 2026.52, 1_050_000, 'right'),
// the model powering this session: claude-fable-5[1m], 1M-token window
t('claude', 'Fable 5 [1m]', 2026.05, 1_000_000, 'below'),
// Grok 4.5: text-window only (opt-in; no pxpipe overlay on this chart).
// Window = xAI docs maxPromptLength for grok-4.5 (500000).
t('grok', 'Grok 4.5', 2026.42, 500_000, 'below', 10),
{
name: 'Fable 5 [1m] + pxpipe',
x: 2026.05,
tokens: 1_000_000,
chars: Math.round(1_000_000 * fableCpt),
kind: 'pxpipe',
label: 'above',
},
];
}
const fmt = (v: number): string =>
v >= 1e6
? `${+(v / 1e6).toFixed(v >= 1e7 ? 0 : 1)}M`
: v >= 1e3
? `${+(v / 1e3).toFixed(v >= 1e4 ? 0 : 1)}K`
: String(v);
/** Token windows read best in power-of-two units: 4,096 → "4K", 1,047,576 → "1M". */
const fmtTok = (t: number): string =>
t >= 1e6
? `${Math.round(t / 1e6)}M`
: t >= 1024
? t % 1024 === 0 && t < 10_000
? `${t / 1024}K`
: `${Math.round(t / 1e3)}K`
: String(t);
// ---------------------------------------------------------------------------
// 3. Draw
// ---------------------------------------------------------------------------
function pngWidth(path: string): number {
const png = readFileSync(path);
if (png.length < 24 || png.toString('ascii', 1, 4) !== 'PNG') {
throw new Error(`${path} is not a PNG`);
}
return png.readUInt32BE(16);
}
function draw(data: Point[], fableCpt: number): Buffer {
const logicalWidth = 1180;
const logicalHeight = 1000;
const outputWidth = pngWidth(README_EXAMPLE);
const scale = outputWidth / logicalWidth;
const canvas = createCanvas(outputWidth, Math.round(logicalHeight * scale));
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
const bg = '#0d1117';
const grid = '#21262d';
const axis = '#8b949e';
const ink = '#e6edf3';
const dim = '#8b949e';
const colors: Record<Point['kind'], string> = {
openai: '#10a37f',
gemini: '#a371f7',
claude: '#58a6ff',
grok: '#e3b341',
pxpipe: '#f0883e',
};
ctx.fillStyle = bg;
ctx.fillRect(0, 0, 1180, 1000);
// Title block
ctx.fillStyle = ink;
ctx.font = '600 24px sans-serif';
ctx.fillText('Characters a frontier context window holds', 36, 44);
ctx.fillStyle = dim;
ctx.font = '400 14px sans-serif';
ctx.fillText(
`each point: model · context window (tokens) → characters it holds · text at ~${TEXT_CPT} chars/token · ` +
`Fable pxpipe measured at ${fableCpt.toFixed(1)} chars/vision-token (px ÷ ${PX_PER_VISION_TOKEN})`,
36,
68,
);
// Plot area
const left = 76;
const right = 1180 - 36;
const top = 100;
const bottom = 1000 - 72;
// Linear scale — log gave every decade equal height, which flattened the whole
// point of the chart: 18M must physically tower ~4.5× over the 4M pack.
const Y_MAX = 20_000_000;
const X_MIN = 2018;
const X_MAX = 2027.25;
const y = (v: number) => bottom - (v / Y_MAX) * (bottom - top);
const x = (v: number) => left + ((v - X_MIN) / (X_MAX - X_MIN)) * (right - left);
// Horizontal gridlines + y labels (every 2M — 18M lands exactly on a line)
ctx.font = '400 13px sans-serif';
for (let v = 0; v <= Y_MAX; v += 2_000_000) {
const gy = y(v);
ctx.strokeStyle = grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(left, gy);
ctx.lineTo(right, gy);
ctx.stroke();
ctx.fillStyle = axis;
ctx.textAlign = 'right';
ctx.fillText(fmt(v), left - 10, gy + 4);
}
// Vertical year gridlines + x labels
ctx.textAlign = 'center';
for (let yr = 2018; yr <= 2026; yr++) {
const gx = x(yr);
ctx.strokeStyle = grid;
ctx.beginPath();
ctx.moveTo(gx, top);
ctx.lineTo(gx, bottom);
ctx.stroke();
ctx.fillStyle = axis;
ctx.fillText(String(yr), gx, bottom + 24);
}
// Axis titles
ctx.save();
ctx.translate(20, (top + bottom) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = axis;
ctx.fillText('characters (linear scale)', 0, 0);
ctx.restore();
ctx.fillStyle = axis;
ctx.fillText('release year', (left + right) / 2, 1000 - 40);
// One line per vendor series (chronological within each series)
for (const vendor of ['openai', 'gemini', 'claude', 'grok'] as const) {
const pts = data.filter((p) => p.kind === vendor);
ctx.strokeStyle = colors[vendor];
ctx.lineWidth = 2;
ctx.beginPath();
pts.forEach((p, i) => {
if (i === 0) ctx.moveTo(x(p.x), y(p.chars));
else ctx.lineTo(x(p.x), y(p.chars));
});
ctx.stroke();
}
// Dashed vertical connector: Fable text window → same window imaged (pxpipe).
// Grok is plotted as a text series only (no pxpipe overlay).
const overlays: Array<{ textName: string; pxName: string }> = [
{ textName: 'Fable 5 [1m]', pxName: 'Fable 5 [1m] + pxpipe' },
];
for (const o of overlays) {
const base = data.find((p) => p.name === o.textName)!;
const px = data.find((p) => p.name === o.pxName)!;
const cx = x(base.x); // same as x(px.x) — both points share the year
const y0 = y(base.chars) - 7;
const y1 = y(px.chars) + 8;
ctx.strokeStyle = colors.pxpipe;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(cx, y0);
ctx.lineTo(cx, y1);
ctx.stroke();
ctx.setLineDash([]);
ctx.textAlign = 'left';
const annLine1 = `${(px.chars / base.chars).toFixed(1)}×`;
const annLine2 = 'same window, imaged';
const annX = cx + 12;
const annY = (y0 + y1) / 2 + 4;
ctx.lineJoin = 'round';
ctx.strokeStyle = bg;
ctx.lineWidth = 5;
ctx.font = '700 15px sans-serif';
ctx.strokeText(annLine1, annX, annY - 9);
ctx.fillStyle = colors.pxpipe;
ctx.fillText(annLine1, annX, annY - 9);
ctx.font = '600 12px sans-serif';
ctx.strokeText(annLine2, annX, annY + 9);
ctx.fillText(annLine2, annX, annY + 9);
}
// Points + labels
for (const p of data) {
const cx = x(p.x);
const cy = y(p.chars);
const emphasized =
p.kind === 'pxpipe' ||
p.name === 'Fable 5 [1m]' ||
p.name === 'Grok 4.5';
ctx.beginPath();
ctx.arc(cx, cy, emphasized ? 6.5 : 4.5, 0, Math.PI * 2);
ctx.fillStyle = colors[p.kind];
ctx.fill();
// Outer ring on pxpipe overlays so they read as measured, not vendor text.
if (p.kind === 'pxpipe') {
ctx.beginPath();
ctx.arc(cx, cy, 9, 0, Math.PI * 2);
ctx.strokeStyle = colors.pxpipe;
ctx.lineWidth = 2;
ctx.stroke();
}
const line1 = p.name;
// no " tok" — the subtitle defines the pattern, and the ~4M band is crowded
const line2 = `${fmtTok(p.tokens)} → ${fmt(p.chars)} chars`;
const nameColor = colors[p.kind];
let lx = cx;
let ly1: number;
let align: CanvasTextAlign = 'center';
switch (p.label) {
case 'above':
ly1 = cy - 28;
break;
case 'below':
ly1 = cy + 22;
break;
case 'left':
lx = cx - 12;
ly1 = cy - 3;
align = 'right';
break;
case 'right':
lx = cx + 12;
ly1 = cy - 3;
align = 'left';
break;
}
ly1 += p.dy ?? 0;
ctx.textAlign = align;
// bg-colored glyph halo so crossing series lines never strike through text,
// without erasing whole rectangles out of neighboring lines/labels
ctx.lineJoin = 'round';
ctx.strokeStyle = bg;
ctx.lineWidth = 5;
ctx.font = emphasized ? '700 14px sans-serif' : '600 13px sans-serif';
ctx.strokeText(line1, lx, ly1);
ctx.fillStyle = nameColor;
ctx.fillText(line1, lx, ly1);
ctx.font = '400 12px sans-serif';
ctx.strokeText(line2, lx, ly1 + 15);
ctx.fillStyle = dim;
ctx.fillText(line2, lx, ly1 + 15);
}
ctx.textAlign = 'left';
// Legend (upper-left of plot — the lines hug the bottom there)
const legend: Array<[string, string]> = [
[colors.openai, `OpenAI · GPT — plain text @ ~${TEXT_CPT} chars/token`],
[colors.gemini, 'Google · Gemini'],
[colors.claude, 'Anthropic · Claude → Fable'],
[colors.grok, 'xAI · Grok 4.5'],
[colors.pxpipe, 'Fable 5 [1m] · same 1M window · pxpipe images (measured)'],
];
ctx.font = '400 13px sans-serif';
let ly = top + 20;
for (const [c, label] of legend) {
ctx.fillStyle = c;
ctx.beginPath();
ctx.arc(left + 22, ly - 4, 5.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = ink;
ctx.fillText(label, left + 36, ly);
ly += 22;
}
// Footnote
ctx.fillStyle = '#484f58';
ctx.font = '400 12px sans-serif';
ctx.fillText(
`same ratio at any size: a standard 200K Fable 5 window × pxpipe ≈ ${fmt(Math.round(200_000 * fableCpt))} chars (vs 800K as text). Regenerate: npx tsx scripts/gen-context-chart.ts`,
36,
1000 - 14,
);
return canvas.toBuffer('image/png');
}
// ---------------------------------------------------------------------------
const fixture = loadFixture();
const fable = await measureFableDensity(fixture);
const data = points(fable.cpt);
console.log('\n model released window (tokens) chars in window');
for (const p of data) {
console.log(
` ${p.name.padEnd(20)} ${String(Math.floor(p.x)).padEnd(10)} ${fmtTok(p.tokens).padStart(6)} ${fmt(p.chars).padStart(6)}`,
);
}
const fableText = data.find((p) => p.name === 'Fable 5 [1m]')!;
const fablePx = data.find((p) => p.name === 'Fable 5 [1m] + pxpipe')!;
console.log(
`\n Fable pxpipe multiplier on the same window: ${(fablePx.chars / fableText.chars).toFixed(2)}×`,
);
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, draw(data, fable.cpt));
console.log(`\nwrote ${OUT}`);