jevstrudel.git / tools / critic / critic.mjs
1#!/usr/bin/env node

Jev's art critic, per revision: scores every revision of every song with the art critic's rubric (website/src/jev/critic.mjs) and writes the result into the song's SPEC.md frontmatter, as art: on the revision and as critic: for the song as it stands.

op-env-run -- node tools/critic/critic.mjs # dry run: what it would score op-env-run -- node tools/critic/critic.mjs --write # score and record … --all rescore revisions that already have a score … --song jev/dial-up only this song (repeatable) … --runs 5 ask each revision 5 times (default 3)

Each revision is asked --runs times and the mean recorded: one call's art moves by up to 0.06 on unchanged code (measured 2026-09-25 across 14 songs), enough to reorder the song list. art: is the mean, artRuns: every run's own art beside it, and the critic: block's criteria are means too (meanVerdict in website/src/jev/critic.mjs).

Each revision is judged on its own code against the spec it was made from:

  • a revision whose output names a file in the song folder (song.js, glitch.js) is that file, judged against the current spec;
  • an overwritten one (output: null) is recovered from git: song.js and SPEC.md as they were just before the commit that recorded the next revision. If that snapshot is not the revision (no song.js, or a spec listing some other revision last), the revision is recorded as art: null with the reason, never guessed.

The current revision is also judged on its measured mix, when its spec has a measured: taken of this very song.js (tools/measure): craft then rests on how the parts actually sit, not only on the code.

The key is JEVSTRUDEL_TYPESAFE_API_KEY from the environment, which op-env-run supplies; it is never printed or written anywhere.

35import { execFileSync } from 'node:child_process';
36import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs';
37import { join } from 'node:path';
38import {
39  artOf,
40  artRequest,
41  CRITIC_MODEL,
42  meanVerdict,
43  RUBRIC,
44  setCritic,
45  setRevisionArt,
46  specBody,
47} from '../../website/src/jev/critic.mjs';
48import { measuredFor, readMeasured } from '../../website/src/jev/measured.mjs';
50const API = 'https://api.typesafe.ai/v1/systemone';
51const args = process.argv.slice(2);
52const write = args.includes('--write');
53const all = args.includes('--all');
54const only = args.flatMap((a, i) => (args[i - 1] === '--song' ? [a] : []));
55const runsArg = args.includes('--runs') ? args[args.indexOf('--runs') + 1] : '3';
56const runs = Number(runsArg);
57if (!Number.isInteger(runs) || runs < 1) {
58  console.error(`critic: --runs takes a whole number of runs, 1 or more, not ${runsArg}`);
59  process.exit(1);
60}
61
62const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
63const git = (...a) =>
64  execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
65const gitShow = (commit, path) => {
66  try {
67    return git('show', `${commit}:${path}`);
68  } catch {
69    return null;
70  }
71};

The frontmatter and the body, split as the first critic did (text.split('---', 2)), so scores stay comparable with it.

75function split(text) {
76  return { fm: text.split('---')[1], body: specBody(text) };
77}

Revisions from the frontmatter text: rev, output, and the existing art.

80function revisionsOf(fm) {
81  const block = fm.split(/^revisions:\s*$/m)[1];
82  if (!block) return [];
83  return block
84    .split(/^ {2}- (?=rev:)/m)
85    .slice(1)
86    .map((entry) => ({
87      rev: Number(entry.match(/^rev:\s*(\d+)/)[1]),
88      output: entry.match(/^ {4}output:\s*([^\s#]+)/m)?.[1],
89      art: entry.match(/^ {4}art:\s*([^\s#]+)/m)?.[1],
90    }));
91}
93const titleOf = (fm) => fm.match(/^title:(.*)$/m)[1].trim();
94const lastRev = (fm) => Math.max(0, ...revisionsOf(fm).map((r) => r.rev));

Where revision rev of an overwritten song.js lives in git.

97function recover(dir, rev) {
98  const rel = `songs/${dir}`;
99  const commits = git('log', '--format=%H', '--', `${rel}/SPEC.md`).split('\n').filter(Boolean).reverse();
100  const next = commits.find((c) => {
101    const spec = gitShow(c, `${rel}/SPEC.md`);
102    return spec && lastRev(split(spec).fm) > rev;
103  });
104  if (!next) return { reason: `no commit records a revision after rev ${rev}` };
105  const before = `${next}^`;
106  const spec = gitShow(before, `${rel}/SPEC.md`);
107  const code = gitShow(before, `${rel}/song.js`);
108  if (!spec || !code) return { reason: 'predates this repo: no song.js before the next revision was recorded' };
109  const listed = lastRev(split(spec).fm);
110  if (listed !== rev && !(listed === 0 && rev === 1)) {
111    return { reason: `the song.js before rev ${rev + 1} was recorded is rev ${listed}'s, not rev ${rev}'s` };
112  }
113  return { code, body: split(spec).body, from: git('rev-parse', '--short', before).trim() };
114}
116async function ask(state) {
117  const res = await fetch(API, {
118    method: 'POST',
119    headers: {
120      Authorization: `Bearer ${process.env.JEVSTRUDEL_TYPESAFE_API_KEY}`,
121      'Content-Type': 'application/json',
122      'User-Agent': 'jevstrudel-critic',
123    },
124    body: JSON.stringify(artRequest(state)),
125  });
126  if (!res.ok) throw new Error(`TypeSafe answered ${res.status}: ${(await res.text()).slice(0, 200)}`);
127  const verdict = artOf((await res.json()).answers);
128  if (!verdict) throw new Error('the answer is missing a criterion');
129  return verdict;
130}
131
132const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD, local
133const dirs = readdirSync(join(root, 'songs'), { withFileTypes: true })
134  .filter((d) => d.isDirectory())
135  .flatMap((theme) =>
136    readdirSync(join(root, 'songs', theme.name), { withFileTypes: true })
137      .filter((d) => d.isDirectory() && existsSync(join(root, 'songs', theme.name, d.name, 'SPEC.md')))
138      .map((d) => `${theme.name}/${d.name}`),
139  )
140  .filter((d) => !only.length || only.includes(d))
141  .sort();
142
143if (write && !process.env.JEVSTRUDEL_TYPESAFE_API_KEY) {
144  console.error('critic: no JEVSTRUDEL_TYPESAFE_API_KEY; run inside op-env-run');
145  process.exit(1);
146}
147
148let calls = 0;
149for (const dir of dirs) {
150  const path = join(root, 'songs', dir, 'SPEC.md');
151  let text = readFileSync(path, 'utf8');
152  const { fm, body } = split(text);
153  const title = titleOf(fm);
154  const song = fm.match(/^song:\s*(\S+)/m)?.[1] ?? 'song.js';
155  let current = null;
156  for (const r of revisionsOf(fm)) {
157    const isCurrent = r.output === song;
158    if (r.art !== undefined && !all) {
159      console.log(`${dir} rev ${r.rev}: already scored (${r.art})`);
160      continue;
161    }
162    let job;
163    if (r.output && r.output !== 'null') {
164      const file = join(root, 'songs', dir, r.output);
165      job = existsSync(file)
166        ? { code: readFileSync(file, 'utf8'), body, from: r.output }
167        : { reason: `${r.output} is missing` };
168    } else {
169      job = recover(dir, r.rev);
170    }
171    if (job.reason) {
172      console.log(`${dir} rev ${r.rev}: cannot score — ${job.reason}`);
173      if (write) text = setRevisionArt(text, r.rev, { reason: job.reason });
174      continue;
175    }
176    if (!write) {
177      console.log(`${dir} rev ${r.rev}: would score ${job.from}${isCurrent ? ' (current)' : ''}, ${runs}×`);
178      continue;
179    }
180    // the measured mix (tools/measure) is of the current song.js only
181    const measured = isCurrent ? measuredFor(readMeasured(text), job.code) : null;
182    const state = { title, spec: job.body, code: job.code, measured };
183    const verdict = meanVerdict(await Promise.all(Array.from({ length: runs }, () => ask(state))));
184    calls += runs;
185    console.log(
186      `${dir} rev ${r.rev}: ${verdict.art} from [${verdict.artRuns.join(', ')}] (${job.from}${isCurrent ? ', current' : ''})`,
187    );
188    text = setRevisionArt(text, r.rev, verdict);
189    if (isCurrent) current = verdict;
190  }
191  if (write && current !== null) text = setCritic(text, current, today);
192  if (write) writeFileSync(path, text);
193}
194if (write) console.log(`critic: ${calls} calls to ${CRITIC_MODEL}`);