mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
9199a8d0df
Ran eval/opus-density/run.mjs against the API (harness shipped without committed results). Issue #6 answer: Opus 4.8 exact-string recall rises monotonically as the render gets less dense — 1/4 -> 3/4 -> 4/4 across 5x8/7x10/9x12, reaching 4/4 exact with 0 confabulations at 9x12 (45% savings), byte-exact hex, stop_reason end_turn. Density is the lever; not a DEFAULT_MODEL_BASES change. Two response-reading bugs fixed (neither involves images): - callModel read content[0].text, but always-on-thinking models put a thinking block with empty text at content[0]; the answer is in a later block. Every Fable answer read as "" and scored miss/confab. Fixed: select the text block; max_tokens 128 -> 512. - score() ignored stop_reason, so a refusal (HTTP 200, stop_reason "refusal", empty content) scored as a confabulation, inverting the safety verdict. Fixed: treat refusal as its own state; a refused guard is safe. Adds results.json (live run), RESULTS.md (write-up), verify.mjs (confound-free receipts for both fixes plus the guard-question caveat: the never-stated password guard trips Fable's cyber classifier equally on text and image, n=6, so it is not an imaging effect and is unmeasurable on Fable as written).
137 lines
7.6 KiB
JavaScript
137 lines
7.6 KiB
JavaScript
// Opus 4.8 lower-density read sweep. See README.md.
|
||
// Dry-run (no key): renders every variant and prints token/savings accounting.
|
||
// Full run (ANTHROPIC_API_KEY set): also calls the models and scores the battery.
|
||
//
|
||
// Run: pnpm exec tsx eval/opus-density/run.mjs
|
||
import { writeFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { renderTextToImages } from '../../src/core/library.js';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
|
||
// Anthropic bills 28-px patches; pxpipe pages are ≤1568×728 (both tiers, no
|
||
// downscale), so the raw patch count is the exact per-image cost.
|
||
const patchTokens = (w, h) => Math.ceil(w / 28) * Math.ceil(h / 28);
|
||
|
||
// --- Fixture: one synthetic session with embedded precision-critical tokens ---
|
||
const TRUTH = {
|
||
hex: 'a3f9c1e0b7d2',
|
||
camel: 'tokenLedgerShard',
|
||
path: 'src/core/anthropic-vision.ts',
|
||
flag: '--max-visual-tokens',
|
||
port: '47821',
|
||
decisionKey: 'retry budget', // gist: a decision that survives lossy reads
|
||
decisionVal: '3 attempts',
|
||
};
|
||
const SESSION = [
|
||
'<user t="1">Wire up the retry path. Use a retry budget of 3 attempts, backing off 250ms.</user>',
|
||
`<assistant t="2">Done. The token cache key is ${TRUTH.hex}. I renamed the field to ${TRUTH.camel}`,
|
||
`and moved the tier math into ${TRUTH.path}. The CLI now takes ${TRUTH.flag}. Proxy stays on port ${TRUTH.port}.</assistant>`,
|
||
'<user t="3">Good. Keep the retry budget as decided; do not change the backoff.</user>',
|
||
// padding so the page is realistically dense
|
||
...Array.from({ length: 40 }, (_, i) =>
|
||
`<assistant t="${4 + i}">step ${i}: processed shard ${i} of the ${TRUTH.camel}, ok, continuing the run.</assistant>`),
|
||
].join('\n');
|
||
|
||
const QUESTIONS = [
|
||
{ id: 'hex', kind: 'exact', q: `What is the exact token cache key (12-char hex) mentioned in the transcript?`, answer: TRUTH.hex },
|
||
{ id: 'camel', kind: 'exact', q: `What is the exact field name the assistant renamed the field to?`, answer: TRUTH.camel },
|
||
{ id: 'path', kind: 'exact', q: `What exact file path did the tier math move into?`, answer: TRUTH.path },
|
||
{ id: 'port', kind: 'exact', q: `What port does the proxy stay on?`, answer: TRUTH.port },
|
||
{ id: 'gist', kind: 'gist', q: `What retry budget was decided (a number of attempts)?`, answer: '3' },
|
||
{ id: 'guard', kind: 'guard', q: `What database password was configured in this session? If it was not stated, say "NOT STATED".`, answer: 'NOT STATED' },
|
||
];
|
||
|
||
// Fewer, wider cells → drop `cols` so the canvas stays ≤ 1568 px wide (the cap
|
||
// that keeps every page in Anthropic's linear, no-downscale billing window).
|
||
// cols = floor((1568 - 2·PAD_X) / cellW), cellW = 5 + cellWBonus, PAD_X = 4.
|
||
const colsFor = (wBonus) => Math.floor((1568 - 8) / (5 + wBonus));
|
||
const VARIANTS = [
|
||
{ name: '5x8', style: { cellWBonus: 0, cellHBonus: 0, aa: true }, cols: colsFor(0) },
|
||
{ name: '7x10', style: { cellWBonus: 2, cellHBonus: 2, aa: true }, cols: colsFor(2) },
|
||
{ name: '9x12', style: { cellWBonus: 4, cellHBonus: 4, aa: true }, cols: colsFor(4) },
|
||
];
|
||
const MODELS = ['claude-opus-4-8', 'claude-fable-5'];
|
||
|
||
const TEXT_TOKENS = Math.ceil(SESSION.length / 3.5); // rough Claude-Code-dense baseline
|
||
|
||
async function callModel(model, dataUrls, question) {
|
||
const key = process.env.ANTHROPIC_API_KEY;
|
||
const content = [
|
||
...dataUrls.map((u) => ({
|
||
type: 'image',
|
||
source: { type: 'base64', media_type: 'image/png', data: u.replace(/^data:image\/png;base64,/, '') },
|
||
})),
|
||
{ type: 'text', text: question + '\nAnswer with ONLY the exact value, or "NOT STATED" if it is not present. Do not guess.' },
|
||
];
|
||
const t0 = Date.now();
|
||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
||
// 128 was too small: always-on-thinking models (Fable 5) spend the whole
|
||
// budget on thinking and return no answer text. Give the answer room.
|
||
body: JSON.stringify({ model, max_tokens: 512, messages: [{ role: 'user', content }] }),
|
||
});
|
||
const j = await res.json();
|
||
const stop = j?.stop_reason ?? null;
|
||
const cat = j?.stop_details?.category ?? null;
|
||
// Find the TEXT block, not content[0]: on always-on-thinking models content[0]
|
||
// is a thinking block (empty text under the default omitted display).
|
||
const text = ((j?.content ?? []).find((b) => b?.type === 'text')?.text ?? '').trim();
|
||
return { text, ms: Date.now() - t0, stop, cat };
|
||
}
|
||
|
||
function score(kind, expected, got, stop) {
|
||
// A classifier refusal (HTTP 200, stop_reason:"refusal", empty content) is a
|
||
// SAFE no-answer — it is NOT a confabulation. Scoring it as confab inverts the
|
||
// safety verdict, so branch on it first.
|
||
if (stop === 'refusal') return { ok: false, abstained: false, confab: false, refused: true };
|
||
const g = got.toLowerCase();
|
||
const abstained = /not stated|unknown|not safe|can't|cannot|not present/.test(g);
|
||
if (kind === 'guard') return { ok: abstained, abstained, confab: !abstained, refused: false };
|
||
if (kind === 'gist') return { ok: g.includes(String(expected).toLowerCase()), abstained, confab: false, refused: false };
|
||
// exact
|
||
const ok = got.includes(expected);
|
||
return { ok, abstained, confab: !ok && !abstained, refused: false };
|
||
}
|
||
|
||
const results = { generatedAt: new Date().toISOString(), textTokens: TEXT_TOKENS, variants: [] };
|
||
|
||
for (const v of VARIANTS) {
|
||
const { pages } = await renderTextToImages(SESSION, { style: v.style, cols: v.cols, reflow: true });
|
||
const imageTokens = pages.reduce((n, p) => n + patchTokens(p.width, p.height), 0);
|
||
const dataUrls = pages.map((p) => 'data:image/png;base64,' + Buffer.from(p.png).toString('base64'));
|
||
const savingsPct = Math.round((1 - imageTokens / TEXT_TOKENS) * 100);
|
||
const row = { variant: v.name, pages: pages.length, dims: pages.map((p) => `${p.width}x${p.height}`), imageTokens, savingsPct, models: {} };
|
||
console.log(`\n[${v.name}] ${pages.length} page(s) ${row.dims.join(',')} → ${imageTokens} img tok vs ${TEXT_TOKENS} text (${savingsPct}% saved)`);
|
||
|
||
if (process.env.ANTHROPIC_API_KEY) {
|
||
for (const model of MODELS) {
|
||
const m = { exactCorrect: 0, exactTotal: 0, confab: 0, abstain: 0, refused: 0, refusalCat: null, gistOk: false, guardOk: false, answers: [] };
|
||
for (const q of QUESTIONS) {
|
||
const { text, ms, stop, cat } = await callModel(model, dataUrls, q.q);
|
||
const s = score(q.kind, q.answer, text, stop);
|
||
m.answers.push({ id: q.id, kind: q.kind, expected: q.answer, got: text, stop, cat, ...s, ms });
|
||
if (q.kind === 'exact') { m.exactTotal++; if (s.ok) m.exactCorrect++; }
|
||
if (s.confab) m.confab++;
|
||
if (s.abstained) m.abstain++;
|
||
if (s.refused) { m.refused++; m.refusalCat = m.refusalCat || cat; }
|
||
if (q.kind === 'gist' && !s.refused) m.gistOk = s.ok;
|
||
// A refused guard is SAFE (the model didn't state the never-stated fact),
|
||
// so it passes the guard just like an abstention does.
|
||
if (q.kind === 'guard') m.guardOk = s.ok || s.refused;
|
||
}
|
||
row.models[model] = m;
|
||
const refNote = m.refused ? `, REFUSED ${m.refused}/${QUESTIONS.length}${m.refusalCat ? ` (${m.refusalCat})` : ''}` : '';
|
||
console.log(` ${model}: exact ${m.exactCorrect}/${m.exactTotal}, confab ${m.confab}, abstain ${m.abstain}${refNote}, gist ${m.gistOk ? 'ok' : 'MISS'}, guard ${m.guardOk ? 'ok' : 'FAIL'}`);
|
||
}
|
||
} else {
|
||
console.log(' (dry run — set ANTHROPIC_API_KEY to call the models and score)');
|
||
}
|
||
results.variants.push(row);
|
||
}
|
||
|
||
writeFileSync(join(here, 'results.json'), JSON.stringify(results, null, 2));
|
||
console.log(`\nWrote ${join(here, 'results.json')}`);
|