jevstrudel.git / worker / src / art-rubric.mjs
1// The art critic's rubric: the one question set behind every "N% art" on
2// the site. It lives here, in the Worker, because the Worker scores
3// listeners' songs with it (content-jobs.ts), and the deploy uploads worker/
4// alone; the site (website/src/jev/critic.mjs re-exports it) and
5// tools/critic/ import it from here, so a listener's song and the site's
6// own are judged by exactly the same question. Plain JavaScript with no
7// imports, so Node, Vite and the Worker all run this file as it is.
8//
9// A rubric, not a yes/no. Jev scores six criteria on 0-5 ladders whose
10// steps are absolute, the way a harsh critic would use them: 0 is not a
11// song (three notes, one bar looped), 3 is genuinely good, 5 is among the
12// best ever made in its genre. The art score is the mean over the six, out
13// of 5, so 60% means good across the board, 90% takes peak work nearly
14// everywhere, and 100% is out of reach (Jev's score is a probability-
15// weighted mean, never quite 5).
16//
17// The first critic asked one Noul, "is this a deliberate creative work?",
18// and every song cleared it at 93-96% (2026-09-24): it could tell a song
19// from noise, never one song from another. Changing a criterion or a step
20// makes new scores incomparable with recorded ones; rescore every song
21// (tools/critic/critic.mjs --write --all) and bump RUBRIC.
22export const CRITIC_MODEL = 'jev-1.13.0';
23export const RUBRIC = 'rubric-1';
24
25const INSTRUCTIONS =
26  'You are a harsh, experienced music critic scoring one criterion of a piece of music. The state holds its title, ' +
27  'its spec (what it set out to be: intent, which is not achievement), its code in Strudel, a live-coding music ' +
28  'language (judge what the code actually plays), and facts measured from the code. Most amateur tracks land at 1 ' +
29  'or 2 on any criterion. 3 means genuinely good, 4 excellent, and 5 is reserved for the best ever made in the genre. ' +
30  'A few notes, or one bar repeated, is 0 on everything. Criterion: ';
31
32// Each criterion: what it judges, and its steps from 0 to 5.
33export const CRITERIA = {
34  hook: {
35    label: 'Hook',
36    judges: 'a memorable motif (melody, riff, vocal line or rhythm) and whether it earns its returns',
37    levels: [
38      'none: nothing to remember',
39      'a motif exists but is generic or buried',
40      'a serviceable hook that is forgotten soon after',
41      'a hook that sticks after one listen',
42      'a hook people would hum days later',
43      'an iconic hook, among the best in its genre',
44    ],
45  },
46  development: {
47    label: 'Development',
48    judges: 'whether the music evolves (variation, new material, changes of texture) or loops the same bars',
49    levels: [
50      'one pattern looped, or a few notes',
51      'repetitive: the same bars with parts muted in and out',
52      'some variation, but mostly the same few bars',
53      'keeps evolving: sections bring real new material and variation',
54      'rich development: every return is changed, nothing outstays its welcome',
55      'masterful development from start to finish',
56    ],
57  },
58  structure: {
59    label: 'Structure',
60    judges: 'the arc: tension and release, builds that pay off, and an ending that lands',
61    levels: [
62      'no shape: it starts and stops',
63      'sections exist but do not build or release',
64      'a standard arc, executed without payoff',
65      'a satisfying arc: builds pay off and it ends well',
66      'an arc that surprises and still feels inevitable',
67      'perfect pacing, peak of its genre',
68    ],
69  },
70  concept: {
71    label: 'Concept',
72    judges: 'whether the sound serves the subject: does the joke or idea land through the music, not just the words',
73    levels: [
74      'no idea, or one that has nothing to do with the sound',
75      'the idea is only in the title or the samples',
76      'the idea is present but the music is generic',
77      'the music and the idea reinforce each other',
78      'the concept lands hard: sound, lyrics and form all serve it',
79      'a concept executed so well it redefines the joke',
80    ],
81  },
82  originality: {
83    label: 'Originality',
84    judges: 'what it brings beyond a template of its genre',
85    levels: [
86      'a default pattern or a copy',
87      'a genre template with names swapped in',
88      'competent genre work with a few personal touches',
89      'a distinct voice within the genre',
90      'fresh: ideas the genre has not heard often',
91      'genre-defining originality',
92    ],
93  },
94  craft: {
95    label: 'Craft',
96    judges: 'sound design and mix: balance, parts that do not clash, clean transitions, vocals that sit right',
97    levels: [
98      'broken: clipping, clashing or inaudible parts',
99      'rough: unbalanced, muddy or harsh',
100      'passable: works, with obvious rough edges',
101      'clean and balanced; everything audible and in place',
102      'polished: professional sound design and mix',
103      'reference-quality production',
104    ],
105  },
106};
107
108const TOP = 5;
109
110// Facts measured from the code, so Jev judges what is there, not only
111// what the spec says.
112export function songFacts(code) {
113  const bpm = Number(code.match(/setcps\(\s*([\d.]+)\s*\/\s*60\s*\/\s*\d+\s*\)/)?.[1]) || null; // any beats a bar (ALL GREEN is 5/4)
114  const bars = Math.max(0, ...[...code.matchAll(/\.mask\("<1!(\d+) 0!\d+>"\)/g)].map((m) => Number(m[1]))) || null;
115  const lines = code.split('\n').filter((l) => l.trim() && !l.trim().startsWith('//')).length;
116  // Call sites, not questions: a helper like `plays(part)` asks one noul per part.
117  const kinds = [...new Set([...code.matchAll(/\b(choice|score|noul)\s*\(/g)].map((m) => m[1]))];
118  const jevs = [...code.matchAll(/\bjev\s*\(\s*\{/g)].length;
119  const jev = !jevs
120    ? 'none'
121    : [
122        /\bwalk\s*\(/.test(code) ? "Jev walks the song's form and decides when it ends" : null,
123        `${jevs} request${jevs > 1 ? 's' : ''} a section, asking ${kinds.join(', ') || 'nothing'} questions`,
124      ]
125        .filter(Boolean)
126        .join('; ');
127  return { tempo: bpm ? `${bpm} bpm` : 'unknown', barsAsWritten: bars, codeLines: lines, arrangedByJev: jev };
128}
129
130// The mix as heard (tools/measure, measured.mjs), for the state: what the
131// numbers are, then the numbers. Only the current, unedited song has them.
132// Per section it reads the total only: every part of every section would
133// multiply the prompt for detail the whole-song parts already give.
134const measuredMix = (m) => ({
135  what:
136    'this song played as written, measured in a browser: integrated loudness and peak in dBFS, in total and for ' +
137    'each part (a part is what Jev levels in the song; "other" is everything else), and the total per section, ' +
138    'keyed by the bars it spans. Quieter than about -40 is inaudible in the mix; the output is limited at -1, so ' +
139    'a peak of -1 means the mix is too hot and is being held down',
140  total: m.total,
141  parts: m.parts,
142  ...(m.sections
143    ? { sections: Object.fromEntries(Object.entries(m.sections).map(([k, v]) => [k, v?.total ?? null])) }
144    : {}),
145});
146
147// The request body; `spec` is the SPEC.md body without its frontmatter, and
148// `measured` the song's measured mix when it has one.
149export const artRequest = ({ title, spec, code, measured = null }) => ({
150  model: CRITIC_MODEL,
151  questions: Object.fromEntries(
152    Object.entries(CRITERIA).map(([key, c]) => [
153      key,
154      { type: 'score', instructions: `${INSTRUCTIONS}${c.label}: ${c.judges}.`, criteria: c.levels },
155    ]),
156  ),
157  state: { title, spec, code, facts: songFacts(code), ...(measured ? { measuredMix: measuredMix(measured) } : {}) },
158});
159
160// From the answers: `art`, 0 to 1, and each criterion's score out of 5;
161// null when any criterion is missing.
162export function artOf(answers) {
163  const criteria = {};
164  for (const key of Object.keys(CRITERIA)) {
165    const score = answers?.[key]?.score;
166    if (typeof score !== 'number') return null;
167    criteria[key] = Math.round(score * 10) / 10;
168  }
169  const mean = Object.values(criteria).reduce((a, b) => a + b, 0) / Object.keys(criteria).length;
170  return { art: Math.round((mean / TOP) * 100) / 100, criteria };
171}
172
173// Several verdicts on the same code, as one: the mean `art` (0 to 1, to
174// the hundredth, like one verdict's) and each criterion's mean score out of
175// 5 (to the tenth), plus `artRuns`, every verdict's own art in the order
176// asked. One call's art moves by up to 0.06 on unchanged code (measured
177// 2026-09-25 across 14 songs), so the recorded score is a mean
178// (tools/critic/).
179export function meanVerdict(verdicts) {
180  if (!verdicts.length) throw new Error('meanVerdict: no verdicts');
181  const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
182  const criteria = Object.fromEntries(
183    Object.keys(CRITERIA).map((key) => [key, Math.round(mean(verdicts.map((v) => v.criteria[key])) * 10) / 10]),
184  );
185  const artRuns = verdicts.map((v) => v.art);
186  return { art: Math.round(mean(artRuns) * 100) / 100, criteria, artRuns };
187}