Skip to content

Commit a499329

Browse files
committed
plugins/ml: JEPA-lite probe, image-mode critic, evolving search, playtest harness, selftest (40 checks)
Tried: rendering the text to a PNG so the critic sees it (headless Chromium); a ridge probe predicting next-frame change from the text (self-supervised); an advisor-cell search (DeepSeek/Kimi/ZAI proposals, quantum vs xorshift mutation, typed gate + TypeSafe Jev) on the fidelity/chars/compute triangle. Found: image mode lifts mirror 0.068 -> 0.322 (Qwen3-VL-30B); signed-delta probe R^2 <= 0.02 for everything, so the probe predicts |change| instead: motion 0.24 vs mirror ~0; selftest pins the golden 0x6dbdd1a8 through the port the syzygy projector loads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014B7KcHL9b1jwug3L7dmsV3
1 parent 0212202 commit a499329

5 files changed

Lines changed: 558 additions & 0 deletions

File tree

‎plugins/ml/playtest.mjs‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env node
2+
// =============================================================================
3+
// playtest.mjs — the loop end to end, with REAL API calls. Every paid call is
4+
// cached (plugins/ml/cache/) and logged (plugins/ml/logs/calls.jsonl), so a
5+
// second run replays for free and `SYZ_ML_OFFLINE=1` re-derives every number
6+
// below without the network.
7+
//
8+
// node plugins/ml/playtest.mjs [--stage ceiling|grid|search|all]
9+
// [--critic-mode text|image] [--critic MODEL] [--gens N] [--per-cell N]
10+
// [--cells llm:deepseek,llm:kimi,llm:zai,quantum,local] [--tag NAME]
11+
//
12+
// Stages:
13+
// ceiling the critic reads the REAL frames; compared with ground truth. This
14+
// is the best any projection could hope to score vs truth.
15+
// grid the fixed baselines (mirror, mirror x2, syzygy, motion) scored by
16+
// every scorer — the lake test, in numbers.
17+
// search the evolving advisor-cell search (search.mjs) from the baselines.
18+
// Output: plugins/ml/logs/playtest-<tag>.json (+ a printed summary).
19+
// =============================================================================
20+
import fs from 'node:fs';
21+
import path from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
import { project, score, defaults } from './index.mjs';
24+
import { SCENES, renderSequence } from './scenes.mjs';
25+
import { describeOriginal, DEFAULT_CRITIC } from './scorers/vlm.mjs';
26+
import { compareScenes } from './compare.mjs';
27+
import { runSearch, pareto } from './search.mjs';
28+
import { stats } from './apis.mjs';
29+
30+
const HERE = path.dirname(fileURLToPath(import.meta.url));
31+
const arg = (k, d) => { const i = process.argv.indexOf('--' + k); return i > 0 ? process.argv[i + 1] : d; };
32+
const stage = arg('stage', 'all'), mode = arg('critic-mode', 'image'), critic = arg('critic', DEFAULT_CRITIC);
33+
const tag = arg('tag', `${mode}-${critic.split('/').pop()}`);
34+
const ctx = { critic_mode: mode, critic_model: critic };
35+
const feeds = SCENES.map((s) => renderSequence(s));
36+
const report = { tag, critic, mode, started: new Date().toISOString() };
37+
const mean = (a) => a.reduce((s, x) => s + x, 0) / Math.max(1, a.length);
38+
const OUT = path.join(HERE, 'logs', `playtest-${tag}.json`);
39+
const save = () => { fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, JSON.stringify(report, null, 1)); };
40+
41+
if (stage === 'ceiling' || stage === 'all') {
42+
console.log(`=== ceiling: ${critic} reads the real frames, vs ground truth ===`);
43+
report.ceiling = [];
44+
for (const seq of feeds) {
45+
const r = await describeOriginal(seq, { model: critic });
46+
const c = compareScenes(seq.truth, r.desc);
47+
report.ceiling.push({ scene: seq.truth.scene, fidelity: c.fidelity, motion_recall: c.motion_recall, pairs: c.pairs });
48+
console.log(` ${seq.truth.scene.padEnd(6)} fidelity ${c.fidelity.toFixed(3)} motion_recall ${c.motion_recall}`);
49+
}
50+
console.log(` MEAN ceiling ${mean(report.ceiling.map((x) => x.fidelity)).toFixed(3)}`); save();
51+
}
52+
53+
if (stage === 'grid' || stage === 'all') {
54+
console.log(`=== grid: baselines, critic ${critic} (${mode}) ===`);
55+
const cands = [['mirror', {}], ['mirror', { frames: 2 }], ['syzygy', {}], ['syzygy', { field: 'braille' }], ['motion', {}], ['motion', { bg: 'fused' }]];
56+
report.grid = [];
57+
for (const [n, over] of cands) {
58+
const params = { ...defaults(n), ...over }, rows = [];
59+
for (const seq of feeds) {
60+
const pr = project(n, seq, params);
61+
const v = await score('vlm', { seq, projection: pr, ctx });
62+
const inv = await score('inverse', { seq, projection: pr });
63+
rows.push({ scene: seq.truth.scene, vlm: v.fidelity, truth: v.detail.vs_truth.fidelity, motion: v.detail.vs_vlm.motion_recall,
64+
inverse: inv.fidelity, chars: pr.char_budget, ms: pr.compute_estimate.ms, rec: v.detail.reconstruction });
65+
}
66+
const jp = await score('jepa', { projection: { projector: n, params } });
67+
const g = { projector: n, over, vlm: mean(rows.map((r) => r.vlm)), truth: mean(rows.map((r) => r.truth)),
68+
motion: mean(rows.filter((r) => r.motion !== null).map((r) => r.motion)), inverse: mean(rows.map((r) => r.inverse)),
69+
jepa: jp.fidelity, chars: mean(rows.map((r) => r.chars)), ms: mean(rows.map((r) => r.ms)), rows };
70+
report.grid.push(g);
71+
console.log(` ${(n + JSON.stringify(over)).padEnd(26)} vlm ${g.vlm.toFixed(3)} truth ${g.truth.toFixed(3)} motion ${g.motion.toFixed(2)} jepa ${g.jepa.toFixed(3)} inverse ${g.inverse.toFixed(3)} chars ${g.chars} ms ${g.ms.toFixed(1)}`);
72+
}
73+
save();
74+
}
75+
76+
if (stage === 'search' || stage === 'all') {
77+
const gens = +arg('gens', 3), perCell = +arg('per-cell', 2);
78+
const cells = arg('cells', 'llm:deepseek,llm:kimi,llm:zai,quantum,local').split(',');
79+
console.log(`=== search: ${gens} generations x ${cells.length} cells x ${perCell} ===`);
80+
const r = await runSearch({ feeds, generations: gens, perCell, cells, ctx, out: OUT.replace('.json', '-search.json') });
81+
const seeds = r.history.filter((h) => h.cell === 'seed');
82+
const best = [...r.history].sort((a, b) => b.U - a.U)[0];
83+
const byCell = {};
84+
for (const h of r.history) (byCell[h.cell] ||= []).push(h.U);
85+
report.search = { genLog: r.genLog, best, front: r.front.map((f) => ({ id: f.id, vlm: f.vlm, chars: f.chars, ms: f.ms, projector: f.projector, params: f.params })),
86+
rejected: r.rejected, byCell: Object.fromEntries(Object.entries(byCell).map(([k, v]) => [k, { n: v.length, best: Math.max(...v), mean: mean(v) }])),
87+
history: r.history };
88+
console.log(` seeds best U ${Math.max(...seeds.map((s) => s.U)).toFixed(3)} -> searched best U ${best.U.toFixed(3)} (#${best.id} ${best.cell} ${best.projector} ${JSON.stringify(best.params)})`);
89+
for (const [k, v] of Object.entries(report.search.byCell)) console.log(` cell ${k.padEnd(13)} n=${v.n} best U ${v.best.toFixed(3)} mean U ${v.mean.toFixed(3)}`);
90+
console.log(` rejected by gate: ${r.rejected.length}`);
91+
save();
92+
}
93+
report.api = stats; report.finished = new Date().toISOString(); save();
94+
console.log(`api calls this run: ${stats.calls} (cache hits ${stats.cacheHits}) ${JSON.stringify(stats.byProvider)}`);
95+
console.log(`report: ${path.relative(process.cwd(), OUT)}`);
96+
const { closeRenderer } = await import('./render.mjs'); await closeRenderer();

‎plugins/ml/render.mjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// render.mjs — draw a text projection the way a terminal shows it (light
2+
// monospace on black, cells twice as tall as wide) and return PNG bytes. Uses
3+
// the Playwright + Chromium the container ships with; imported lazily so the
4+
// rest of the plugin layer never needs a browser.
5+
import { createRequire } from 'node:module';
6+
7+
let browserP = null;
8+
async function browser() {
9+
if (!browserP) {
10+
const req = createRequire(import.meta.url);
11+
let pw;
12+
try { pw = req('playwright'); } catch { pw = req('/opt/node22/lib/node_modules/playwright'); }
13+
browserP = pw.chromium.launch();
14+
}
15+
return browserP;
16+
}
17+
18+
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19+
20+
export async function renderTextPNG(text, { fontPx = 12 } = {}) {
21+
const b = await browser();
22+
const page = await b.newPage({ deviceScaleFactor: 1 });
23+
try {
24+
await page.setContent(`<!doctype html><html><body style="margin:0;background:#000">
25+
<pre id="t" style="margin:0;padding:6px;display:inline-block;font:${fontPx}px/${(2 * 0.602 * fontPx).toFixed(2)}px 'DejaVu Sans Mono',monospace;color:#e8e8e8;background:#000">${esc(text)}</pre></body></html>`);
26+
return await (await page.$('#t')).screenshot({ type: 'png' });
27+
} finally { await page.close(); }
28+
}
29+
30+
export async function closeRenderer() { if (browserP) { (await browserP).close(); browserP = null; } }

‎plugins/ml/scorers/jepa.mjs‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// =============================================================================
2+
// jepa — a JEPA-flavoured, self-supervised proxy: how much does the text tell
3+
// you about the FUTURE of the feed?
4+
//
5+
// For a given projector config, a linear (ridge) predictor is trained to map
6+
// features of the projection of frames 0..T-1 to a target computed from frame T,
7+
// which the projector never saw. No labels: the feed supervises itself. Held-out
8+
// R^2 is the score.
9+
//
10+
// features the text read back as ink, pooled to 16x8; plus four 8x4 channels
11+
// counting each arrow direction, one for '~' trails, and (for
12+
// multi-frame projections) one of |ink change| between the oldest
13+
// and newest block, so stacking frames gets credit too
14+
// target 'energy' (default): |next-frame luma - current luma| at 16x8 —
15+
// WHERE the next change will happen. Predicting it needs to know
16+
// where things are and which way they move, which is what
17+
// "understanding over time" should mean. Sign-free on purpose: a
18+
// dark car on grass and a bright ball on a floor move the same way
19+
// with opposite-signed deltas, which a linear probe cannot untangle.
20+
// 'delta': the signed change (measured: a linear probe gets R^2 <= 0.02)
21+
// 'next': the next frame's luma itself (mostly static; R^2 ~0.98 for all)
22+
//
23+
// Honest scope: this is a linear probe on a 16x8 pixel target, not a learned
24+
// joint embedding. The real JEPA (predict a frozen image encoder's embedding of
25+
// frame T, e.g. CLIP via DeepInfra) is the proposed next step; the harness is
26+
// the same with a different target function.
27+
// =============================================================================
28+
import { lumaGrid } from '../core.mjs';
29+
import { registerScorer, project } from '../registry.mjs';
30+
import { renderSequence, randomSpec } from '../scenes.mjs';
31+
import { textGrid } from './inverse.mjs';
32+
33+
const TW = 16, TH = 8, AW = 8, AH = 4;
34+
const ARROW = { '>': 0, '→': 0, '<': 1, '←': 1, '^': 2, '↑': 2, 'v': 3, '↓': 3, '~': 4 };
35+
36+
export function features(text) {
37+
const blocks = text.split('\n\n'), lines = blocks[blocks.length - 1].split('\n').filter((l) => !l.startsWith('legend:'));
38+
const t = textGrid(text), f = new Float64Array(TW * TH + 6 * AW * AH + 1), cnt = new Float64Array(TW * TH);
39+
// multi-frame projections: |ink(newest) - ink(oldest block)| pooled to 8x4
40+
if (blocks.length > 1) {
41+
const o = textGrid(blocks[0]);
42+
for (let y = 0; y < Math.min(t.rows, o.rows); y++) for (let x = 0; x < Math.min(t.cols, o.cols); x++) {
43+
const ax = Math.min(AW - 1, Math.floor(x * AW / t.cols)), ay = Math.min(AH - 1, Math.floor(y * AH / t.rows));
44+
f[TW * TH + 5 * AW * AH + ay * AW + ax] += Math.abs(t.grid[y * t.cols + x] - o.grid[y * o.cols + x]);
45+
}
46+
}
47+
for (let y = 0; y < t.rows; y++) for (let x = 0; x < t.cols; x++) {
48+
const X = Math.min(TW - 1, Math.floor(x * TW / t.cols)), Y = Math.min(TH - 1, Math.floor(y * TH / t.rows));
49+
f[Y * TW + X] += t.grid[y * t.cols + x]; cnt[Y * TW + X]++;
50+
const ch = [...(lines[y] || '')][x], a = ARROW[ch];
51+
if (a !== undefined) {
52+
const ax = Math.min(AW - 1, Math.floor(x * AW / t.cols)), ay = Math.min(AH - 1, Math.floor(y * AH / t.rows));
53+
f[TW * TH + a * AW * AH + ay * AW + ax] += 1;
54+
}
55+
}
56+
for (let i = 0; i < TW * TH; i++) f[i] = cnt[i] ? f[i] / cnt[i] : 0;
57+
f[f.length - 1] = 1;
58+
return f;
59+
}
60+
61+
export function target(seq, kind = 'delta') {
62+
const n = seq.frames.length, next = lumaGrid(seq.frames[n - 1], TW, TH);
63+
if (kind === 'next') return Float64Array.from(next, (v) => v / 255);
64+
const cur = lumaGrid(seq.frames[n - 2], TW, TH);
65+
if (kind === 'energy') return Float64Array.from(next, (v, i) => Math.abs(v - cur[i]) / 255);
66+
return Float64Array.from(next, (v, i) => (v - cur[i]) / 255);
67+
}
68+
69+
// Solve (X^T X + lam I) W = X^T Y by Cholesky. X: n x d, Y: n x k.
70+
export function ridge(X, Y, lam = 1e-1) {
71+
const n = X.length, d = X[0].length, k = Y[0].length;
72+
const A = Array.from({ length: d }, () => new Float64Array(d)), B = Array.from({ length: d }, () => new Float64Array(k));
73+
for (let r = 0; r < n; r++) {
74+
const x = X[r], y = Y[r];
75+
for (let i = 0; i < d; i++) { if (!x[i]) continue; const xi = x[i];
76+
for (let j = i; j < d; j++) A[i][j] += xi * x[j];
77+
for (let j = 0; j < k; j++) B[i][j] += xi * y[j]; }
78+
}
79+
for (let i = 0; i < d; i++) { for (let j = 0; j < i; j++) A[i][j] = A[j][i]; A[i][i] += lam; }
80+
const L = Array.from({ length: d }, () => new Float64Array(d));
81+
for (let i = 0; i < d; i++) for (let j = 0; j <= i; j++) {
82+
let s = A[i][j]; for (let q = 0; q < j; q++) s -= L[i][q] * L[j][q];
83+
L[i][j] = i === j ? Math.sqrt(Math.max(s, 1e-12)) : s / L[j][j];
84+
}
85+
const W = Array.from({ length: d }, () => new Float64Array(k));
86+
for (let c = 0; c < k; c++) {
87+
const z = new Float64Array(d);
88+
for (let i = 0; i < d; i++) { let s = B[i][c]; for (let q = 0; q < i; q++) s -= L[i][q] * z[q]; z[i] = s / L[i][i]; }
89+
for (let i = d - 1; i >= 0; i--) { let s = z[i]; for (let q = i + 1; q < d; q++) s -= L[q][i] * W[q][c]; W[i][c] = s / L[i][i]; }
90+
}
91+
return W;
92+
}
93+
94+
export function predict(W, x) {
95+
const k = W[0].length, out = new Float64Array(k);
96+
for (let i = 0; i < x.length; i++) if (x[i]) for (let c = 0; c < k; c++) out[c] += x[i] * W[i][c];
97+
return out;
98+
}
99+
100+
export function r2(P, Y) {
101+
const k = Y[0].length, mean = new Float64Array(k);
102+
for (const y of Y) for (let c = 0; c < k; c++) mean[c] += y[c] / Y.length;
103+
let sse = 0, sst = 0;
104+
for (let r = 0; r < Y.length; r++) for (let c = 0; c < k; c++) { sse += (P[r][c] - Y[r][c]) ** 2; sst += (Y[r][c] - mean[c]) ** 2; }
105+
return sst ? 1 - sse / sst : 0;
106+
}
107+
108+
// Train + evaluate the probe for one projector config. Deterministic (seeded feeds).
109+
const memo = new Map();
110+
export function probe(projector, params, { train = 600, test = 150, kind = 'energy', frames = 12, lam = 10 } = {}) {
111+
const key = JSON.stringify([projector, params, train, test, kind, frames, lam]);
112+
if (memo.has(key)) return memo.get(key);
113+
const sample = (seed) => {
114+
const seq = renderSequence(randomSpec(seed), frames + 1);
115+
const past = { frames: seq.frames.slice(0, frames), truth: seq.truth };
116+
return { x: features(project(projector, past, params).text_projection), y: target(seq, kind) };
117+
};
118+
const tr = Array.from({ length: train }, (_, i) => sample(1000 + i));
119+
const te = Array.from({ length: test }, (_, i) => sample(900000 + i));
120+
const W = ridge(tr.map((s) => s.x), tr.map((s) => s.y), lam);
121+
const res = { r2_test: r2(te.map((s) => predict(W, s.x)), te.map((s) => s.y)),
122+
r2_train: r2(tr.map((s) => predict(W, s.x)), tr.map((s) => s.y)), train, test, kind };
123+
memo.set(key, res);
124+
return res;
125+
}
126+
127+
registerScorer({
128+
name: 'jepa',
129+
cost: 'cheap',
130+
about: 'self-supervised: held-out R^2 of a ridge probe predicting the next frame\'s change from the text',
131+
async score({ projection, ctx = {} }) {
132+
const r = probe(projection.projector, projection.params, ctx.jepa || {});
133+
return { fidelity: Math.max(0, Math.min(1, r.r2_test)), detail: r };
134+
},
135+
});

0 commit comments

Comments
 (0)