jevstrudel.git / website / src / jev / jevCore.test.mjs
1import { describe, expect, it } from 'vitest';
2import { pure, sequence, stack } from '@strudel/core';
3import { askOpening, asWritten, choice, createJev, noul, score, setReplay, setWrittenParts, walk } from './jevCore.mjs';
4import { performanceOf } from './performance.mjs';
5import { boothStore } from './boothStore.mjs';
6
7const settle = () => new Promise((r) => setTimeout(r, 0));
8const values = (pat, b, e) => pat.queryArc(b, e).map((h) => h.value);
9// play cycles one at a time, letting answers land between them
10async function play(pat, cycles) {
11  for (let c = 0; c < cycles; c += 1) {
12    pat.queryArc(c, c + 1);
13    await settle();
14  }
15}
16
17const moves = choice('Pick the move', { asWritten: 'as written', other: 'the other one' });
18
19describe('jev', () => {
20  it('asks every question for the next section in one request, and answers become patterns', async () => {
21    const calls = [];
22    const jev = createJev(async (state, questions, timing) => {
23      calls.push({ state, questions, timing });
24      return {
25        move: { type: 'choice', choice: 'other', confidence: 0.8, probabilities: { asWritten: 0.2, other: 0.8 } },
26        energy: { type: 'score', score: 1.5, confidence: 0.7 },
27        drop: { type: 'noul', noul: 0.9 },
28      };
29    });
30    const djJev = jev({
31      state: { title: 'T' },
32      questions: {
33        move: moves,
34        energy: score('How intense?', ['low', 'mid', 'high']),
35        drop: noul('Drop the drums?', { true: 'drop them', false: 'keep them' }),
36      },
37    });
38    const { move, energy, drop } = djJev.every(2, { fallback: { energy: 2, drop: 0 } });
39    // section 0 plays the fallbacks; querying its second half asks about section 1
40    expect(values(move.choice, 0, 2)).toEqual(['asWritten']);
41    expect(values(energy.score, 0, 2)).toEqual([2]);
42    expect(values(drop.noul, 0, 2)).toEqual([0]);
43    await settle();
44    expect(calls).toHaveLength(1);
45    expect(Object.keys(calls[0].questions)).toEqual(['move', 'energy', 'drop']);
46    expect(calls[0].questions.move).toEqual({
47      type: 'choice',
48      instructions: 'Pick the move',
49      criteria: moves.criteria,
50    });
51    expect(calls[0].state).toMatchObject({ song: { title: 'T' }, segment: 2, cycles: '3-4' });
52    // the cycle its section begins at, for the page's timing in the relay's log
53    expect(calls[0].timing).toEqual({ begins: 2 });
54    expect(values(move.choice, 2, 4)).toEqual(['other']);
55    expect(values(move.confidence, 2, 4)).toEqual([0.8]);
56    expect(values(energy.score, 2, 4)).toEqual([1.5]);
57    expect(values(drop.noul, 2, 4)).toEqual([0.9]);
58    // composition is plain Strudel
59    const song = move.choice.pick({ asWritten: pure('a'), other: pure('b') });
60    expect(values(song, 2, 3)).toEqual(['b']);
61  });
62
63  it('plays the fallback for anything unusable, and when Jev cannot be reached', async () => {
64    for (const answer of [{ move: { choice: 'rm -rf' } }, { move: { choice: 'toString' } }, {}, undefined]) {
65      const { move } = createJev(async () => answer)({ questions: { move: moves } }).every(1);
66      await play(move.choice, 1);
67      expect(values(move.choice, 1, 2)).toEqual(['asWritten']);
68    }
69    const down = createJev(() => Promise.reject(new Error('offline')))({ questions: { move: moves } }).every(1);
70    await play(down.move.choice, 1);
71    expect(values(down.move.choice, 1, 2)).toEqual(['asWritten']);
72  });
73
74  it('plays the fallbacks through a section that began before Jev answered', async () => {
75    let answer;
76    const answers = createJev(() => new Promise((r) => (answer = r)))({ questions: { move: moves } }).every(1);
77    const { choice: move } = answers.move;
78    values(move, 0, 1);
79    await settle();
80    values(move, 1, 2); // section 1 begins with its request still out
81    answer({ move: { choice: 'other' } });
82    await settle();
83    expect(values(move, 1, 2)).toEqual(['asWritten']);
84  });
85
86  it('a late answer keeps the section and plays from the next cycle', async () => {
87    let answer;
88    const levels = score('How much?', ['out', 'back', 'full']);
89    const dj = createJev(() => new Promise((r) => (answer = r)))({ questions: { drums: levels } }).every(4, {
90      fallback: { drums: 2 },
91    });
92    for (let c = 0; c < 5; c++) {
93      dj.drums.score.queryArc(c, c + 1); // section 1 (cycles 4-7) begins with its answer still out
94      await settle();
95    }
96    answer({ drums: { score: 0.2, confidence: 1 } });
97    await settle();
98    // cycle 4 already played the fallback; from cycle 5 on, Jev's answer
99    expect(values(dj.drums.score, 4, 8)).toEqual([2, 0.2]);
100    expect(dj.drums.score.queryArc(4, 8).map((h) => [h.whole.begin.valueOf(), h.whole.end.valueOf()])).toEqual([
101      [4, 5],
102      [5, 8],
103    ]);
104  });
105
106  it('askOpening: asks about the opening before the song starts', async () => {
107    const calls = [];
108    boothStore.beginEval();
109    const dj = createJev(async (state) => {
110      calls.push(state);
111      return { move: { choice: 'other', confidence: 0.9 } };
112    })({ questions: { move: moves } }).every(4);
113    await askOpening(1000);
114    expect(calls).toHaveLength(1);
115    expect(calls[0]).toMatchObject({ segment: 1, cycles: '1-4' });
116    expect(values(dj.move.choice, 0, 4)).toEqual(['other']);
117    boothStore.commitEval();
118  });
119
120  it('a read ahead (.early) does not count as the section beginning, and keeps the fallback it played', async () => {
121    // the answer lands while the song reads section 1 a cycle early, before section 1 plays:
122    // the drums take it, and the transition already sounding keeps its fallback to the bar's end
123    let answer;
124    const levels = score('How much?', ['out', 'back', 'full']);
125    boothStore.beginEval();
126    const dj = createJev(() => new Promise((r) => (answer = r)))({ questions: { drums: levels, into: moves } }).every(
127      4,
128      { fallback: { drums: 2 } },
129    );
130    boothStore.commitEval();
131    const song = pure(0).stack(dj.drums.score, dj.into.choice.early(1));
132    for (let c = 0; c < 4; c++) {
133      song.queryArc(c, c + 1);
134      await settle();
135    }
136    answer({ drums: { score: 0.2, confidence: 1 }, into: { choice: 'other', confidence: 1 } });
137    await settle();
138    expect(values(dj.drums.score, 4, 5)).toEqual([0.2]);
139    expect(values(dj.into.choice, 4, 5)).toEqual(['asWritten']);
140    expect(values(dj.into.choice.early(1), 3, 4)).toEqual(['asWritten']);
141    const d = boothStore.getSnapshot().booth.decisions[1];
142    expect(d.status).toBe('answered');
143    expect(d.problems).toEqual(['into: answered after a read ahead played it; it kept the fallback']);
144  });
145
146  describe('walk: a form, in code', () => {
147    const song = sequence(0, 1, 2, 3).slow(4); // each cycle's value is its own number
148    const sectionQ = choice('Pick the next section', { intro: 'the intro', verse: 'the verse', outro: 'the outro' });
149    const sections = {
150      intro: { at: 0, next: ['verse'] },
151      verse: { at: 2, next: ['verse', 'outro'] },
152      outro: { at: 4, next: [] },
153    };
154    const heard = (pat, n) => [...Array(n).keys()].map((c) => String(values(pat, c, c + 1)[0] ?? '-'));
155
156    it('narrows the choice to the sections allowed next, and each section plays its own cycles', async () => {
157      const picks = ['verse', 'outro'];
158      const calls = [];
159      const { section } = createJev(async (state, questions) => {
160        calls.push({ state, questions });
161        return questions.section ? { section: { choice: picks.shift() } } : {};
162      })({ questions: { section: sectionQ } }).every(2);
163      const form = walk(section, { sections });
164      const arranged = form.arrange(song);
165      await play(arranged, 12);
166      // intro may only be followed by verse: nothing to ask, no request
167      expect(calls[0].state.form).toMatchObject({ playingNow: 'verse', mayComeNext: ['verse', 'outro'] });
168      expect(calls[0].questions.section).toEqual({
169        type: 'choice',
170        instructions: 'Pick the next section',
171        criteria: { verse: 'the verse', outro: 'the outro' },
172      });
173      expect(calls[1].state.history.map((h) => h.section)).toEqual(['intro', 'verse', 'verse']);
174      expect(heard(section.choice, 10)).toEqual([
175        'intro',
176        'intro',
177        'verse',
178        'verse',
179        'verse',
180        'verse',
181        'outro',
182        'outro',
183        '-',
184        '-',
185      ]);
186      // the outro plays cycles 4-5, where this 4-cycle song wraps to 0-1
187      expect(heard(arranged, 10)).toEqual(['0', '1', '2', '3', '2', '3', '0', '1', '-', '-']);
188    });
189
190    it('an answer composed inside arrange() reads the section playing, not its written position', async () => {
191      // Jev repeats the verse; each section gets its own move
192      const moveOf = ['asWritten', 'other', 'asWritten', 'other'];
193      let n = 0;
194      const answers = createJev(async (state, q) => ({
195        ...(q.section ? { section: { choice: state.segment <= 3 ? 'verse' : 'outro' } } : {}),
196        move: { choice: moveOf[n++ + 1] ?? 'asWritten' },
197      }))({ questions: { section: sectionQ, move: moves } }).every(2);
198      const form = walk(answers.section, { sections });
199      // the move is picked INSIDE the arrangement
200      const played = form.arrange(answers.move.choice.pick({ asWritten: pure('a'), other: pure('b') }));
201      await play(played, 10);
202      // sections: intro, verse, verse (repeated, at the verse's written cycles), outro
203      expect(heard(answers.section.choice, 8)).toEqual([
204        'intro',
205        'intro',
206        'verse',
207        'verse',
208        'verse',
209        'verse',
210        'outro',
211        'outro',
212      ]);
213      const moves3 = [0, 2, 4, 6].map((c) => answers.move.choice.queryArc(c, c + 1)[0].value);
214      expect(heard(played, 8)).toEqual(moves3.flatMap((m) => (m === 'other' ? ['b', 'b'] : ['a', 'a'])));
215    });
216
217    it('plays in written order without Jev, and forces an ending at the cap', async () => {
218      const down = createJev(() => Promise.reject(new Error('offline')))({ questions: { section: sectionQ } }).every(2);
219      await play(walk(down.section, { sections }).arrange(song), 8);
220      expect(down.section.choice.queryArc(0, 8).map((h) => h.value)).toEqual(['intro', 'verse', 'outro']);
221
222      const loops = createJev(async (s, q) => (q.section ? { section: { choice: 'verse' } } : {}))({
223        questions: { section: sectionQ },
224      }).every(2);
225      await play(walk(loops.section, { sections, max: 4 }).arrange(song), 12);
226      expect(loops.section.choice.queryArc(0, 12).map((h) => h.value)).toEqual(['intro', 'verse', 'verse', 'outro']);
227    });
228
229    it('asWritten: a query sees the song as written, and asks and changes nothing', async () => {
230      let calls = 0;
231      const dj = createJev(async (s, q) => {
232        calls++;
233        return q.section ? { section: { choice: 'verse' } } : {};
234      })({ questions: { section: sectionQ } }).every(2);
235      const arranged = walk(dj.section, { sections, max: 4 }).arrange(song);
236      const all = asWritten(() => ({
237        sections: dj.section.choice.queryArc(0, 12).map((h) => h.value),
238        cycles: heard(arranged, 12),
239      }));
240      await settle();
241      expect(all.sections).toEqual(['intro', 'verse', 'outro']);
242      // the outro is written at cycles 4-5, where the four-cycle song reads 0 and 1
243      expect(all.cycles).toEqual(['0', '1', '2', '3', '0', '1', '-', '-', '-', '-', '-', '-']);
244      expect(calls).toBe(0);
245      // a real play afterwards still asks, and plays Jev's answers
246      await play(arranged, 12);
247      expect(calls).toBeGreaterThan(0);
248      expect(dj.section.choice.queryArc(0, 12).map((h) => h.value)).toEqual(['intro', 'verse', 'verse', 'outro']);
249    });
250
251    it('asks about a section it skipped before the one after it', async () => {
252      const asked = [];
253      const answers = createJev(async (state) => {
254        asked.push(state.segment);
255        return {};
256      })({ questions: { move: moves, section: sectionQ } }).every(2);
257      walk(answers.section, { sections });
258      values(answers.move.choice, 3.5, 4); // lands in section 1's second half first
259      await settle();
260      expect(asked).toEqual([2, 3]);
261    });
262
263    it('asks nothing after a section whose pending fallback ends the song', async () => {
264      // the verse's written next is the outro, so that is its fallback while Jev is slow
265      const q = choice('Pick', { intro: 'i', verse: 'v', outro: 'o' });
266      const form = {
267        intro: { at: 0, next: ['verse'] },
268        verse: { at: 2, next: ['outro', 'verse'] },
269        outro: { at: 4, next: [] },
270      };
271      const calls = [];
272      let answer;
273      const { section } = createJev((state, questions) => {
274        calls.push(questions);
275        return new Promise((r) => (answer = r));
276      })({ questions: { section: q } }).every(2);
277      walk(section, { sections: form, max: 8 });
278      await play(section.choice, 4); // intro, then the verse (forced), which asks about section 2
279      expect(calls).toHaveLength(1);
280      expect(() => section.choice.queryArc(4, 6)).not.toThrow(); // section 2 plays while Jev is out
281      await settle();
282      expect(calls).toHaveLength(1); // nothing asked about what follows the pending outro
283      answer({ section: { choice: 'verse' } }); // too late: section 2 already began
284      await play(section.choice, 10);
285      expect(values(section.choice, 4, 5)).toEqual(['outro']);
286      expect(values(section.choice, 6, 7)).toEqual([]);
287      expect(calls.every((c) => !c.section || Object.keys(c.section.criteria).length)).toBe(true);
288    });
289
290    it('offers only sections that can still reach the ending by the cap', async () => {
291      // chorus needs two more sections (bridge, outro); at the cap it is not offered
292      const q = choice('Pick', { intro: 'i', verse: 'v', chorus: 'c', bridge: 'b', outro: 'o' });
293      const long = {
294        intro: { at: 0, next: ['verse'] },
295        verse: { at: 2, next: ['verse', 'chorus', 'outro'] },
296        chorus: { at: 4, next: ['bridge'] },
297        bridge: { at: 6, next: ['outro'] },
298        outro: { at: 8, next: [] },
299      };
300      const offered = [];
301      const { section } = createJev(async (state, questions) => {
302        if (questions.section) offered.push(Object.keys(questions.section.criteria));
303        return questions.section ? { section: { choice: 'verse' } } : {};
304      })({ questions: { section: q } }).every(2);
305      walk(section, { sections: long, max: 5 });
306      await play(section.choice, 10);
307      // sections 0-4: intro, then verse; chorus would need 3 more slots
308      expect(offered[0]).toEqual(['verse', 'chorus', 'outro']);
309      expect(offered.at(-1)).toEqual(['verse', 'outro']);
310      expect(section.choice.queryArc(8, 9).map((h) => h.value)).toEqual(['outro']);
311      expect(() =>
312        walk(createJev(async () => ({}))({ questions: { section: q } }).every(2).section, { sections: long, max: 2 }),
313      ).toThrow(/over the cap/);
314    });
315
316    describe('times: the most plays of a section in one performance', () => {
317      // a groove that loops a → b → c → a, as borrow-checker-blues' does: no
318      // section repeats back to back, so only a total bounds the loop
319      const q = choice('Pick', { intro: 'i', a: 'a', b: 'b', c: 'c', outro: 'o' });
320      const grooves = (times) => ({
321        intro: { at: 0, next: ['a'] },
322        a: { at: 2, next: ['b'], ...(times ? { times } : {}) },
323        b: { at: 4, next: ['c', 'outro'] },
324        c: { at: 6, next: ['a', 'outro'] },
325        outro: { at: 8, next: [] },
326      });
327      // a Jev that always loops back when it may
328      const looper = (offered) => async (state, questions) => {
329        if (!questions.section) return {};
330        const options = Object.keys(questions.section.criteria);
331        offered.push(options);
332        return { section: { choice: ['a', 'c', 'b'].find((o) => options.includes(o)) ?? options[0] } };
333      };
334      const path = (section) => section.choice.queryArc(0, 64).map((h) => h.value);
335
336      it('without it, a loop runs to the cap', async () => {
337        const { section } = createJev(looper([]))({ questions: { section: q } }).every(2);
338        walk(section, { sections: grooves(), max: 16 });
339        await play(section.choice, 40);
340        expect(path(section).filter((s) => s === 'a')).toHaveLength(5);
341      });
342
343      it('with it, the loop goes round at most that often, and the song still ends', async () => {
344        const offered = [];
345        const { section } = createJev(looper(offered))({ questions: { section: q } }).every(2);
346        walk(section, { sections: grooves(2), max: 16 });
347        await play(section.choice, 40);
348        expect(path(section)).toEqual(['intro', 'a', 'b', 'c', 'a', 'b', 'c', 'outro']);
349        // once a has played twice, c is offered only the outro: nothing is asked
350        expect(offered.some((o) => o.includes('a') && !o.includes('b'))).toBe(true);
351        expect(offered.at(-1)).not.toContain('a');
352      });
353
354      it('never offers a section whose only way to the ending runs through one used up', async () => {
355        // x may only go on through y, and y plays once: after y, x is not offered
356        const q2 = choice('Pick', { intro: 'i', x: 'x', y: 'y', v: 'v', outro: 'o' });
357        const sections2 = {
358          intro: { at: 0, next: ['v', 'x'] },
359          v: { at: 2, next: ['x', 'v', 'outro'] },
360          x: { at: 4, next: ['y'] },
361          y: { at: 6, next: ['outro', 'v'], times: 1 },
362          outro: { at: 8, next: [] },
363        };
364        const offered = [];
365        const { section } = createJev(async (state, questions) => {
366          if (!questions.section) return {};
367          const options = Object.keys(questions.section.criteria);
368          offered.push(options);
369          return { section: { choice: options.includes('x') ? 'x' : options.includes('v') ? 'v' : options[0] } };
370        })({ questions: { section: q2 } }).every(2);
371        walk(section, { sections: sections2, max: 10 });
372        await play(section.choice, 40);
373        const played = path(section);
374        expect(played.filter((s) => s === 'y')).toHaveLength(1);
375        expect(played.at(-1)).toBe('outro');
376        // after y, x is offered no more, since x leads only to y
377        expect(offered[0]).toContain('x');
378        expect(offered.slice(1).every((o) => !o.includes('x'))).toBe(true);
379      });
380
381      it('the fallback is the written next while it may play, else the shortest way on', async () => {
382        // Jev detours to c early, then goes offline at b, whose written next is c, used up
383        const q3 = choice('Pick', { intro: 'i', a: 'a', b: 'b', c: 'c', outro: 'o' });
384        const sections3 = {
385          intro: { at: 0, next: ['a', 'c'] },
386          a: { at: 2, next: ['b'] },
387          b: { at: 4, next: ['c', 'outro', 'a'] },
388          c: { at: 6, next: ['outro', 'b'], times: 1 },
389          outro: { at: 8, next: [] },
390        };
391        const picks = ['c', 'b'];
392        const { section } = createJev(async (state, questions) => {
393          if (!questions.section) return {};
394          if (picks.length) return { section: { choice: picks.shift() } };
395          throw new Error('offline');
396        })({ questions: { section: q3 } }).every(2);
397        walk(section, { sections: sections3, max: 16 });
398        await play(section.choice, 40);
399        expect(path(section)).toEqual(['intro', 'c', 'b', 'outro']);
400      });
401
402      it('is refused unless a whole number of plays, at least 1', () => {
403        const fresh = () => createJev(async () => ({}))({ questions: { section: q } }).every(2).section;
404        for (const times of [0, 1.5, '2', -1]) {
405          expect(() => walk(fresh(), { sections: { ...grooves(), a: { at: 2, next: ['b'], times } } })).toThrow(
406            /times/,
407          );
408        }
409        expect(() => walk(fresh(), { sections: { ...grooves(), a: { at: 2, next: ['b'], time: 2 } } })).toThrow(
410          /not time/,
411        );
412      });
413    });
414
415    it('a section the form does not have, from Jev or a recording, plays the fallback and the song ends', async () => {
416      // "reading 'next'": a section name missing from the form must never be settled
417      const answers = [
418        ...['bridge', 'toString', '__proto__', undefined].map((choice) => ({ choice, confidence: 1 })),
419        // a usable top pick whose probabilities name a section the song lacks: sampling may not pick it
420        { choice: 'outro', confidence: 0.1, probabilities: { bridge: 0.99, outro: 0.01 } },
421      ];
422      for (const answer of answers) {
423        const { section } = createJev(async (s, q) => (q.section ? { section: answer } : {}), {
424          random: () => 0.5,
425        })({
426          questions: { section: sectionQ },
427        }).every(2, { sample: 1 });
428        walk(section, { sections, max: 6 });
429        await play(section.choice, 14);
430        expect(section.choice.queryArc(0, 14).map((h) => h.value)).toEqual(['intro', 'verse', 'outro']);
431      }
432      setReplay({
433        changed: true,
434        jevs: [
435          {
436            segments: [
437              { status: 'answered', values: { section: { choice: 'intro' } } },
438              { status: 'answered', values: { section: { choice: 'verse' } } },
439              { status: 'answered', values: { section: { choice: 'bridge' } } }, // gone from the song
440            ],
441          },
442        ],
443      });
444      try {
445        const { section } = createJev(async () => {
446          throw new Error('a replay asks nothing');
447        })({ questions: { section: sectionQ } }).every(2);
448        walk(section, { sections, max: 6 });
449        await play(section.choice, 14);
450        expect(section.choice.queryArc(0, 14).map((h) => h.value)).toEqual(['intro', 'verse', 'outro']);
451      } finally {
452        setReplay(null);
453      }
454    });
455
456    it('refuses a form that does not match its choice, or cannot fall back to its written order', () => {
457      const jev = createJev(async () => ({}));
458      const fresh = () => jev({ questions: { section: sectionQ } }).every(2).section;
459      expect(() => walk(fresh(), { sections: { intro: sections.intro, verse: sections.verse } })).toThrow(
460        /missing outro/,
461      );
462      const skips = {
463        intro: { at: 0, next: ['outro'] },
464        verse: { at: 2, next: ['outro'] },
465        outro: { at: 4, next: [] },
466      };
467      expect(() => walk(fresh(), { sections: skips })).toThrow(/must include/);
468      const noEnd = {
469        intro: { at: 0, next: ['verse'] },
470        verse: { at: 2, next: ['outro'] },
471        outro: { at: 4, next: ['intro'] },
472      };
473      expect(() => walk(fresh(), { sections: noEnd })).toThrow(/ending/);
474      expect(() => walk({}, { sections })).toThrow(/answer from jev/);
475    });
476
477    it('refuses a way on, or a section, that can never play within the cap', () => {
478      // hey-listen's drop2d → drop1: reached late, and the long way round from there overruns the cap
479      const q = choice('Pick', { intro: 'i', a: 'a', b: 'b', c: 'c', outro: 'o' });
480      const fresh = () => createJev(async () => ({}))({ questions: { section: q } }).every(2).section;
481      const form = (cNext) => ({
482        intro: { at: 0, next: ['a'] },
483        a: { at: 2, next: ['b'] },
484        b: { at: 4, next: ['c'] },
485        c: { at: 6, next: cNext },
486        outro: { at: 8, next: [] },
487      });
488      // c is the 4th section at the earliest; back to a from there needs a, b, c, outro: 8 > 6
489      expect(() => walk(fresh(), { sections: form(['outro', 'a']), max: 6 })).toThrow(/"c" → "a" can never be taken/);
490      expect(() => walk(fresh(), { sections: form(['outro', 'a']), max: 8 })).not.toThrow();
491      // a section only reachable by a way that is too long to end in time
492      const far = {
493        intro: { at: 0, next: ['a', 'outro'] },
494        a: { at: 2, next: ['b'] },
495        b: { at: 4, next: ['c'] },
496        c: { at: 6, next: ['outro'] },
497        outro: { at: 8, next: [] },
498      };
499      expect(() => walk(fresh(), { sections: far, max: 4 })).toThrow(/"a" can never play|can never be taken/);
500    });
501  });
502
503  it('tells Jev how the listener reacted', async () => {
504    const states = [];
505    boothStore.beginEval();
506    const { move } = createJev(async (state) => {
507      states.push(state);
508      return {};
509    })({ questions: { move: moves } }).every(1);
510    boothStore.commitEval();
511    const view = boothStore.getSnapshot().booth;
512    view.react(0, 'fire');
513    view.react(0, 'fire');
514    await play(move.choice, 1);
515    expect(states.at(-1).audience).toEqual([{ cycles: '1', fire: 2, sleep: 0 }]);
516  });
517
518  it('refuses what it cannot ask', () => {
519    const jev = createJev(async () => ({}));
520    expect(() => jev({})).toThrow(/questions/);
521    expect(() => jev({ every: 8, questions: { move: moves } })).toThrow(/state and questions only/);
522    expect(() => jev({ questions: { move: { type: 'extract' } } })).toThrow(/choice\(\), score\(\) or noul\(\)/);
523    expect(() => jev({ questions: { move: choice('x', { a: 'a' }) } })).toThrow(/at least two/);
524    expect(() => jev({ questions: { e: score('x', ['a', 'b']) } }).every(8)).toThrow(/fallback from 0 to 1/);
525    expect(() => jev({ questions: { d: noul('x') } }).every(8)).toThrow(/fallback from 0 to 1/);
526    expect(() => jev({ questions: { move: moves } }).every(8, { fallback: { move: 'nope' } })).toThrow(/not an option/);
527    expect(() => jev({ questions: { move: moves } }).every(8, { fallback: { stray: 1 } })).toThrow(/not a question/);
528    const twice = jev({ questions: { move: moves } });
529    twice.every(8);
530    expect(() => twice.every(4)).toThrow(/one .every\(\)/);
531    expect(() => jev({ questions: { move: choice(pure('x'), { a: 'a', b: 'b' }) } })).toThrow(/single quotes/);
532  });
533
534  describe('.every options', () => {
535    const sectionQ = choice('Pick the next section', { intro: 'the intro', verse: 'the verse', outro: 'the outro' });
536    const sections = {
537      intro: { at: 0, next: ['verse'] },
538      verse: { at: 2, next: ['verse', 'outro'] },
539      outro: { at: 4, next: [] },
540    };
541
542    it('after: asks once the other jev has decided, with its answer in the state', async () => {
543      boothStore.beginEval();
544      const order = [];
545      const formJev = createJev(async (state, q) => {
546        order.push('section');
547        return q.section ? { section: { choice: 'outro' } } : {};
548      })({ questions: { section: sectionQ } });
549      const { section } = formJev.every(2);
550      const form = walk(section, { sections });
551      const told = [];
552      const djJev = createJev(async (state) => {
553        order.push('move');
554        told.push(state);
555        return { move: { choice: 'other', confidence: 0.9 } };
556      })({ questions: { move: moves } });
557      const { move } = djJev.every(2, { after: { section } });
558      await play(form.arrange(move.choice), 8);
559      // section 1 is forced (intro → verse): no section request, the move asked with it known
560      expect(order[0]).toBe('move');
561      expect(told[0].decidedThisSection).toEqual({ section: 'verse: the verse' });
562      expect(told.at(-1).decidedThisSection).toEqual({ section: 'outro: the outro' });
563      expect(order).toContain('section');
564      // after the outro the song is over for both
565      expect(values(move.choice, 6, 8)).toEqual([]);
566      // and the move asked about the section after it says the song ended, not that Jev failed
567      const view = boothStore.starting().find((v) => v.questions.some((q) => q.name === 'move')) ?? boothStore.getSnapshot().booth;
568      console.log('DBG', JSON.stringify(boothStore.starting().map((v) => [v.questions.map((q) => q.name), v.decisions.map((d) => d && [d.segment, d.status, d.reason, d.ended])])));
569      const ended = view.decisions.filter((d) => d?.ended);
570      expect(ended.map((d) => [d.segment, d.status])).toEqual([[3, 'fallback']]);
571    });
572
573    it('forget: leaves a question out of the history; a part absent from its section is not asked', async () => {
574      const levels = score('How much?', ['out', 'back', 'full']);
575      const told = [];
576      boothStore.beginEval();
577      const formJev = createJev(async () => ({}))({ questions: { section: sectionQ } });
578      const { section } = formJev.every(2);
579      const form = walk(section, { sections });
580      const dj = createJev(async (state, q) => {
581        told.push({ state, asked: Object.keys(q) });
582        return { drums: { score: 1, confidence: 1 }, move: { choice: 'other', confidence: 1 } };
583      })({ questions: { drums: levels, move: moves } }).every(2, {
584        after: { section },
585        fallback: { drums: 2 },
586        forget: ['move'],
587      });
588      // as written, the drums play in the intro and the verse (cycles 0-3), not the outro
589      setWrittenParts([
590        { value: { s: 'bd', orbit: 'drums' }, whole: { begin: 0, end: 4 } },
591        { value: { s: 'x', orbit: 'other' }, whole: { begin: 4, end: 6 } },
592      ]);
593      boothStore.commitEval();
594      await play(form.arrange(stack(dj.drums.score, dj.move.choice)), 8);
595      const verse = told.find((t) => t.state.decidedThisSection.section.startsWith('verse'));
596      const outro = told.find((t) => t.state.decidedThisSection.section.startsWith('outro'));
597      expect(verse.asked).toEqual(['drums', 'move']);
598      expect(outro.asked).toEqual(['move']);
599      expect(outro.state.history.every((row) => !('move' in row))).toBe(true);
600      expect(outro.state.history.some((row) => 'drums' in row)).toBe(true);
601      expect(() => createJev(async () => ({}))({ questions: { move: moves } }).every(2, { forget: ['nope'] })).toThrow(
602        /forget: "nope"/,
603      );
604    });
605
606    it('after: history rows say which section each was, where the song is, and which rows were fallbacks', async () => {
607      const long = choice('Pick', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' });
608      const form4 = {
609        intro: { at: 0, next: ['verse'] },
610        verse: { at: 2, next: ['chorus', 'verse'] },
611        chorus: { at: 4, next: ['outro'] },
612        outro: { at: 6, next: [] },
613      };
614      const { section } = createJev(async (s, q) => (q.section ? { section: { choice: 'chorus' } } : {}))({
615        questions: { section: long },
616      }).every(2);
617      walk(section, { sections: form4 });
618      const told = [];
619      let n = 0;
620      const { move } = createJev(async (state) => {
621        told.push(state);
622        // Jev cannot be reached about section 1; it answers after that
623        if (n++ === 0) throw new Error('offline');
624        return { move: { choice: 'other', confidence: 0.9 } };
625      })({ questions: { move: moves } }).every(2, { after: { section } });
626      await play(move.choice, 8);
627      const last = told.at(-1);
628      expect(last.form).toEqual({ playingNow: 'chorus', sectionsLeft: 13 });
629      expect(last.history).toEqual([
630        { cycles: '1-2', decidedBefore: { section: 'intro' }, move: 'asWritten' },
631        { cycles: '3-4', decidedBefore: { section: 'verse' }, move: 'asWritten (fallback)' },
632        { cycles: '5-6', decidedBefore: { section: 'chorus' }, move: 'other' },
633      ]);
634    });
635
636    it("the form's history carries how each past section was arranged, less what is forgotten or absent", async () => {
637      const long = choice('Pick', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' });
638      const form4 = {
639        intro: { at: 0, next: ['verse'] },
640        verse: { at: 2, next: ['chorus', 'verse'] },
641        chorus: { at: 4, next: ['outro', 'verse'] },
642        outro: { at: 6, next: [] },
643      };
644      const levels = score('How much?', ['out', 'back', 'full']);
645      const formTold = [];
646      boothStore.beginEval();
647      const { section } = createJev(async (state, q) => {
648        formTold.push(state);
649        return q.section ? { section: { choice: state.segment < 4 ? 'verse' : 'outro' } } : {};
650      })({ questions: { section: long } }).every(2);
651      const form = walk(section, { sections: form4 });
652      let n = 0;
653      const dj = createJev(async (state) => {
654        // the opening (no drums in the intro), the verse with the drums
655        // back, then Jev cannot be reached about the repeated verse
656        n++;
657        if (n === 3) throw new Error('offline');
658        return {
659          drums: { score: 1, confidence: 1 },
660          into: { choice: 'other', confidence: 1 },
661        };
662      })({ questions: { drums: levels, into: moves } }).every(2, {
663        after: { section },
664        fallback: { drums: 0 },
665        forget: ['into'],
666      });
667      // as written the drums play from the verse on, not in the intro
668      setWrittenParts([{ value: { s: 'bd', orbit: 'drums' }, whole: { begin: 2, end: 8 } }]);
669      boothStore.commitEval();
670      await askOpening(100);
671      await play(form.arrange(stack(section.choice, dj.drums.score, dj.into.choice)), 8);
672      const last = formTold.find((s) => s.segment === 4);
673      expect(last.history).toEqual([
674        { cycles: '1-2', section: 'intro' },
675        { cycles: '3-4', section: 'verse', arrangement: { drums: '1 (back)' } },
676        { cycles: '5-6', section: 'verse', arrangement: { drums: '0 (out) (fallback)' } },
677      ]);
678      // the parts' own history is as before: its own answers, with the section before
679      expect(JSON.stringify(last)).not.toMatch(/other/);
680    });
681
682    it('refuses two jev()s after one form that share a question name, which its history could not tell apart', () => {
683      const jev = createJev(async () => ({}));
684      const { section } = jev({ questions: { section: sectionQ } }).every(2);
685      jev({ questions: { move: moves } }).every(2, { after: { section } });
686      expect(() => jev({ questions: { move: moves } }).every(2, { after: { section } })).toThrow(/both have "move"/);
687      // a name one of them forgets is not in the history, so both may have it
688      const other = jev({ questions: { section: sectionQ } }).every(2).section;
689      jev({ questions: { into: moves } }).every(2, { after: { section: other }, forget: ['into'] });
690      expect(() => jev({ questions: { into: moves } }).every(2, { after: { section: other } })).not.toThrow();
691    });
692
693    it('allow: narrows a choice per request from what is decided, and plays a lone option unasked', async () => {
694      const intoQ = choice('How in?', { cut: 'c', fill: 'f', riser: 'r' });
695      const { section } = createJev(async (s, q) => (q.section ? { section: { choice: 'outro' } } : {}))({
696        questions: { section: sectionQ },
697      }).every(2);
698      walk(section, { sections });
699      const asked = [];
700      const seen = [];
701      const { into } = createJev(async (state, q) => {
702        asked.push(q.into && Object.keys(q.into.criteria));
703        return q.into ? { into: { choice: 'riser' } } : {};
704      })({ questions: { into: intoQ } }).every(2, {
705        after: { section },
706        allow: {
707          into: (d) => {
708            seen.push(d);
709            return d.section === 'outro' ? ['cut'] : ['cut', 'riser'];
710          },
711        },
712      });
713      await play(into.choice, 8);
714      expect(seen[0]).toEqual({ section: 'verse', playingNow: 'intro' });
715      expect(asked[0]).toEqual(['cut', 'riser']);
716      // into the outro only a cut is allowed: nothing is asked
717      expect(asked).toHaveLength(1);
718      expect([0, 2, 4].map((c) => values(into.choice, c, c + 1)[0])).toEqual(['cut', 'riser', 'cut']);
719
720      const noFallback = createJev(async () => ({ into: { choice: 'fill' } }))({ questions: { into: intoQ } }).every(
721        1,
722        {
723          allow: { into: () => ['fill', 'riser'] },
724        },
725      );
726      await play(noFallback.into.choice, 1);
727      expect(values(noFallback.into.choice, 1, 2)).toEqual(['cut']);
728      expect(() =>
729        createJev(async () => ({}))({ questions: { into: intoQ, e: score('x', ['a', 'b']) } }).every(1, {
730          fallback: { e: 0 },
731          allow: { e: () => [] },
732        }),
733      ).toThrow(/must name a choice/);
734    });
735
736    it("sample: draws from Jev's probabilities; minConfidence: falls back when Jev is unsure", async () => {
737      const answer = { choice: 'asWritten', confidence: 0.2, probabilities: { asWritten: 0.6, other: 0.4 } };
738      const sampled = createJev(async () => ({ move: { ...answer, confidence: 0.9 } }), { random: () => 0.99 })({
739        questions: { move: moves },
740      }).every(1, { sample: 1 });
741      await play(sampled.move.choice, 1);
742      expect(values(sampled.move.choice, 1, 2)).toEqual(['other']);
743
744      const unsure = createJev(async () => ({ move: { ...answer, choice: 'other' } }))({
745        questions: { move: moves },
746      }).every(1, { minConfidence: 0.5 });
747      await play(unsure.move.choice, 1);
748      expect(values(unsure.move.choice, 1, 2)).toEqual(['asWritten']);
749
750      // not both on one question: sampling acts on the probabilities
751      const both = () =>
752        createJev(async () => ({}))({ questions: { move: moves } }).every(1, { sample: 0.7, minConfidence: 0.3 });
753      expect(both).toThrow(/cannot also have a minConfidence/);
754    });
755
756    it('tells Jev how the last sections measured', async () => {
757      let told;
758      const { move } = createJev(
759        async (state) => {
760          told = state;
761          return {};
762        },
763        { hear: (from, to) => ({ loudnessDbfs: -from, peakDbfs: -1, clipped: false }) },
764      )({ questions: { move: moves } }).every(2);
765      await play(move.choice, 6);
766      // the sections finished before the one playing: here 1-2 and 3-4, as 5-6 plays
767      expect(told.measuredSound.map((m) => m.cycles)).toEqual(['1-2', '3-4']);
768    });
769
770    it('tells Jev how each part measured', async () => {
771      let told;
772      const parts = {
773        riff: { loudnessDbfs: -18, peakDbfs: -3, silentShare: 0 },
774        bass: { loudnessDbfs: -40, peakDbfs: -30, silentShare: 0.9 },
775      };
776      const { move } = createJev(
777        async (state) => {
778          told = state;
779          return {};
780        },
781        { hear: () => ({ loudnessDbfs: -16, peakDbfs: -1, clipped: false, silentShare: 0, parts }) },
782      )({ questions: { move: moves } }).every(2);
783      await play(move.choice, 6);
784      expect(told.measuredSound[0].parts).toEqual(parts);
785    });
786
787    it('shows two jev()s on one cadence as one strip', async () => {
788      boothStore.beginEval();
789      const { section } = createJev(async () => ({}))({ questions: { section: sectionQ } }).every(2);
790      const { move } = createJev(async () => ({}))({ questions: { move: moves } }).every(2, { after: { section } });
791      boothStore.commitEval();
792      await play(move.choice, 3);
793      const booth = boothStore.getSnapshot().booth;
794      expect(booth.questions.map((q) => q.name)).toEqual(['section', 'move']);
795      expect(Object.keys(booth.decisions[1].values)).toEqual(['section', 'move']);
796    });
797  });
798
799  describe('listenersSoFar: every listener, cut to the sections that matter', () => {
800    const sectionQ = choice('Pick the next section', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' });
801    const sections = {
802      intro: { at: 0, next: ['verse', 'chorus'] },
803      verse: { at: 2, next: ['chorus', 'outro'] },
804      chorus: { at: 4, next: ['verse', 'outro'] },
805      outro: { at: 6, next: [] },
806    };
807
808    it("sends the tally for the sections played and those that may come next, and only the form's names", async () => {
809      const told = [];
810      const tally = {
811        intro: { fire: 1, sleep: 4 },
812        verse: { fire: 7, sleep: 0 },
813        outro: { fire: 2, sleep: 2 },
814        '<script>': { fire: 99, sleep: 0 }, // anyone's writing: not a section
815        chorus: { fire: -3, sleep: 'x' }, // nonsense counts become 0
816      };
817      const { section } = createJev(
818        async (state, q) => {
819          told.push(state);
820          return q.section ? { section: { choice: 'verse' } } : {};
821        },
822        { listeners: () => tally },
823      )({ questions: { section: sectionQ } }).every(2);
824      walk(section, { sections });
825      await play(section.choice, 3);
826      // asked about segment 1 while the intro plays: the intro, and verse or chorus next
827      const s1 = told.find((s) => s.segment === 2);
828      expect(s1.listenersSoFar).toEqual({
829        intro: { fire: 1, sleep: 4 },
830        verse: { fire: 7, sleep: 0 },
831        chorus: { fire: 0, sleep: 0 },
832      });
833    });
834
835    it('a jev asked after a form hears the tally of the section it is entering', async () => {
836      const told = [];
837      const listeners = () => ({ verse: { fire: 3, sleep: 1 } });
838      const { section } = createJev(async (s, q) => (q.section ? { section: { choice: 'verse' } } : {}), {
839        listeners,
840      })({ questions: { section: sectionQ } }).every(2);
841      const form = walk(section, { sections });
842      const { move } = createJev(
843        async (state) => {
844          told.push(state);
845          return {};
846        },
847        { listeners },
848      )({ questions: { move: moves } }).every(2, { after: { section } });
849      await play(form.arrange(move.choice), 3);
850      expect(told.find((s) => s.segment === 2).listenersSoFar).toEqual({ verse: { fire: 3, sleep: 1 } });
851    });
852
853    it('without a tally, sends nothing', async () => {
854      const told = [];
855      const { move } = createJev(async (state) => void told.push(state), { listeners: () => null })({
856        questions: { move: moves },
857      }).every(1);
858      await play(move.choice, 2);
859      expect(told.length).toBeGreaterThan(0);
860      expect(told.every((s) => !('listenersSoFar' in s))).toBe(true);
861    });
862  });
863
864  describe('replay: a recorded performance, played without asking Jev', () => {
865    const sectionQ = choice('Pick the next section', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' });
866    const sections = {
867      intro: { at: 0, next: ['verse', 'chorus'] },
868      verse: { at: 2, next: ['chorus', 'verse', 'outro'] },
869      chorus: { at: 4, next: ['verse', 'outro'] },
870      outro: { at: 6, next: [] },
871    };
872    const levels = score('How much?', ['out', 'back', 'full']);
873
874    // A song as songs are: a form's jev() sampled, and parts asked after it.
875    function song(decide) {
876      const jev = createJev(decide, { random: () => 0.01 });
877      boothStore.beginEval();
878      const { section } = jev({ questions: { section: sectionQ } }).every(2, { sample: 1 });
879      const form = walk(section, { sections, max: 6 });
880      const dj = jev({ questions: { drums: levels, move: moves, drop: noul('Drop?') } }).every(2, {
881        after: { section },
882        fallback: { drums: 2, drop: 0 },
883      });
884      boothStore.commitEval();
885      const pattern = form.arrange(stack(section.choice, dj.drums.score, dj.move.choice, dj.drop.noul));
886      return { pattern, section, dj };
887    }
888    const heard = (s) => {
889      const out = [];
890      for (let c = 0; c < 12; c++) out.push(values(s.pattern, c, c + 1));
891      return out;
892    };
893    // a live Jev that varies its answers from call to call
894    const live = () => {
895      let n = 0;
896      return async (state, q) => {
897        n++;
898        const out = {};
899        if (q.section) {
900          const options = Object.keys(q.section.criteria);
901          const top = options.includes('chorus') ? 'chorus' : options.includes('outro') ? 'outro' : options[0];
902          out.section = {
903            choice: top,
904            confidence: 0.6,
905            probabilities: Object.fromEntries(options.map((o) => [o, o === top ? 0.6 : 0.4 / (options.length - 1)])),
906          };
907        }
908        if (q.drums) out.drums = { score: (n % 3) * 0.7, confidence: 0.5, probabilities: { 0: 0.2, 1: 0.3, 2: 0.5 } };
909        if (q.move) out.move = { choice: n % 2 ? 'other' : 'asWritten', confidence: 0.9 };
910        if (q.drop) out.drop = { noul: n / 10 };
911        return out;
912      };
913    };
914
915    it('plays the recorded answers, the same sections and levels, and makes no request', async () => {
916      const recorded = song(live());
917      await askOpening(100);
918      await play(recorded.pattern, 12);
919      const original = heard(recorded);
920      const booth = boothStore.getSnapshot().booth;
921      const perf = performanceOf(booth, { song: 'jev/x', codeHash: 'h', ended: 'finished', upTo: booth.last });
922      expect(perf.form).toEqual({ jev: 0, question: 'section' });
923      // a sampled pick is recorded as it played, with Jev's top pick beside it
924      expect(perf.jevs[0].segments.some((s) => s.values.section.sampled === 1)).toBe(true);
925      // what the site gives back is JSON
926      setReplay(JSON.parse(JSON.stringify({ jevs: perf.jevs })));
927      try {
928        let asked = 0;
929        const replayed = song(async () => {
930          asked++;
931          return {};
932        });
933        await askOpening(100);
934        await play(replayed.pattern, 12);
935        expect(asked).toBe(0);
936        expect(heard(replayed)).toEqual(original);
937        const again = boothStore.getSnapshot().booth;
938        expect(again.replay).toEqual({ changed: false });
939        expect(again.last).toBe(booth.last);
940        expect(again.decisions.map((d) => d.status)).toEqual(booth.decisions.map((d) => d.status));
941        expect(again.decisions.every((d) => !d.states.length)).toBe(true);
942      } finally {
943        setReplay(null);
944      }
945    });
946
947    it("plays a changed song where its form still allows, and the fallbacks where it doesn't", async () => {
948      setReplay({
949        changed: true,
950        jevs: [
951          {
952            segments: [
953              { status: 'answered', values: { section: { choice: 'intro' } } },
954              { status: 'answered', values: { section: { choice: 'outro' } } }, // intro → outro is not allowed
955              { status: 'answered', values: { section: { choice: 'chorus' } } },
956            ],
957          },
958          {
959            segments: [
960              { status: 'answered', values: { drums: { score: 1 }, move: { choice: 'gone' }, drop: { noul: 0.5 } } },
961              { status: 'answered', values: { drums: { score: 0 }, move: { choice: 'other' }, drop: { noul: 0.2 } } },
962            ],
963          },
964        ],
965      });
966      try {
967        let asked = 0;
968        const s = song(async () => {
969          asked++;
970          return {};
971        });
972        await askOpening(100);
973        await play(s.pattern, 12);
974        expect(asked).toBe(0);
975        const booth = boothStore.getSnapshot().booth;
976        expect(booth.replay).toEqual({ changed: true });
977        // segment 1: the recorded outro is not allowed after the intro; the written verse plays
978        expect(booth.decisions[1].values.section.choice).toBe('verse');
979        expect(booth.decisions[1].problems.join()).toMatch(/section: the recorded answer is not allowed/);
980        // the parts it recorded still play; an option no longer there plays the fallback
981        expect(values(s.dj.drums.score, 0, 2)).toEqual([1]);
982        expect(values(s.dj.move.choice, 0, 2)).toEqual(['asWritten']);
983        expect(booth.decisions[0].problems.join()).toMatch(/move: the recorded answer/);
984        expect(values(s.dj.drums.score, 2, 4)).toEqual([0]);
985        // segment 2 is recorded for the form only; the parts it lacks play their fallbacks
986        expect(booth.decisions[2].values.section.choice).toBe('chorus');
987        expect(values(s.dj.drums.score, 4, 6)).toEqual([2]);
988      } finally {
989        setReplay(null);
990      }
991    });
992
993    it('a late answer replays from the cycle it played from', async () => {
994      setReplay({
995        jevs: [
996          {
997            segments: [
998              { status: 'answered', values: { move: { choice: 'asWritten' } } },
999              { status: 'answered', from: 5, values: { move: { choice: 'other' } } },
1000            ],
1001          },
1002        ],
1003      });
1004      try {
1005        boothStore.beginEval();
1006        const dj = createJev(async () => {
1007          throw new Error('asked');
1008        })({ questions: { move: moves } }).every(4, { segments: 2 });
1009        boothStore.commitEval();
1010        await askOpening(100);
1011        await play(dj.move.choice, 8);
1012        expect(values(dj.move.choice, 4, 8)).toEqual(['asWritten', 'other']);
1013      } finally {
1014        setReplay(null);
1015      }
1016    });
1017  });
1018});