jevstrudel.git / website / src / jev / jevCore.mjs

jev({...}) — TypeSafe's API as Strudel patterns, in the shape of its SDK (@typesafe-ai/sdk: client.systemOne({ state, questions }), with questions built by choice, score and noul). A song declares its Jev once, at the top, and composes the answers in code:

const djJev = jev({ // TypeSafe's request: state and questions state: { title: 'LIGHTNING IN A BOTTLE', key: 'E minor' }, questions: { section: choice('Pick the next section', { intro: '…', riff: '…', …, outro: '…' }), move: choice('Pick which parts play it', { asWritten: '…', noDrums: '…' }), energy: score('How intense should it be?', ['held back', 'steady', 'driving', 'all out']), }, }) const { section, move, energy } = djJev.every(8, { fallback: { energy: 3 } }) // Strudel: when to ask … const form = walk(section, { max: 16, sections: { intro: { at: 0, next: ['riff'] }, … } }) form.arrange(move.choice.pick({ asWritten: song, noDrums: … })).velocity(energy.score.div(3).range(.55, 1))

jev({ state, questions }) is exactly the SDK's request; nothing about time is Jev's. djJev.every(cycles, { fallback }) is Strudel's side: it asks every cycles cycles and returns the answers, mirroring the SDK's response.answers, with a pattern (one value per cycles) for each field: .choice, .confidence and .probabilities for a choice; .score, .confidence and .probabilities for a score (on the rubric's own scale, 0 to levels-1); .noul for a noul.

fallback (to .every) is what plays when Jev has not answered, or answered with something unusable: a choice falls back to its first option unless named; a score and a noul must name theirs, since nothing about a ladder or a yes/no says which end is "as written".

walk(answer, { max, start, sections }) is not Jev: it is the song's form as rules in code, as TypeSafe's docs ask ("keep control flow and deterministic rules in code"). Each section is one of a choice's options, with at (the cycle it starts at as written) and next (the sections allowed to follow it; empty ends the song), and optionally times (the most times it plays in one performance). It narrows that choice, per request, to the options allowed next (or skips it when only one is), makes the written order its fallback, forces an ending at max sections (16 by default), ends the song after an ending section, and form.arrange(pat) plays each section's own cycles of pat.

times counts every play of the section, not a run of it: a loop is usually a cycle of different sections (groove a → b → c → a), where no one section ever repeats back to back, so only a total bounds it: a cycle through a section with times: 2 goes round at most twice. A section that has played its times is no longer offered, nor is any section whose only ways to an ending now run through one; there is always a way on (see stepsToEnd), and the written order, which plays each section once, is never capped.

A jev() asked after a form (after: { section }) arranges each section the form picks; the form's own history rows carry what it settled for each past section as arrangement (its questions, less the ones it forgets and parts absent there), so the form's Jev can hear what the arrangement built and released before it picks the next section.

Jev only ever selects among what the song wrote down, and anything unusable plays the fallback and says why in the console. All of the text goes in 'single quotes': "double quotes" and backticks are mini-notation.

One request per section, as TypeSafe advises ("ask independent questions together, then compose their answers in code"): every question for section n+1 goes to Jev at once, as soon as the song first queries section n+1's answers: halfway through section n at the latest, and earlier when a song reads ahead (a transition's .early(1) asks a bar into section n, which gives a chained after: request its time but leaves out 🔥/😴 from the rest of section n), with the song's state, the history of answers, the reactions, and, with a form, where the song is in it. A section that begins before its answer lands plays its fallbacks to its end. "Begins" is playing time: the earliest cycle the song queried in one scheduler tick, so a read ahead (.early(1)) does not count as the section beginning. A question read ahead while its answer is out has already played its fallback, though: it keeps it for that section (and says so), so a transition never switches halfway through its bar.

allow: { name: (decided) => [options] } (to .every) narrows a choice per request, the way a form narrows its sections: decided holds the after: answers ({ section: 'drop1' }) and, when one of them walks a form, playingNow, the section before it. Rules like "no riser out of a build" are then code, not a sentence Jev may ignore. The fallback must always be allowed; a single allowed option is played without asking.

Listeners: listeners() (to createJev) is every listener's 🔥/😴 tally for the song playing, { [section]: { fire, sleep } } (reactions.mjs), or null. Each request carries it as listenersSoFar, cut to the sections that matter to the answer: the last few played and those that may come next (or the one being entered, for a jev() asked after a form's), at most LISTENERS_MAX, and only names the song's form has (the tally is anyone's writing). A song without a form keys sections by number.

Replay: setReplay(performance) makes every jev() declared after it play a recorded performance (performance.mjs) instead of asking Jev: each segment takes the recorded answers of its jev() (by declaration order), with no request at all. The rules still apply: a recorded answer the song's form or allow no longer permits there, or that is no longer an option, plays the fallback and says so, and a segment the recording does not have plays the fallbacks. So a song whose code changed since plays the performance where it still can. setReplay(null) goes back to Jev.

Decisions can also come from outside (followDecisions): a listening party's guests (party.mjs) play the decisions the host's page settled, sent to them as they are made (watchDecisions), and never ask Jev. Each decision is checked against the song before it plays, like an answer; one that arrives after its section began is a late answer.

106import { Fraction, Hap, Pattern, TimeSpan, logger } from '@strudel/core';
107import { boothStore } from './boothStore.mjs';
109export const JEV_MODEL = 'jev-1.13.0';
110
111const isText = (x) => typeof x === 'string';
112const isObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
113
114function text(where, value) {
115  // The transpiler turns double-quoted and backtick strings into mini-notation.
116  if (value instanceof Pattern) {
117    throw new Error(`jev: write ${where} in 'single quotes'; "double quotes" and backticks are mini-notation`);
118  }
119  if (!isText(value)) throw new Error(`jev: ${where} must be text`);
120  return value;
121}

The section that follows name in the song as written, if any.

124function writtenNext(sections, name, every) {
125  const at = sections[name].at + every;
126  return Object.keys(sections).find((n) => sections[n].at === at);
127}

How many sections must still play after each one to reach an ending, through only the sections that may still play (open); Infinity where none can. A shortest way is a simple path, so it plays each section on it at most once: a section open now (one play left, at least) can be on it. That is why a request never runs out of sections: the section chosen at segment n could reach an ending by the cap through sections open then; playing it closes at most itself, which its own shortest way never revisits, so the next section on that way is still open, one step nearer, and offered at n + 1.

138function stepsToEnd(sections, open = () => true) {
139  const all = Object.keys(sections);
140  const d = Object.fromEntries(all.map((n) => [n, open(n) && !sections[n].next.length ? 0 : Infinity]));
141  for (let changed = true; changed; ) {
142    changed = false;
143    for (const n of all) {
144      if (!open(n)) continue;
145      const x = 1 + Math.min(Infinity, ...sections[n].next.map((m) => d[m]));
146      if (x < d[n]) [d[n], changed] = [x, true];
147    }
148  }
149  return d;
150}

The SDK's question builders, same signatures, same objects on the wire. Their jsdoc (and jev's, below) is the reference tab's and the MCPs' api_reference's (package.json's jsdoc-json reads this file); its examples are evaluated and played in reference.test.mjs.

A question for jev(): Jev picks one of the song's options. Each option is a name the code uses and a sentence Jev reads; Jev can only ever answer one of the names, so it cannot make anything up. TypeSafe's Choice, with the SDK's signature.

Its answer, from .every(...), has three patterns with one value per section: .choice, the option's name (compose it with Strudel's pick); .confidence, 0 to 1, how peaked Jev's probabilities are; and .probabilities, option name → probability. Without a usable answer it plays its fallback, the first option unless .every's fallback names another.

All of the text goes in 'single quotes': "double quotes" and backticks are mini-notation, and jev() refuses them. @name choice @tags jev @param {string} instructions the question, in 'single quotes' @param {Object} criteria at least two options, { name: 'what it is, for Jev' } @returns {Object} the question, for jev({ questions }) @example setcps(.5) const djJev = jev({ state: { title: 'TWO MOODS', what: 'a sawtooth chord over a kick' }, questions: { mood: choice('Pick the chord for the next four bars', { dark: 'A minor, low and filtered', bright: 'C major, open', }), }, }) const dj = djJev.every(4, { segments: 8 }) stack( note(dj.mood.choice.pick({ dark: "a2,c3,e3", bright: "c3,e3,g3" })).s('sawtooth').lpf(1200), s("bd*4"), )

185export function choice(instructions, criteria) {
186  return { type: 'choice', instructions, criteria };
187}

A question for jev(): Jev rates the section on a ladder of 2 to 10 levels the song writes down, low to high. TypeSafe's Score, with the SDK's signature.

Its answer, from .every(...), has three patterns with one value per section: .score, from 0 to levels−1 and fractional (Jev's weighted mean over the levels, so .round() it for a whole step); .confidence, 0 to 1; and .probabilities, keyed by level index ('0', '1', …). A score has no natural "as written" end, so .every must name its fallback: fallback: { name: level }. @name score @tags jev @param {string} instructions the question, in 'single quotes' @param {Array} criteria 2 to 10 levels, low to high, each a 'label: what it means' @returns {Object} the question, for jev({ questions }) @example setcps(.5) const djJev = jev({ state: { title: 'RAMP', what: 'a kick and a saw bass' }, questions: { energy: score('How intense should the next four bars be?', [ 'held back: half velocity', 'steady: most of the way', 'all out: full velocity', ]), }, }) const dj = djJev.every(4, { fallback: { energy: 2 }, segments: 8 }) stack(s("bd*4"), note("<a1 c2 f1 g1>").s('sawtooth').lpf(600)) .velocity(dj.energy.score.div(2).range(.5, 1))

214export function score(instructions, criteria) {
215  return { type: 'score', instructions, criteria };
216}

A question for jev(): how likely a yes/no is, as a calibrated probability. TypeSafe's Noul, with the SDK's signature; criteria says what true and false mean, { true: '…', false: '…' }, either optional.

Its answer, from .every(...), is one pattern with one value per section: .noul, the probability of true, 0 to 1. A noul has no .confidence: the probability is the certainty. .every must name its fallback: fallback: { name: 0 } (or 1, or anything between). @name noul @tags jev @param {string} instructions the question, in 'single quotes' @param {Object} criteria optional, { true: '…', false: '…' } @returns {Object} the question, for jev({ questions }) @example setcps(.5) const djJev = jev({ state: { title: 'BREAKDOWN', what: 'drums under a pad; the drums can drop out for a section' }, questions: { breakdown: noul('Should the next four bars drop the drums for a breakdown?', { true: 'the drums stop, the pad alone', false: 'the drums keep going', }), }, }) const dj = djJev.every(4, { fallback: { breakdown: 0 }, segments: 8 }) stack( note("<c3,e3,g3 a2,c3,e3>").s('sawtooth').lpf(900), s("bd*4, ~ sd").mask(dj.breakdown.noul.lt(.5)), )

244export function noul(instructions = null, criteria = null) {
245  return { type: 'noul', instructions, ...(criteria ? { criteria } : {}) };
246}

A fallback answer, shaped like Jev's.

249const fallbackAnswer = (q, value) =>
250  q.type === 'choice'
251    ? { type: 'choice', choice: value, confidence: 0, probabilities: {}, fallback: true }
252    : q.type === 'score'
253      ? { type: 'score', score: value, confidence: 0, probabilities: {}, fallback: true }
254      : { type: 'noul', noul: value, fallback: true };

What a question's answer says, in words, for the history Jev is sent. brief: a score's level by its name alone, the words before its colon ('full: as written' is 'full'), for another jev()'s history, which does not carry this one's questions and would otherwise repeat every label.

260function describe(q, a, { brief = false } = {}) {
261  if (q.type === 'choice') return a.choice;
262  if (q.type === 'score') {
263    const label = q.criteria[Math.round(a.score)];
264    return `${Math.round(a.score * 10) / 10} (${brief && isText(label) ? label.split(':')[0] : label})`;
265  }
266  return Math.round(a.noul * 100) / 100;
267}

How many sections listenersSoFar names at most, and how many of them are the ones just played.

271const LISTENERS_MAX = 8;
272const LISTENERS_BACK = 4;

The performance jev()s declared from now on replay (see the header).

275let replaying = null;
276export function setReplay(performance) {
277  replaying = performance ? { jevs: performance.jevs ?? [], changed: Boolean(performance.changed) } : null;
278}
280const cycleText = (c) => (Number.isFinite(c) ? (Math.round(c * 10) / 10).toFixed(1) : 'none yet');

Ask every jev() of the song about to start (the evaluation's, from the booth) about its opening section, and wait until they answer or ms passes: the scheduler awaits it before its first tick (useReplContext's beforeStart), so the opening is Jev's call rather than always the fallbacks. An answer later than ms plays from the next cycle.

287export async function askOpening(ms) {
288  const asked = boothStore.starting().map((view) => view.askOpening?.());
289  if (!asked.length) return;
290  let timer;
291  await Promise.race([Promise.all(asked), new Promise((r) => (timer = setTimeout(r, ms)))]);
292  clearTimeout(timer);
293}

The parts of the song about to start, as written: which orbit sounds in which cycles (preload.mjs reads it off the song queried asWritten). A question named for a part's orbit (part('drums', …) plays on .orbit('drums')) is not asked about a section where that part has no notes as written; songs are written so, so no song names them by hand. They are attached to the song's own jev()s (the evaluation's, from the booth), so another song evaluated later never inherits them.

302export function setWrittenParts(haps) {
303  const at = new Map();
304  const orbits = new Set();
305  for (const hap of haps) {
306    const orbit = hap.value?.orbit;
307    if (typeof orbit !== 'string' || /^\d+$/.test(orbit) || !hap.whole) continue;
308    orbits.add(orbit);
309    const from = Math.floor(hap.whole.begin.valueOf());
310    const to = Math.ceil(hap.whole.end.valueOf());
311    for (let c = from; c < Math.max(to, from + 1); c++) {
312      if (!at.has(c)) at.set(c, new Set());
313      at.get(c).add(orbit);
314    }
315  }
316  const parts = {
317    orbits,
318    playsIn: (orbit, [from, to]) => {
319      for (let c = from; c < to; c++) if (at.get(c)?.has(orbit)) return true;
320      return false;
321    },
322  };
323  for (const view of boothStore.starting()) view.writtenParts = parts;
324}

── decisions from outside ────────────────────── A performance's decisions, shared: a listening party's host sends each one its jev()s settle (watchDecisions), and its guests' jev()s play them instead of asking (followDecisions). The same two ends fit a performance recorded and replayed later: keep what watchDecisions hands out, and feed it back through the receivers followDecisions returns.

The views are the song about to start (or playing), in the order its jev()s are declared, which is the same for the same code: a view's index is its name on the wire.

What of a decision leaves the page: the values and how they came to be, not what Jev was told (state: the history and measurements, large and the sender's own). last is the segment the song ends after, once known.

340export function shareDecision(decision, last) {
341  const { cycles, status, values, from, planned, problems, reason, ms } = decision;
342  return {
343    cycles,
344    status,
345    values,
346    ...(from !== undefined ? { from, planned } : {}),
347    ...(problems?.length ? { problems } : {}),
348    ...(reason !== undefined ? { reason } : {}),
349    ...(ms !== undefined ? { ms } : {}),
350    last,
351  };
352}

Calls fn(view, segment, decision) (decision as shareDecision makes it) for every decision the starting song's jev()s settle from now on. Returns the ones already settled, in the same shape, and how to stop.

357export function watchDecisions(fn) {
358  const settled = [];
359  const watching = boothStore.starting().map((view, i) => {
360    for (const d of view.decisions) {
361      if (d && d.status !== 'asking' && d.status !== 'opening') {
362        settled.push({ view: i, segment: d.segment, decision: shareDecision(d, view.last) });
363      }
364    }
365    const watcher = (segment, d) => fn(i, segment, shareDecision(d, view.last));
366    view.watchers.add(watcher);
367    return [view, watcher];
368  });
369  return { settled, stop: () => watching.forEach(([view, w]) => view.watchers.delete(w)) };
370}

Puts the starting song's jev()s in follow mode: they ask Jev nothing and play what they receive. Returns a receiver per view, in declaration order: receive(segment, decision) → taken. Feed it what is already known before the song starts; askOpening then waits for the opening's.

376export function followDecisions() {
377  return boothStore.starting().map((view) => view.follow());
378}

The listeners' reactions to a section, as totals counted elsewhere (a party's room): every jev() of the playing song hears them.

382export function setReactions(segment, { fire = 0, sleep = 0 }) {
383  for (const view of boothStore.starting()) {
384    view.reactions[segment] = { fire, sleep };
385    boothStore.changed(view);
386  }
387}

asWritten(fn): queries made inside fn see every jev() as the song was written (each section its written successor, playing its fallbacks) and change nothing: no request goes out, the playhead does not move, nothing is locked. For looking ahead at a whole song, e.g. to load its samples before it plays (preload.mjs); a normal query of a section that has not begun asks Jev about it.

395let writtenDepth = 0;
396export function asWritten(fn) {
397  writtenDepth++;
398  try {
399    return fn();
400  } finally {
401    writtenDepth--;
402  }
403}

While walk()'s arrange() queries a section's own cycles, the time it shifted them by: an answer composed inside an arrangement (a .pick inside form.arrange(…)) is queried at the section's written position, and adds this back to find the section actually playing. Queries are synchronous, so a stack is exact.

410const offsets = [];
411const offsetNow = () => offsets.reduce((sum, x) => sum + x, 0);
412const shifted = (pat, shift) =>
413  new Pattern((st) => {
414    offsets.push(shift);
415    try {
416      return pat.late(shift).query(st);
417    } finally {
418      offsets.pop();
419    }
420  });

Each jev() keeps its internals here, keyed by its answers, so walk() can attach a form to the question an answer came from.

424const owners = new WeakMap();

Sampling a choice at a temperature: probabilities raised to 1/t and renormalised (t = 1 samples Jev's own distribution; t → 0 is its top pick). random is Math.random in the page, a stub in tests.

429function sampleChoice(answer, t, random) {
430  const entries = Object.entries(answer.probabilities ?? {}).filter(([, p]) => p > 0);
431  if (!entries.length || !(t > 0)) return answer;
432  const weights = entries.map(([k, p]) => [k, p ** (1 / t)]);
433  const total = weights.reduce((sum, [, w]) => sum + w, 0);
434  let r = random() * total;
435  for (const [k, w] of weights) {
436    r -= w;
437    if (r <= 0) return k === answer.choice ? answer : { ...answer, choice: k, top: answer.choice, sampled: t };
438  }
439  return answer;
440}

Jev arranges the song live. Jev is TypeSafe AI's System One model: it never writes text or music. It makes typed decisions over options the song wrote down, in about a tenth of a second, with calibrated probabilities. It can pick one option (choice), rate on a ladder (score), or say how likely a yes is (noul). The song composes those answers in code, so Jev only ever selects among what the song wrote.

jev({ state, questions }) is TypeSafe's request exactly, as the SDK (@typesafe-ai/sdk) sends it, and takes nothing else. state is what Jev judges: any object, usually the song's title, key, lyrics and what each section is. questions names each question, built with choice, score or noul. Every question goes to Jev in one request, and no question can see another's answer. To ask one question after another is settled, declare a second jev() and give its .every an after.

.every(cycles, options) is Strudel's side, called once on what jev() returns. It asks Jev again every cycles cycles; each ask is a section. It returns the answers keyed by question name, each field a pattern with one value per section:

  • a choice: .choice, .confidence, .probabilities
  • a score: .score, .confidence, .probabilities
  • a noul: .noul

The options to .every:

  • fallback: { name: value }: what plays before Jev answers, or instead of an unusable answer. A choice falls back to its first option unless named here; a score (0 to levels−1) and a noul (0 to 1) must be named.
  • after: { name: answer }: an answer from another jev() on the same cadence. This jev() is asked once that answer is settled, and sees it in its state as decidedThisSection.
  • allow: { name: (decided) => [options] }: narrows a choice per section from what after decided, plus playingNow (the section before) when that answer walks a form. A rule like "no riser out of a build" is then code, not a sentence Jev may ignore. The fallback must always be allowed.
  • sample: a temperature (or { name: t }) to draw each choice from Jev's probabilities instead of always its top pick.
  • minConfidence: a threshold (or { name: c }) under which a choice or score plays its fallback. Not together with sample on the same question.
  • forget: [names]: questions left out of the history Jev is sent.
  • segments: how many sections the song lasts, when no walk form ends it.

Each request carries the song's state, the history of past answers, listeners' 🔥/😴 reactions (listenersSoFar), and, with a form, where the song is in it. The next section's questions go out halfway through the current one at the latest. A section that begins before its answer lands plays its fallbacks. An answer that is not one of the song's options plays the fallback, and the console says why.

TypeSafe's advice holds here: "ask independent questions together, then compose their answers in code", and "keep control flow and deterministic rules in code" (see walk).

All of Jev's text goes in 'single quotes'. The REPL turns "double quotes" and backticks into mini-notation, and jev() refuses them. @name jev @tags jev @param {Object} request { state, questions }, TypeSafe's request @returns {Object} { every(cycles, options) }, which returns the answers @example setcps(.5) const djJev = jev({ state: { title: 'NIGHT BUS', what: 'a chord pad over drums', key: 'A minor' }, questions: { chord: choice('Pick the chord for the next four bars', { home: 'A minor, where the song rests', lift: 'F major, a lift', tense: 'E major, pulling back home', }), energy: score('How hard should the drums hit?', ['soft: a kick a bar', 'steady: four on the floor']), hats: noul('Should the next four bars add eighth-note hats?'), }, }) const dj = djJev.every(4, { fallback: { energy: 1, hats: 0 }, segments: 8 }) stack( note(dj.chord.choice.pick({ home: "a2,c3,e3", lift: "f2,a2,c3", tense: "e2,g#2,b2" })).s('sawtooth').lpf(1000), s(dj.energy.score.round().pick(["bd", "bd4"])), s("hh8").mask(dj.hats.noul.gt(.5)), )

decide(state, questions, { begins }) resolves to Jev's answers, keyed like questions: the relay in the page (jev.mjs), a stub in tests. begins is the cycle the section asked about begins at, so the page can tell the relay how far ahead each call went out. hear(from, to) summarises what the page actually played between two cycles (meter.mjs), or null; random samples.

497export function createJev(decide, { hear = () => null, random = Math.random, listeners = () => null } = {}) {
498  return function jev({ state = {}, questions = {}, ...rest } = {}) {
499    if (Object.keys(rest).length) {
500      throw new Error(
501        `jev: it takes TypeSafe's state and questions only, not ${Object.keys(rest).join(', ')}; when to ask is .every(cycles, { fallback }), a form is walk(...)`,
502      );
503    }
504    if (state instanceof Pattern) text('`state`', state);
505    const checked = new Map(); // name → question, validated
506    for (const [name, q] of Object.entries(questions)) {
507      if (!/^[A-Za-z_]\w*$/.test(name)) throw new Error(`jev: "${name}" is not a usable question name`);
508      if (!q || !['choice', 'score', 'noul'].includes(q.type)) {
509        throw new Error(`jev: question "${name}" must be built with choice(), score() or noul()`);
510      }
511      if (q.instructions !== null) text(`${name}'s instructions`, q.instructions);
512      if (q.type === 'choice') {
513        const names = Object.keys(q.criteria ?? {});
514        if (names.length < 2) throw new Error(`jev: choice "${name}" needs at least two options`);
515        for (const o of names) if (q.criteria[o] !== null) text(`option "${o}" of ${name}`, q.criteria[o]);
516      } else if (q.type === 'score') {
517        const levels = q.criteria;
518        if (!Array.isArray(levels) || levels.length < 2 || levels.length > 10) {
519          throw new Error(`jev: score "${name}" needs 2 to 10 levels, low to high`);
520        }
521        levels.forEach((l, i) => l !== null && text(`${name}'s level ${i}`, l));
522      } else if (q.criteria) {
523        for (const k of ['true', 'false']) q.criteria[k] != null && text(`${name}'s ${k}`, q.criteria[k]);
524      }
525      checked.set(name, q);
526    }
527    if (!checked.size) throw new Error('jev: give it `questions`');
528    let asked = false;

Strudel's side: ask every every cycles, with these fallbacks.

  • fallback: what plays without a usable answer (see the header).
  • after: { name: answer } from another jev() on the same cadence: this one is asked only once those are decided, with them in its state, since questions in one request cannot see each other.
  • sample: a temperature (or { name: t }) to sample each choice from Jev's probabilities instead of always taking its top pick.
  • minConfidence: a threshold (or { name: c }) under which a choice or score plays its fallback instead. Not with sample on the same question: sampling acts on the probabilities, and a close call is exactly where it should differ from the top pick. TypeSafe: "If you have a specific statistical algorithm in mind, you should probably be using probabilities instead of confidence" (docs.typesafe.ai).
543    const everyFn = (
544      every,
545      {
546        fallback = {},
547        segments = Infinity,
548        after = {},
549        sample = null,
550        minConfidence = null,
551        allow = {},
552        forget = [],
553        ...more
554      } = {},
555    ) => {
556      if (Object.keys(more).length) {
557        throw new Error(
558          `jev: .every() takes fallback, after, sample, minConfidence, allow, forget and segments, not ${Object.keys(more).join(', ')}`,
559        );
560      }
561      for (const name of forget) {
562        if (!checked.has(name)) throw new Error(`jev: forget: "${name}" is not one of this jev()'s questions`);
563      }
564      if (asked) throw new Error('jev: one .every() per jev(); declare another jev() for another cadence');
565      asked = true;
566      if (!(every > 0)) throw new Error('jev: .every(cycles) needs a positive number of cycles');
567      if (!(segments > 0)) throw new Error('jev: `segments` must be a positive number (or left out)');
568
569      const qs = new Map();
570      for (const [name, q] of checked) {
571        let fb = fallback[name];
572        if (q.type === 'choice') {
573          const names = Object.keys(q.criteria);
574          fb ??= names[0];
575          if (!names.includes(fb)) throw new Error(`jev: ${name}'s fallback "${fb}" is not an option`);
576        } else if (q.type === 'score') {
577          if (!(typeof fb === 'number' && fb >= 0 && fb <= q.criteria.length - 1)) {
578            throw new Error(
579              `jev: score "${name}" needs a fallback from 0 to ${q.criteria.length - 1}: .every(n, { fallback: { ${name}: … } })`,
580            );
581          }
582        } else if (!(typeof fb === 'number' && fb >= 0 && fb <= 1)) {
583          throw new Error(`jev: noul "${name}" needs a fallback from 0 to 1: .every(n, { fallback: { ${name}: … } })`);
584        }
585        qs.set(name, { ...q, fallback: fb });
586      }
587      for (const name of Object.keys(fallback)) {
588        if (!qs.has(name)) throw new Error(`jev: fallback for "${name}", which is not a question`);
589      }
590      const perName = (option, name, what) => {
591        if (option === null || typeof option === 'number') return option;
592        for (const k of Object.keys(option))
593          if (!qs.has(k)) throw new Error(`jev: ${what} for "${k}", which is not a question`);
594        return option[name] ?? null;
595      };
596      const temperature = (name) => perName(sample, name, 'sample');
597      const threshold = (name) => perName(minConfidence, name, 'minConfidence');
598      for (const name of qs.keys()) {
599        if (temperature(name) && threshold(name) !== null) {
600          throw new Error(
601            `jev: "${name}" is sampled, so it cannot also have a minConfidence: sampling uses the probabilities`,
602          );
603        }
604      }
605      for (const [name, rule] of Object.entries(allow)) {
606        if (qs.get(name)?.type !== 'choice') throw new Error(`jev: allow.${name} must name a choice question`);
607        if (typeof rule !== 'function') throw new Error(`jev: allow.${name} must be a function of what is decided`);
608      }
609      const upstream = Object.entries(after).map(([name, answer]) => {
610        const owner = owners.get(answer);
611        if (!owner) throw new Error(`jev: after.${name} must be an answer from another jev().every(…)`);
612        if (owner.every !== every)
613          throw new Error(`jev: after.${name} is asked every ${owner.every} cycles, not ${every}`);
614        if (qs.has(name)) throw new Error(`jev: after.${name} has the name of one of this jev()'s questions`);
615        return { name, owner };
616      });

Set by walk(): { name, sections: { [option]: { at, next, times } }, max, start, endings }

619      let form = null;
620      const limit = () => (form ? form.max : segments);
621      const cyclesOf = (s) => (every === 1 ? `${s + 1}` : `${s * every + 1}-${(s + 1) * every}`);

── what the page shows (boothStore.mjs) ────────

624      const view = {
625        model: JEV_MODEL,
626        about: state,
627        every,
628        get segments() {
629          return limit();
630        },
631        questions: [...qs].map(([name, q]) => ({
632          name,
633          type: q.type,
634          instructions: q.instructions,
635          criteria: q.criteria,
636        })),
637        form: null, // { name } once walk() attaches one
638        // the segment after which the song ends, once known: `segments` for a
639        // fixed length; with a form, the ending Jev picks
640        last: Number.isFinite(segments) ? segments - 1 : null,
641        decisions: [],
642        reactions: [],
643      };
644      view.index = boothStore.adopt(view);
645      // a recorded performance, when one is being replayed (setReplay)
646      const replayed = replaying ? { segments: replaying.jevs[view.index]?.segments ?? [] } : null;
647      view.replay = replaying ? { changed: replaying.changed } : null;
648      const changed = () => boothStore.changed(view);
649      // Who hears each settled decision (watchDecisions: a party's host).
650      view.watchers = new Set();
651      // Follow mode (followDecisions): decisions come from outside, through
652      // receive() below, and nothing is asked.
653      let following = false;
654      view.follow = () => {
655        following = true;
656        return receive;
657      };
658      view.react = (segment, kind) => {
659        const r = (view.reactions[segment] ??= { fire: 0, sleep: 0 });
660        r[kind] += 1;
661        changed();
662      };
664      const fallbacks = (sectionName) => {
665        const values = Object.fromEntries([...qs].map(([name, q]) => [name, fallbackAnswer(q, q.fallback)]));
666        if (form) values[form.name] = fallbackAnswer({ type: 'choice' }, sectionName);
667        return values;
668      };
669      view.decisions[0] = { segment: 0, cycles: cyclesOf(0), status: 'opening', values: fallbacks() };

who is waiting for a segment to be decided (after:, in another jev)

672      const waiters = new Map();
673      const record = (segment, fields) => {
674        view.decisions[segment] = { ...view.decisions[segment], segment, ...fields };
675        changed();
676        if (fields.status !== 'asking') {
677          for (const watcher of view.watchers) watcher(segment, view.decisions[segment]);
678          for (const resolve of waiters.get(segment) ?? []) resolve(view.decisions[segment]);
679          waiters.delete(segment);
680        }
681      };
682      // Resolves to segment's decision once made, or null when the song
683      // will not reach it.
684      const decided = (segment) => {
685        request(segment);
686        const d = view.decisions[segment];
687        if (d && d.status !== 'asking') return Promise.resolve(d);
688        if (!d) return Promise.resolve(null);
689        return new Promise((resolve) => waiters.set(segment, [...(waiters.get(segment) ?? []), resolve]));
690      };
691      // Ask about the opening now, before the song starts (askOpening).
692      view.askOpening = () => decided(0);
693      // A row that played a fallback says so, so it does not read as Jev's
694      // choice; the opening and a forced section are the song's, not fallbacks.
695      const said = (d, k, how) => {
696        const a = d.values[k];
697        const text = describe(qs.get(k) ?? { type: 'choice' }, a, how);
698        return a.fallback && !a.forced && d.status !== 'opening' ? `${text} (fallback)` : text;
699      };
700      // What this jev() settled for a segment, as its history rows say it:
701      // forget: questions whose past answers Jev is not shown (it would copy
702      // them); a part absent from its section was never asked.
703      const remembered = (d, how) =>
704        Object.fromEntries(
705          Object.keys(d.values)
706            .filter((k) => !forget.includes(k) && !d.values[k]?.absent)
707            .map((k) => [k, said(d, k, how)]),
708        );
709      // The jev()s asked after this one (`after:`), each keyed by its view:
710      // what they settled for each section, for this one's history.
711      const arrangers = new Map();
712      const arrangementAt = (segment) => {
713        const rows = [...arrangers.values()].map(({ at }) => at(segment)).filter(Boolean);
714        return rows.length ? Object.assign({}, ...rows) : null;
715      };
716      const history = (upTo) =>
717        view.decisions.slice(0, upTo).map((d) => {
718          const arrangement = arrangementAt(d.segment);
719          return {
720            cycles: d.cycles,
721            ...(upstream.length
722              ? { decidedBefore: Object.fromEntries(upstream.map(({ name, owner }) => [name, owner.valueAt(d.segment)])) }
723              : {}),
724            ...remembered(d),
725            ...(arrangement ? { arrangement } : {}),
726          };
727        });
728      // Tell each jev() this one is asked after what it settles, once
729      // settled (a decision still out has not played yet, or plays its
730      // fallbacks until it lands), so its history can say how each section
731      // was arranged.
732      for (const { owner } of upstream) {
733        owner.arrangedBy(view, [...qs.keys()].filter((k) => !forget.includes(k)), (segment) => {
734          const d = view.decisions[segment];
735          if (!d || d.status === 'asking') return null;
736          const row = remembered(d, { brief: true });
737          return Object.keys(row).length ? row : null;
738        });
739      }
740      const audience = (upTo) =>
741        view.reactions
742          .map((r, s) => (r && s < upTo ? { cycles: cyclesOf(s), fire: r.fire, sleep: r.sleep } : null))
743          .filter(Boolean)
744          .slice(-4);

The form's section in a segment: this jev's own form, or the one of a jev it is asked after; without a form, the segment's number.

748      const formOwner = () => (form ? null : upstream.map(({ owner }) => owner).find((o) => o.sectionNames()));
749      const sectionAt = (s) => {
750        if (form) return view.decisions[s]?.values[form.name]?.choice ?? null;
751        const owner = formOwner();
752        return owner ? owner.sectionAt(s) : String(s + 1);
753      };
754      // Every listener's tally for the sections that matter to segment's
755      // answer (the header's Listeners).
756      const listenersFor = (segment, next) => {
757        const tally = listeners();
758        if (!tally || typeof tally !== 'object') return null;
759        const names = form ? Object.keys(form.sections) : formOwner()?.sectionNames();
760        const known = (n) => typeof n === 'string' && (names ? names.includes(n) : /^\d+$/.test(n));
761        const recent = [];
762        for (let s = Math.max(0, segment - LISTENERS_BACK); s < segment; s++) recent.push(sectionAt(s));
763        const wanted = [...new Set([...recent, ...next])].filter((n) => known(n) && Object.hasOwn(tally, n));
764        const count = (x) => (Number.isInteger(x) && x > 0 ? x : 0);
765        const picked = wanted.slice(-LISTENERS_MAX).map((n) => [n, { fire: count(tally[n]?.fire), sleep: count(tally[n]?.sleep) }]);
766        return picked.length ? Object.fromEntries(picked) : null;
767      };

── asking ──────────────────────────────────────

770      const requested = new Set();
771      // With a form: the sections its rules allowed at each segment planned,
772      // which a decision from outside (receive) must also keep to.
773      const offered = new Map();
774      // Answers already heard before their section began: `${segment}:${name}`
775      // for a question a read ahead (`.early(1)`) played while its answer was
776      // still out. Only a query of a section that has not begun is one.
777      const readAhead = new Set();
778      // Playing time: the earliest cycle queried in the latest scheduler
779      // tick (the queries of one tick are synchronous, so a microtask ends
780      // it). A read ahead (`.early`) queries later cycles in the same tick,
781      // so it never moves the playhead into a section that has not begun.
782      let playhead = -Infinity;
783      let tickEarliest = null;
784      // Questions this tick read for a segment whose answer is still out;
785      // once the tick ends and its playing time is known, the ones for a
786      // segment that has not begun were read ahead.
787      let tickReads = [];
788      const heardAt = (cycle) => {
789        if (tickEarliest === null) {
790          tickEarliest = cycle;
791          queueMicrotask(() => {
792            for (const { segment, key } of tickReads) if (segment * every > tickEarliest) readAhead.add(key);
793            tickReads = [];
794            playhead = Math.max(playhead, tickEarliest);
795            tickEarliest = null;
796          });
797        } else tickEarliest = Math.min(tickEarliest, cycle);
798      };
799      const begun = (segment) => playhead >= segment * every;
800      const request = (segment) => {
801        if (requested.has(segment) || segment >= limit()) return;
802        if (view.last !== null && segment > view.last) return;
803        // The opening (segment 0) is asked before the song starts (askOpening);
804        // every later segment follows the one before it.
805        const opening = segment === 0;
806        // A segment first queried past its middle (a lagging or throttled tab)
807        // was never asked about; settle it first, so the chain has no gaps.
808        if (!opening && !view.decisions[segment - 1]) request(segment - 1);
809        const previous = opening ? null : view.decisions[segment - 1];
810        if (!opening && !previous) return;
811        // The section before ends the song (or will, if its answer is still
812        // out and its fallback is an ending): nothing to ask yet. Not marked
813        // requested, so an answer that goes on is asked about when it lands.
814        if (!opening && form && !form.sections[previous.values[form.name]?.choice]?.next.length) return;
815        requested.add(segment);
816
817        const asking = Object.fromEntries(
818          [...qs].map(([name, q]) => {
819            const { fallback: _, ...wire } = q;
820            return [name, wire];
821          }),
822        );
823        let planned = fallbacks();
824        let whereInForm = {};
825        let mayComeNext = form ? [] : [String(segment + 1)];
826        if (form) {
827          // the form's rules, in code: which sections may come next (the
828          // opening is always the form's start)
829          const current = previous?.values[form.name].choice;
830          // Only what may still play (`times`, counted over the sections
831          // before this one) and can still reach an ending by the cap: a
832          // section here plays, then at least toEnd[x] more, all by segment
833          // max - 1. Never empty: see stepsToEnd.
834          const plays = {};
835          for (let s = 0; s < segment; s++) {
836            const played = view.decisions[s]?.values[form.name]?.choice;
837            plays[played] = (plays[played] ?? 0) + 1;
838          }
839          const toEnd = stepsToEnd(
840            form.sections,
841            (x) => (plays[x] ?? 0) < (form.sections[x].times ?? Infinity),
842          );
843          const allowed = opening
844            ? [form.start]
845            : form.sections[current].next.filter((x) => segment + toEnd[x] <= form.max - 1);
846          offered.set(segment, allowed);
847          const written = opening ? form.start : writtenNext(form.sections, current, every);
848          const shortest = [...allowed].sort((a, b) => toEnd[a] - toEnd[b])[0];
849          const forced = allowed.length === 1 ? allowed[0] : null;
850          mayComeNext = allowed;
851          planned = fallbacks(forced ?? (allowed.includes(written) ? written : shortest));
852          if (forced) planned[form.name] = { ...planned[form.name], forced: true };
853          const q = qs.get(form.name);
854          if (forced) delete asking[form.name];
855          else asking[form.name] = choice(q.instructions, Object.fromEntries(allowed.map((n) => [n, q.criteria[n]])));
856          whereInForm = {
857            form: {
858              ...(opening ? { songStarts: true } : { playingNow: current }),
859              ...(forced ? { comesNext: forced } : { mayComeNext: allowed }),
860              endsAfter: form.endings,
861              sectionsLeft: form.max - segment,
862            },
863          };
864        }
865
866        const heard = audience(segment);
867        const measured = [];
868        for (let s = Math.max(0, segment - 4); s < segment - 1; s++) {
869          const m = hear(s * every, (s + 1) * every);
870          if (m) measured.push({ cycles: cyclesOf(s), ...m });
871        }
872        const sent = {
873          song: state,
874          segment: segment + 1,
875          cycles: cyclesOf(segment),
876          ...whereInForm,
877          history: history(segment),
878          ...(heard.length ? { audience: heard } : {}),
879          ...(measured.length ? { measuredSound: measured } : {}),
880        };

A form's section, settled here or by receive(), is always one of form.sections, and one the form allowed there: every way in is checked. Jev's answer must be an own key of the narrowed choice (asking, the allowed sections), or the planned section plays; a sampled pick is kept only if it is one too; a replay keeps a recorded section only if asking offers it; a decision from outside (receive) only if it is the song's and offered there; and every fallback is the start or an allowed section. walk() makes sections and the choice's options the same set. So form.sections[x] is never undefined for a settled x, and the "reading 'next'" TypeError seen once (2026-09-25) cannot come from a live input: it was caught only in a hot-reloaded module version mid-refactor, when this line still read .next without ?. (fixed in 101498b3); follow.test.mjs and jevCore.test.mjs feed foreign section names through each way in.

897        const settle = (fields) => {
898          if (form && form.sections[fields.values[form.name]?.choice]?.next.length === 0) {
899            view.last = Math.min(view.last ?? Infinity, segment);
900          }
901          record(segment, fields);
902        };

The fallbacks are decided now, so a slow answer never leaves a gap.

905        record(segment, { cycles: sent.cycles, status: 'asking', state: replayed ? undefined : sent, values: planned });
906        // Following, the decision comes from outside (receive).
907        if (following) return;
908        if (!Object.keys(asking).length) {
909          // nothing to ask (a forced section and no other questions): no request
910          settle({ status: 'answered', values: planned, problems: [] });
911          return;
912        }
913        const t0 = Date.now();
914        const askedAt = playhead; // playing time when the request went out
915        Promise.all(upstream.map(({ owner }) => owner.decided(segment)))
916          .then((given) => {
917            if (given.some((d) => d === null))
918              throw Object.assign(new Error('the song ends before this section'), { ended: true });
919            const decidedNow = {};
920            if (upstream.length) {
921              sent.decidedThisSection = Object.fromEntries(
922                upstream.map(({ name, owner }, i) => {
923                  const a = given[i].values[owner.name];
924                  decidedNow[name] = owner.question.type === 'choice' ? a.choice : a[owner.question.type];
925                  const meaning = owner.question.criteria?.[a.choice];
926                  return [name, meaning ? `${a.choice}: ${meaning}` : describe(owner.question, a)];
927                }),
928              );
929              // where the song is, from an upstream form (this jev has none of its own)
930              if (!form) {
931                const where = upstream.map(({ owner }) => owner.where(segment)).find(Boolean);
932                if (where) {
933                  sent.form = where;
934                  decidedNow.playingNow = where.playingNow;
935                }
936              }
937            }
938            if (!replayed) {
939              // the section being entered, from the form this jev is asked after
940              const entering = form ? mayComeNext : [formOwner() ? sectionAt(segment) : String(segment + 1)];
941              const heardByAll = listenersFor(segment, entering.filter(Boolean));
942              if (heardByAll) sent.listenersSoFar = heardByAll;
943            }
944            // A part that does not play in this section as written is not
945            // asked about: it keeps its fallback, marked absent.
946            const span = writtenSpan(segment);
947            const parts = view.writtenParts;
948            if (parts && span) {
949              for (const name of Object.keys(asking)) {
950                if (parts.orbits.has(name) && !parts.playsIn(name, span)) {
951                  delete asking[name];
952                  planned = { ...planned, [name]: { ...planned[name], absent: true } };
953                }
954              }
955            }
956            // allow: narrow a choice to what the song's rules permit here
957            for (const [name, rule] of Object.entries(allow)) {
958              const q = qs.get(name);
959              const ok = [...new Set(rule(decidedNow) ?? [])].filter((o) => Object.hasOwn(q.criteria, o));
960              if (!ok.includes(q.fallback)) {
961                throw new Error(
962                  `allow.${name} leaves out its fallback "${q.fallback}"; a fallback must always be allowed`,
963                );
964              }
965              if (ok.length === 1) {
966                delete asking[name];
967                planned = { ...planned, [name]: { ...planned[name], forced: true } };
968              } else {
969                asking[name] = choice(q.instructions, Object.fromEntries(ok.map((o) => [o, q.criteria[o]])));
970              }
971            }
972            if (!Object.keys(asking).length || replayed) return {};
973            return decide(sent, asking, { begins: segment * every });
974          })
975          .then((answers = {}) => {
976            if (replayed) {
977              const fields = replay(segment, asking, planned);
978              settle(fields);
979              const said = Object.entries(fields.values).map(([k, a]) => `${k} ${describe(qs.get(k) ?? { type: 'choice' }, a)}`);
980              logger(`[jev] cycles ${sent.cycles} (replayed): ${said.join(', ')}`);
981              for (const p of [fields.reason, ...(fields.problems ?? [])].filter(Boolean)) {
982                logger(`[jev] cycles ${sent.cycles} (replayed): ${p}`, 'warning');
983              }
984              return;
985            }
986            const values = { ...planned };
987            const problems = [];
988            for (const [name, q] of Object.entries(asking)) {
989              const a = answers[name];
990              const unsure = (c) => {
991                const min = threshold(name);
992                if (min === null || !(typeof c === 'number') || c >= min) return false;
993                problems.push(
994                  `${name}: not confident enough (${Math.round(c * 100)}% < ${Math.round(min * 100)}%); played the fallback`,
995                );
996                return true;
997              };
998              if (q.type === 'choice') {
999                // own keys only: "toString" is `in` every object
1000                if (!Object.hasOwn(q.criteria, a?.choice)) {
1001                  problems.push(a ? `${name}: "${a.choice}" is not an option` : `${name}: no answer`);
1002                } else if (!unsure(a.confidence)) {
1003                  const t = temperature(name);
1004                  const picked = t ? sampleChoice(a, t, random) : a;
1005                  values[name] = Object.hasOwn(q.criteria, picked.choice) ? picked : a;
1006                }
1007              } else if (q.type === 'score') {
1008                if (typeof a?.score !== 'number') problems.push(`${name}: no score`);
1009                else if (!unsure(a.confidence)) {
1010                  values[name] = { ...a, score: Math.min(Math.max(a.score, 0), q.criteria.length - 1) };
1011                }
1012              } else if (typeof a?.noul === 'number') {
1013                values[name] = { ...a, noul: Math.min(Math.max(a.noul, 0), 1) };
1014              } else problems.push(`${name}: no answer`);
1015            }
1016            // A question read ahead keeps the fallback it already played; the
1017            // rest of the section still takes Jev's answers.
1018            for (const name of Object.keys(asking)) {
1019              if (readAhead.has(`${segment}:${name}`) && values[name] !== planned[name]) {
1020                values[name] = planned[name];
1021                problems.push(`${name}: answered after a read ahead played it; it kept the fallback`);
1022              }
1023            }
1024            let fields = { status: 'answered', values, problems, ms: Date.now() - t0 };
1025            // A segment already playing keeps its section, and takes the
1026            // rest of Jev's answers from the next whole cycle with at least
1027            // half a cycle to spare (the scheduler has queried a little
1028            // ahead); one that has no such cycle left plays its fallbacks.
1029            const from = Math.ceil(playhead + 0.5);
1030            const segmentEnd = (segment + 1) * every;
1031            if (begun(segment) && from < segmentEnd) {
1032              if (form) values[form.name] = planned[form.name];
1033              fields = {
1034                ...fields,
1035                values,
1036                planned,
1037                from,
1038                problems: [
1039                  ...problems,
1040                  `answered after the section began (asked at cycle ${cycleText(askedAt)}, answered at ${cycleText(playhead)}, ${Date.now() - t0} ms later); it plays from cycle ${from + 1}`,
1041                ],
1042              };
1043            } else if (begun(segment)) {
1044              fields = {
1045                ...fields,
1046                status: 'fallback',
1047                values: planned,
1048                problems: [
1049                  ...problems,
1050                  `answered after the section began (it begins at cycle ${segment * every}; asked at cycle ${cycleText(askedAt)}, answered at ${cycleText(playhead)}, ${Date.now() - t0} ms later); it played the fallbacks`,
1051                ],
1052              };
1053            }
1054            settle(fields);
1055            const said = Object.entries(fields.values).map(([k, a]) => `${k} ${describe(qs.get(k), a)}`);
1056            logger(`[jev] cycles ${sent.cycles}: ${said.join(', ')}`);
1057            for (const p of fields.problems) logger(`[jev] cycles ${sent.cycles}: ${p}`, 'warning');
1058          })
1059          .catch((e) => {
1060            if (e.ended) {
1061              // an upstream form ended the song first: so does this jev()
1062              view.last = Math.min(view.last ?? Infinity, segment - 1);
1063              record(segment, { status: 'fallback', reason: e.message, ended: true, values: planned });
1064              return;
1065            }
1066            settle({ status: 'fallback', reason: e.message, values: planned, ms: Date.now() - t0 });
1067            logger(`[jev] cycles ${sent.cycles}: ${e.message}; playing the fallbacks`, 'warning');
1068          });
1069      };

A segment as the recorded performance played it, under the song's rules now: asking holds what may be answered here (the form's allowed sections, allow's options), planned the fallbacks.

1074      const replay = (segment, asking, planned) => {
1075        const rec = replayed.segments[segment];
1076        if (!rec?.values) {
1077          return { status: 'fallback', values: planned, replayed: true, reason: 'not in the recorded performance' };
1078        }
1079        if (rec.status === 'fallback') {
1080          return { status: 'fallback', values: planned, replayed: true, reason: 'Jev could not answer when it was recorded' };
1081        }
1082        const values = { ...planned };
1083        const problems = [];
1084        for (const [name, q] of Object.entries(asking)) {
1085          const a = rec.values[name];
1086          const ok =
1087            q.type === 'choice'
1088              ? Object.hasOwn(q.criteria, a?.choice)
1089              : q.type === 'score'
1090                ? typeof a?.score === 'number' && a.score >= 0 && a.score <= q.criteria.length - 1
1091                : typeof a?.noul === 'number' && a.noul >= 0 && a.noul <= 1;
1092          if (ok) values[name] = { ...a, type: q.type };
1093          else problems.push(`${name}: ${a ? 'the recorded answer is not allowed here now' : 'not in the recorded performance'}; played the fallback`);
1094        }
1095        const fields = { status: 'answered', values, problems, replayed: true };
1096        // an answer that landed late played from a later cycle; it keeps its section
1097        const kept = !form || values[form.name].choice === planned[form.name].choice;
1098        return Number.isInteger(rec.from) && kept ? { ...fields, planned, from: rec.from } : fields;
1099      };

── following ─────────────────────────────────── A decision from outside is as untrusted as Jev's answer: each value must be one this song's question allows, or the fallback plays.

1104      const cleanAnswer = (name, a) => {
1105        const q = qs.get(name);
1106        if (!q || !isObject(a)) return null;
1107        const flags = {
1108          ...(a.fallback === true ? { fallback: true } : {}),
1109          ...(a.forced === true ? { forced: true } : {}),
1110          ...(a.absent === true ? { absent: true } : {}),
1111        };
1112        const unit = (x) => typeof x === 'number' && x >= 0 && x <= 1;
1113        const confidence = unit(a.confidence) ? a.confidence : 0;
1114        const probabilities = (ok) =>
1115          isObject(a.probabilities)
1116            ? Object.fromEntries(Object.entries(a.probabilities).filter(([k, p]) => ok(k) && unit(p)))
1117            : {};
1118        if (q.type === 'choice') {
1119          if (!Object.hasOwn(q.criteria, a.choice)) return null;
1120          const own = (k) => Object.hasOwn(q.criteria, k);
1121          const sampled = typeof a.sampled === 'number' && a.sampled > 0 && own(a.top);
1122          return {
1123            type: 'choice',
1124            choice: a.choice,
1125            confidence,
1126            probabilities: probabilities(own),
1127            ...flags,
1128            ...(sampled ? { sampled: a.sampled, top: a.top } : {}),
1129          };
1130        }
1131        if (q.type === 'score') {
1132          if (typeof a.score !== 'number' || !Number.isFinite(a.score)) return null;
1133          const top = q.criteria.length - 1;
1134          return {
1135            type: 'score',
1136            score: Math.min(Math.max(a.score, 0), top),
1137            confidence,
1138            probabilities: probabilities((k) => /^\d+$/.test(k) && Number(k) <= top),
1139            ...flags,
1140          };
1141        }
1142        if (typeof a.noul !== 'number' || !Number.isFinite(a.noul)) return null;
1143        return { type: 'noul', noul: Math.min(Math.max(a.noul, 0), 1), ...flags };
1144      };
1145      // `allowed`: with a form, the sections its rules allow in this segment
1146      // (null when the segment was never planned, so nothing narrows it).
1147      const cleanValues = (values, base, allowed = null) => {
1148        const out = { ...base };
1149        const problems = [];
1150        for (const name of qs.keys()) {
1151          const a = cleanAnswer(name, values?.[name]);
1152          if (a && form && name === form.name && allowed && !allowed.includes(a.choice)) {
1153            problems.push(`${name}: "${a.choice}" may not play here under the song's form; it played the fallback`);
1154          } else if (a) out[name] = a;
1155          else problems.push(`${name}: the decision sent is not one of this song's; it played the fallback`);
1156        }
1157        return { values: out, problems };
1158      };
1159      const texts = (xs) => (Array.isArray(xs) ? xs.filter(isText).slice(0, 12).map((x) => x.slice(0, 300)) : []);

One settled decision from outside, as shareDecision() makes it. Returns whether it was taken: a segment already settled keeps what it has, so a decision received twice (a reconnect) changes nothing.

1164      const receive = (segment, wire) => {
1165        if (!Number.isInteger(segment) || segment < 0 || segment >= limit() || !isObject(wire)) return false;
1166        // plan it first, as asking would, so its fallbacks are the song's
1167        // own for that place in the form (request records them, asks nothing)
1168        if (!view.decisions[segment]) request(segment);
1169        const current = view.decisions[segment];
1170        if (current && current.status !== 'asking' && current.status !== 'opening') return false;
1171        // the song ends where the sender's does
1172        if (wire.last === null || (Number.isInteger(wire.last) && wire.last >= 0 && wire.last < limit())) {
1173          view.last = wire.last;
1174        }
1175        const pending = current?.status === 'asking' ? current.values : null;
1176        const sectionSent = form && wire.values?.[form.name]?.choice;
1177        const base =
1178          pending ?? fallbacks(form ? (Object.hasOwn(form.sections, sectionSent) ? sectionSent : form.start) : undefined);
1179        // A segment planned here has the sections its form allowed (with the
1180        // song's `times` counted). One after a section still pending an
1181        // ending (received ahead of that section's own decision) was never
1182        // planned, so only the section's name is checked.
1183        const allowed = form ? (offered.get(segment) ?? null) : null;
1184        const { values, problems } = cleanValues(wire.values, base, allowed);
1185        // the sender's own late answer, cut where it cut it
1186        const cut =
1187          Number.isInteger(wire.from) && wire.from > segment * every && wire.from < (segment + 1) * every
1188            ? { from: wire.from, planned: cleanValues(wire.planned, base, allowed).values }
1189            : {};
1190        let fields = {
1191          cycles: cyclesOf(segment),
1192          status: wire.status === 'fallback' ? 'fallback' : 'answered',
1193          values,
1194          ...cut,
1195          problems: [...texts(wire.problems), ...problems],
1196          ...(isText(wire.reason) ? { reason: wire.reason.slice(0, 300) } : {}),
1197          ...(typeof wire.ms === 'number' && wire.ms >= 0 ? { ms: wire.ms } : {}),
1198          followed: true,
1199        };
1200        requested.add(segment);
1201        if (!begun(segment)) {
1202          // a question read ahead keeps the fallback it already played
1203          for (const name of qs.keys()) {
1204            if (pending && readAhead.has(`${segment}:${name}`) && fields.values[name] !== pending[name]) {
1205              fields.values = { ...fields.values, [name]: pending[name] };
1206              fields.problems = [
1207                ...fields.problems,
1208                `${name}: arrived after a read ahead played it; it kept the fallback`,
1209              ];
1210            }
1211          }
1212        } else {
1213          // It began before the decision arrived: a late answer. The section
1214          // stays as it began; the rest plays from the next whole cycle.
1215          const from = Math.max(Math.ceil(playhead + 0.5), cut.from ?? -Infinity);
1216          if (from < (segment + 1) * every) {
1217            const late = { ...fields.values };
1218            if (form) late[form.name] = base[form.name];
1219            fields = {
1220              ...fields,
1221              values: late,
1222              planned: base,
1223              from,
1224              problems: [...fields.problems, `arrived after the section began; it plays from cycle ${from + 1}`],
1225            };
1226          } else {
1227            fields = {
1228              ...fields,
1229              status: 'fallback',
1230              values: base,
1231              from: undefined,
1232              planned: undefined,
1233              problems: [...fields.problems, 'arrived after the section began; it played the fallbacks'],
1234            };
1235          }
1236        }
1237        record(segment, fields);
1238        const said = Object.entries(fields.values).map(([k, a]) => `${k} ${describe(qs.get(k), a)}`);
1239        logger(`[jev] cycles ${fields.cycles}, followed: ${said.join(', ')}`);
1240        for (const p of fields.problems) logger(`[jev] cycles ${fields.cycles}: ${p}`, 'warning');
1241        return true;
1242      };

── playing ───────────────────────────────────── What each segment plays, fixed once it has been queried with a final decision; a segment that begins while its request is out plays its fallbacks to the end. The song as written (asWritten): the opening, then each section's written successor with its fallbacks, until the written ending.

1250      const writtenValues = [];
1251      const written = (n) => {
1252        if (n === 0) return view.decisions[0].values;
1253        if (n >= limit()) return null;
1254        if (!form) return fallbacks();
1255        if (writtenValues[n] === undefined) {
1256          const current = written(n - 1)?.[form.name]?.choice;
1257          const next =
1258            current && form.sections[current].next.length ? writtenNext(form.sections, current, every) : null;
1259          writtenValues[n] = next ? fallbacks(next) : null;
1260        }
1261        return writtenValues[n];
1262      };

The cycles a segment's section occupies as written: its own form's section, or the one an upstream form decided, or the segment itself.

1266      const writtenSpan = (segment) => {
1267        if (form) {
1268          const section = view.decisions[segment]?.values[form.name]?.choice;
1269          const at = form.sections[section]?.at;
1270          return at === undefined ? null : [at, at + every];
1271        }
1272        for (const { owner } of upstream) {
1273          const span = owner.writtenSpan?.(segment);
1274          if (span) return span;
1275        }
1276        return [segment * every, (segment + 1) * every];
1277      };
1279      const locked = new Map();
1280      const valuesFor = (n) => {
1281        if (locked.has(n)) return locked.get(n);
1282        const d = view.decisions[n];
1283        if (d.status !== 'asking') locked.set(n, d.values);
1284        return d.values;
1285      };

One hap per segment, whole; nothing plays after the song's end. Segments are counted in playing time: inside an arrangement the query arrives at a section's written position (see offsets).

1290      const perSegment = (value, name) =>
1291        new Pattern((st) => {
1292          const off = offsetNow();
1293          const haps = [];
1294          let begin = st.span.begin.add(off);
1295          const stop = st.span.end.add(off);
1296          const dry = writtenDepth > 0;
1297          if (!dry) heardAt(begin.valueOf());
1298          while (begin.lt(stop)) {
1299            const segment = begin.div(every).floor();
1300            const whole = new TimeSpan(segment.mul(every), segment.add(1).mul(every));
1301            const end = whole.end.min(stop);
1302            const n = segment.valueOf();
1303            if (dry) {
1304              const values = n >= 0 ? written(n) : null;
1305              if (values) {
1306                haps.push(
1307                  new Hap(
1308                    new TimeSpan(whole.begin.sub(off), whole.end.sub(off)),
1309                    new TimeSpan(begin.sub(off), end.sub(off)),
1310                    value(values, n),
1311                  ),
1312                );
1313              }
1314              begin = end;
1315              continue;
1316            }
1317            // Following from mid-song, a segment may have nothing yet: it
1318            // plays its fallbacks until its decision arrives.
1319            if (following && n >= 0 && !view.decisions[n]) request(n);
1320            if (end.gt(whole.begin.add(every / 2))) request(n + 1);
1321            const over = view.last !== null && n > view.last;
1322            if (n >= 0 && n < limit() && !over && view.decisions[n]) {
1323              const d = view.decisions[n];
1324              if (name && d.status === 'asking') tickReads.push({ segment: n, key: `${n}:${name}` });
1325              // an answer that landed mid-section plays from its `from` cycle
1326              const cut = d.from !== undefined && d.from > n * every ? Fraction(d.from) : null;
1327              const pieces = cut
1328                ? [
1329                    [whole.begin, cut, d.planned],
1330                    [cut, whole.end, d.values],
1331                  ]
1332                : [[whole.begin, whole.end, valuesFor(n)]];
1333              for (const [from, to, values] of pieces) {
1334                const partBegin = begin.max(from);
1335                const partEnd = end.min(to);
1336                if (!partBegin.lt(partEnd)) continue;
1337                haps.push(
1338                  new Hap(
1339                    new TimeSpan(from.sub(off), to.sub(off)),
1340                    new TimeSpan(partBegin.sub(off), partEnd.sub(off)),
1341                    value(values, n),
1342                  ),
1343                );
1344              }
1345            }
1346            begin = end;
1347          }
1348          return haps;
1349        });
1351      const FIELDS = {
1352        choice: ['choice', 'confidence', 'probabilities'],
1353        score: ['score', 'confidence', 'probabilities'],
1354        noul: ['noul'],
1355      };
1356      const answers = {};
1357      for (const [name, q] of qs) {
1358        answers[name] = Object.fromEntries(
1359          FIELDS[q.type].map((f) => [f, perSegment((values) => values[name][f], name)]),
1360        );
1361        owners.set(answers[name], {
1362          name,
1363          question: q,
1364          every,
1365          decided,
1366          // what this question settled for a segment, for another jev()'s history
1367          valueAt(segment) {
1368            const a = view.decisions[segment]?.values[name];
1369            if (!a) return null;
1370            const v = q.type === 'choice' ? a.choice : describe(q, a);
1371            return a.fallback && !a.forced && view.decisions[segment].status !== 'opening' ? `${v} (fallback)` : v;
1372          },
1373          // a jev() asked after this one: its settled answers go into this
1374          // one's history rows (`arrangement`), so no two may share a name
1375          arrangedBy(by, names, at) {
1376            for (const [other, { names: taken }] of arrangers) {
1377              const both = other !== by && names.filter((n) => taken.includes(n));
1378              if (both?.length) {
1379                throw new Error(
1380                  `jev: two jev()s asked after "${name}" both have ${both.map((n) => `"${n}"`).join(', ')}; its history could not tell them apart`,
1381                );
1382              }
1383            }
1384            arrangers.set(by, { names, at });
1385          },
1386          // this form's section in `segment`, and every section it has
1387          sectionAt(segment) {
1388            return form && form.name === name ? (view.decisions[segment]?.values[name]?.choice ?? null) : null;
1389          },
1390          sectionNames() {
1391            return form && form.name === name ? Object.keys(form.sections) : null;
1392          },
1393          // the cycles `segment`'s section occupies as written, from this form
1394          writtenSpan(segment) {
1395            return form && form.name === name ? writtenSpan(segment) : null;
1396          },
1397          // where the song is in this question's form before `segment`
1398          where(segment) {
1399            if (!form || form.name !== name) return null;
1400            const now = view.decisions[segment - 1]?.values[name]?.choice;
1401            return now ? { playingNow: now, sectionsLeft: form.max - segment } : null;
1402          },
1403          // walk(): the form's rules over this choice
1404          attachForm(spec) {
1405            if (form) throw new Error('walk: this jev() already has a form');
1406            if (requested.size > 0) throw new Error('walk: attach the form before the song plays');
1407            form = { name, ...spec };
1408            // its sections in the order written, for pages (the song's tally)
1409            view.form = { name, sections: Object.keys(spec.sections).sort((a, b) => spec.sections[a].at - spec.sections[b].at) };
1410            view.last = spec.sections[spec.start].next.length === 0 ? 0 : null;
1411            view.decisions[0].values[name] = fallbackAnswer(q, spec.start);
1412            changed();
1413            return perSegment;
1414          },
1415        });
1416      }
1417      return answers;
1418    };
1419    return { every: everyFn };
1420  };
1421}

The song's form, as rules in code: which sections may follow which, and when the song ends. Jev picks each next section from a choice whose options are the sections; walk offers it only the sections allowed next. It makes the written order the fallback and ends the song. This is TypeSafe's "keep control flow and deterministic rules in code": Jev picks the section, and code decides what may be picked.

Each section is one of the choice's options, exactly:

  • at: the cycle it starts at in the song as written.
  • next: the sections that may follow it. It must include the one that follows it as written; [] makes it an ending.
  • times (optional): the most times it plays in one performance, counted in total, which bounds a loop.

max caps the number of sections (16 by default); the song is steered to an ending by then. start is the first section (the first listed by default). walk refuses a form it cannot always finish.

form.arrange(pat) plays each section's own cycles of pat: write the song out whole, in the written order, and the form replays its sections in the order Jev picks. A second jev() asked after: { section } then arranges each section, e.g. with part levels (score). @name walk @tags jev @param {Object} answer a choice's answer from .every(...), e.g. section in const { section } = formJev.every(4) @param {Object} form { max = 16, start, sections: { name: { at, next, times } } } @returns {Object} { arrange(pat) }: each section plays its own cycles of pat @example setcps(.5) const formJev = jev({ state: { title: 'SHORT FORM', what: 'a pad, then drums under it, then the pad alone to end' }, questions: { section: choice('Pick the section that plays next; the outro ends the song', { intro: 'the pad alone', drop: 'the pad with the drums', outro: 'the pad fading out: the end', }), }, }) const { section } = formJev.every(4) const form = walk(section, { max: 8, sections: { intro: { at: 0, next: ['drop'] }, drop: { at: 4, next: ['drop', 'outro'], times: 3 }, outro: { at: 8, next: [] }, }, }) form.arrange(stack( note("<c3,e3,g3 a2,c3,e3 f2,a2,c3 g2,b2,d3>").s('sawtooth').lpf(900), s("bd*4, ~ sd").mask("<0!4 1!4 0!4>"), ))

1465export function walk(answer, { max = 16, start, sections, ...rest } = {}) {
1466  if (Object.keys(rest).length) throw new Error(`walk: unknown option ${Object.keys(rest).join(', ')}`);
1467  const owner = owners.get(answer);
1468  if (!owner) throw new Error("walk: give it a choice's answer from jev().every(), e.g. const { section } = formJev.every(8); walk(section, {...})");
1469  const { name, question } = owner;
1470  if (question.type !== 'choice') throw new Error(`walk: "${name}" must be a choice`);
1471  if (!(max > 0)) throw new Error("walk: a form's `max` must be a positive number of sections");
1472  const options = Object.keys(question.criteria);
1473  const all = Object.keys(sections ?? {});
1474  const missing = options.filter((o) => !all.includes(o));
1475  const extra = all.filter((o) => !options.includes(o));
1476  if (missing.length || extra.length) {
1477    throw new Error(
1478      `walk: the sections must be ${name}'s options exactly${missing.length ? `; missing ${missing.join(', ')}` : ''}${extra.length ? `; not options: ${extra.join(', ')}` : ''}`,
1479    );
1480  }
1481  for (const [n, sec] of Object.entries(sections)) {
1482    if (!(sec?.at >= 0)) throw new Error(`walk: section "${n}" needs \`at\`, the cycle it starts at`);
1483    if (!Array.isArray(sec.next)) throw new Error(`walk: section "${n}" needs \`next\`, a list (empty to end)`);
1484    for (const x of sec.next) {
1485      if (!Object.hasOwn(sections, x)) throw new Error(`walk: section "${n}" is followed by "${x}", not a section`);
1486    }
1487    const unknown = Object.keys(sec).filter((k) => !['at', 'next', 'times'].includes(k));
1488    if (unknown.length) throw new Error(`walk: section "${n}" takes at, next and times, not ${unknown.join(', ')}`);
1489    if (sec.times !== undefined && !(Number.isInteger(sec.times) && sec.times >= 1)) {
1490      throw new Error(`walk: section "${n}"'s \`times\` must be a whole number of plays, at least 1`);
1491    }
1492  }
1493  const first = start ?? all[0];
1494  if (!Object.hasOwn(sections, first)) throw new Error(`walk: the start "${first}" is not a section`);
1495  const endings = all.filter((n) => sections[n].next.length === 0);
1496  if (!endings.length) throw new Error('walk: the form needs an ending: a section with `next: []`');
1497  // How many sections must still play after each one to reach an ending:
1498  // each request offers only the next sections that can end the song by
1499  // the cap (and within each section's `times`, counted then), so the
1500  // ending is never forced onto a section mid-phrase. Every section may
1501  // play at least once, so here all are open.
1502  const toEnd = stepsToEnd(sections);
1503  const stuck = all.filter((n) => toEnd[n] === Infinity);
1504  if (stuck.length) throw new Error(`walk: no way to an ending from ${stuck.join(', ')}`);
1505  // The written order is the fallback, so it must always be allowed.
1506  for (const [n, sec] of Object.entries(sections)) {
1507    const w = writtenNext(sections, n, owner.every);
1508    if (w && !sec.next.includes(w))
1509      throw new Error(`walk: "${n}" is followed by "${w}" as written, so its \`next\` must include it`);
1510  }
1511  if (toEnd[first] > max - 1)
1512    throw new Error(`walk: the shortest way through the form is ${toEnd[first] + 1} sections, over the cap of ${max}`);
1513  // Every section, and every way on the form writes down, must be able to
1514  // play: a request offers x after n only when an ending is still in reach,
1515  // and n is never reached before its shortest way from the start. A way on
1516  // that can never be offered is a detour the song claims and Jev never
1517  // hears of (hey-listen's drop2d → drop1 was one, 2026-09-26).
1518  const fromStart = Object.fromEntries(all.map((n) => [n, n === first ? 0 : Infinity]));
1519  for (let changed = true; changed; ) {
1520    changed = false;
1521    for (const n of all) {
1522      for (const x of sections[n].next) {
1523        if (fromStart[n] + 1 < fromStart[x]) [fromStart[x], changed] = [fromStart[n] + 1, true];
1524      }
1525    }
1526  }
1527  for (const n of all) {
1528    if (fromStart[n] + toEnd[n] > max - 1) {
1529      throw new Error(`walk: "${n}" can never play: no way through it ends within the cap of ${max} sections`);
1530    }
1531    for (const x of sections[n].next) {
1532      if (fromStart[n] + 1 + toEnd[x] > max - 1) {
1533        throw new Error(
1534          `walk: "${n}" → "${x}" can never be taken: from there the song cannot end within the cap of ${max} sections`,
1535        );
1536      }
1537    }
1538  }
1539  const perSegment = owner.attachForm({ sections, max, start: first, endings });
1540  return {
1541    arrange(pat) {
1542      return perSegment((values, n) => n * owner.every - sections[values[name].choice].at)
1543        .fmap((shift) => (shift ? shifted(pat, shift) : pat))
1544        .innerJoin();
1545    },
1546  };
1547}