1// `jev({...})` — TypeSafe's API as Strudel patterns, in the shape of its 2// SDK (`@typesafe-ai/sdk`: `client.systemOne({ state, questions })`, with 3// questions built by `choice`, `score` and `noul`). A song declares its 4// Jev once, at the top, and composes the answers in code: 5// 6// const djJev = jev({ // TypeSafe's request: state and questions 7// state: { title: 'LIGHTNING IN A BOTTLE', key: 'E minor' }, 8// questions: { 9// section: choice('Pick the next section', { intro: '…', riff: '…', …, outro: '…' }), 10// move: choice('Pick which parts play it', { asWritten: '…', noDrums: '…' }), 11// energy: score('How intense should it be?', ['held back', 'steady', 'driving', 'all out']), 12// }, 13// }) 14// const { section, move, energy } = djJev.every(8, { fallback: { energy: 3 } }) // Strudel: when to ask 15// … 16// const form = walk(section, { max: 16, sections: { intro: { at: 0, next: ['riff'] }, … } }) 17// form.arrange(move.choice.pick({ asWritten: song, noDrums: … })).velocity(energy.score.div(3).range(.55, 1)) 18// 19// `jev({ state, questions })` is exactly the SDK's request; nothing about 20// time is Jev's. `djJev.every(cycles, { fallback })` is Strudel's side: it 21// asks every `cycles` cycles and returns the answers, mirroring the SDK's 22// `response.answers`, with a pattern (one value per `cycles`) for each field: 23// `.choice`, `.confidence` and `.probabilities` for a choice; `.score`, 24// `.confidence` and `.probabilities` for a score (on the rubric's own scale, 25// 0 to levels-1); `.noul` for a noul. 26// 27// `fallback` (to `.every`) is what plays when Jev has not answered, or answered with 28// something unusable: a choice falls back to its first option unless named; 29// a score and a noul must name theirs, since nothing about a ladder or a 30// yes/no says which end is "as written". 31// 32// `walk(answer, { max, start, sections })` is not Jev: it is the song's 33// form as rules in code, as TypeSafe's docs ask ("keep control flow and 34// deterministic rules in code"). Each section is one of a choice's options, 35// with `at` (the cycle it starts at as written) and `next` (the sections 36// allowed to follow it; empty ends the song), and optionally `times` (the 37// most times it plays in one performance). It narrows that choice, per 38// request, to the options allowed next (or skips it when only one is), makes 39// the written order its fallback, forces an ending at `max` sections (16 by 40// default), ends the song after an ending section, and `form.arrange(pat)` 41// plays each section's own cycles of `pat`. 42// 43// `times` counts every play of the section, not a run of it: a loop is 44// usually a cycle of different sections (groove a → b → c → a), where no one 45// section ever repeats back to back, so only a total bounds it: a cycle 46// through a section with `times: 2` goes round at most twice. A section that 47// has played its `times` is no longer offered, nor is any section whose only 48// ways to an ending now run through one; there is always a way on (see 49// `stepsToEnd`), and the written order, which plays each section once, is 50// never capped. 51// 52// A jev() asked after a form (`after: { section }`) arranges each section 53// the form picks; the form's own history rows carry what it settled for 54// each past section as `arrangement` (its questions, less the ones it 55// `forget`s and parts absent there), so the form's Jev can hear what the 56// arrangement built and released before it picks the next section. 57// 58// Jev only ever selects among what the song wrote down, and anything 59// unusable plays the fallback and says why in the console. All of the text 60// goes in 'single quotes': "double quotes" and backticks are mini-notation. 61// 62// One request per section, as TypeSafe advises ("ask independent questions 63// together, then compose their answers in code"): every question for 64// section n+1 goes to Jev at once, as soon as the song first queries 65// section n+1's answers: halfway through section n at the latest, and 66// earlier when a song reads ahead (a transition's `.early(1)` asks a bar into 67// section n, which gives a chained `after:` request its time but leaves out 68// 🔥/😴 from the rest of section n), with the song's `state`, the history of answers, the 69// reactions, and, with a form, where the song is in it. A section that 70// begins before its answer lands plays its fallbacks to its end. "Begins" 71// is playing time: the earliest cycle the song queried in one scheduler 72// tick, so a read ahead (`.early(1)`) does not count as the section 73// beginning. A question read ahead while its answer is out has already 74// played its fallback, though: it keeps it for that section (and says so), 75// so a transition never switches halfway through its bar. 76// 77// `allow: { name: (decided) => [options] }` (to `.every`) narrows a choice 78// per request, the way a form narrows its sections: `decided` holds the 79// `after:` answers (`{ section: 'drop1' }`) and, when one of them walks a 80// form, `playingNow`, the section before it. Rules like "no riser out of a 81// build" are then code, not a sentence Jev may ignore. The fallback must 82// always be allowed; a single allowed option is played without asking. 83// 84// Listeners: `listeners()` (to createJev) is every listener's 🔥/😴 tally 85// for the song playing, { [section]: { fire, sleep } } (reactions.mjs), or 86// null. Each request carries it as `listenersSoFar`, cut to the sections 87// that matter to the answer: the last few played and those that may come 88// next (or the one being entered, for a jev() asked after a form's), at 89// most LISTENERS_MAX, and only names the song's form has (the tally is 90// anyone's writing). A song without a form keys sections by number. 91// 92// Replay: `setReplay(performance)` makes every jev() declared after it 93// play a recorded performance (performance.mjs) instead of asking Jev: each 94// segment takes the recorded answers of its jev() (by declaration order), 95// with no request at all. The rules still apply: a recorded answer the 96// song's form or `allow` no longer permits there, or that is no longer an 97// option, plays the fallback and says so, and a segment the recording does 98// not have plays the fallbacks. So a song whose code changed since plays 99// the performance where it still can. `setReplay(null)` goes back to Jev. 100// 101// Decisions can also come from outside (`followDecisions`): a listening 102// party's guests (party.mjs) play the decisions the host's page settled, 103// sent to them as they are made (`watchDecisions`), and never ask Jev. Each 104// decision is checked against the song before it plays, like an answer; one 105// that arrives after its section began is a late answer. 106import { Fraction, Hap, Pattern, TimeSpan, logger } from '@strudel/core'; 107import { boothStore } from './boothStore.mjs'; 108 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} 122 123// 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} 128 129// How many sections must still play after each one to reach an ending, 130// through only the sections that may still play (`open`); Infinity where 131// none can. A shortest way is a simple path, so it plays each section on it 132// at most once: a section open now (one play left, at least) can be on it. 133// That is why a request never runs out of sections: the section chosen at 134// segment n could reach an ending by the cap through sections open then; 135// playing it closes at most itself, which its own shortest way never 136// revisits, so the next section on that way is still open, one step 137// 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} 151 152// The SDK's question builders, same signatures, same objects on the wire. 153// Their jsdoc (and jev's, below) is the reference tab's and the MCPs' 154// api_reference's (package.json's jsdoc-json reads this file); its examples 155// are evaluated and played in reference.test.mjs. 156 157/** 158 * 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. 159 * 160 * 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. 161 * 162 * All of the text goes in 'single quotes': "double quotes" and backticks are mini-notation, and jev() refuses them. 163 * @name choice 164 * @tags jev 165 * @param {string} instructions the question, in 'single quotes' 166 * @param {Object} criteria at least two options, `{ name: 'what it is, for Jev' }` 167 * @returns {Object} the question, for `jev({ questions })` 168 * @example 169 * setcps(.5) 170 * const djJev = jev({ 171 * state: { title: 'TWO MOODS', what: 'a sawtooth chord over a kick' }, 172 * questions: { 173 * mood: choice('Pick the chord for the next four bars', { 174 * dark: 'A minor, low and filtered', 175 * bright: 'C major, open', 176 * }), 177 * }, 178 * }) 179 * const dj = djJev.every(4, { segments: 8 }) 180 * stack( 181 * note(dj.mood.choice.pick({ dark: "a2,c3,e3", bright: "c3,e3,g3" })).s('sawtooth').lpf(1200), 182 * s("bd*4"), 183 * ) 184 */ 185export function choice(instructions, criteria) { 186 return { type: 'choice', instructions, criteria }; 187} 188 189/** 190 * 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. 191 * 192 * 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 }`. 193 * @name score 194 * @tags jev 195 * @param {string} instructions the question, in 'single quotes' 196 * @param {Array} criteria 2 to 10 levels, low to high, each a 'label: what it means' 197 * @returns {Object} the question, for `jev({ questions })` 198 * @example 199 * setcps(.5) 200 * const djJev = jev({ 201 * state: { title: 'RAMP', what: 'a kick and a saw bass' }, 202 * questions: { 203 * energy: score('How intense should the next four bars be?', [ 204 * 'held back: half velocity', 205 * 'steady: most of the way', 206 * 'all out: full velocity', 207 * ]), 208 * }, 209 * }) 210 * const dj = djJev.every(4, { fallback: { energy: 2 }, segments: 8 }) 211 * stack(s("bd*4"), note("<a1 c2 f1 g1>").s('sawtooth').lpf(600)) 212 * .velocity(dj.energy.score.div(2).range(.5, 1)) 213 */ 214export function score(instructions, criteria) { 215 return { type: 'score', instructions, criteria }; 216} 217 218/** 219 * 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. 220 * 221 * 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). 222 * @name noul 223 * @tags jev 224 * @param {string} instructions the question, in 'single quotes' 225 * @param {Object} criteria optional, `{ true: '…', false: '…' }` 226 * @returns {Object} the question, for `jev({ questions })` 227 * @example 228 * setcps(.5) 229 * const djJev = jev({ 230 * state: { title: 'BREAKDOWN', what: 'drums under a pad; the drums can drop out for a section' }, 231 * questions: { 232 * breakdown: noul('Should the next four bars drop the drums for a breakdown?', { 233 * true: 'the drums stop, the pad alone', 234 * false: 'the drums keep going', 235 * }), 236 * }, 237 * }) 238 * const dj = djJev.every(4, { fallback: { breakdown: 0 }, segments: 8 }) 239 * stack( 240 * note("<c3,e3,g3 a2,c3,e3>").s('sawtooth').lpf(900), 241 * s("bd*4, ~ sd").mask(dj.breakdown.noul.lt(.5)), 242 * ) 243 */ 244export function noul(instructions = null, criteria = null) { 245 return { type: 'noul', instructions, ...(criteria ? { criteria } : {}) }; 246} 247 248// 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 }; 255 256// What a question's answer says, in words, for the history Jev is sent. 257// `brief`: a score's level by its name alone, the words before its colon 258// ('full: as written' is 'full'), for another jev()'s history, which does 259// 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} 268 269// How many sections `listenersSoFar` names at most, and how many of them 270// are the ones just played. 271const LISTENERS_MAX = 8; 272const LISTENERS_BACK = 4; 273 274// 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} 279 280const cycleText = (c) => (Number.isFinite(c) ? (Math.round(c * 10) / 10).toFixed(1) : 'none yet'); 281 282// Ask every jev() of the song about to start (the evaluation's, from the 283// booth) about its opening section, and wait until they answer or `ms` 284// passes: the scheduler awaits it before its first tick (useReplContext's 285// beforeStart), so the opening is Jev's call rather than always the 286// 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} 294 295// The parts of the song about to start, as written: which orbit sounds in 296// which cycles (preload.mjs reads it off the song queried asWritten). A 297// question named for a part's orbit (`part('drums', …)` plays on 298// `.orbit('drums')`) is not asked about a section where that part has no 299// notes as written; songs are written so, so no song names them by hand. 300// They are attached to the song's own jev()s (the evaluation's, from the 301// 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} 325 326// ── decisions from outside ────────────────────── 327// A performance's decisions, shared: a listening party's host sends each one 328// its jev()s settle (watchDecisions), and its guests' jev()s play them 329// instead of asking (followDecisions). The same two ends fit a performance 330// recorded and replayed later: keep what watchDecisions hands out, and feed 331// it back through the receivers followDecisions returns. 332// 333// The views are the song about to start (or playing), in the order its 334// jev()s are declared, which is the same for the same code: a view's index 335// is its name on the wire. 336 337// What of a decision leaves the page: the values and how they came to be, 338// not what Jev was told (`state`: the history and measurements, large and 339// 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} 353 354// Calls fn(view, segment, decision) (decision as shareDecision makes it) for 355// every decision the starting song's jev()s settle from now on. Returns the 356// 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} 371 372// Puts the starting song's jev()s in follow mode: they ask Jev nothing and 373// play what they receive. Returns a receiver per view, in declaration 374// order: `receive(segment, decision) → taken`. Feed it what is already 375// known before the song starts; askOpening then waits for the opening's. 376export function followDecisions() { 377 return boothStore.starting().map((view) => view.follow()); 378} 379 380// The listeners' reactions to a section, as totals counted elsewhere (a 381// 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} 388 389// asWritten(fn): queries made inside fn see every jev() as the song was 390// written (each section its written successor, playing its fallbacks) and 391// change nothing: no request goes out, the playhead does not move, nothing 392// is locked. For looking ahead at a whole song, e.g. to load its samples 393// before it plays (preload.mjs); a normal query of a section that has not 394// 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} 404 405// While walk()'s arrange() queries a section's own cycles, the time it 406// shifted them by: an answer composed inside an arrangement (a `.pick` 407// inside `form.arrange(…)`) is queried at the section's written position, 408// and adds this back to find the section actually playing. Queries are 409// 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 }); 421 422// Each jev() keeps its internals here, keyed by its answers, so walk() can 423// attach a form to the question an answer came from. 424const owners = new WeakMap(); 425 426// Sampling a choice at a temperature: probabilities raised to 1/t and 427// renormalised (t = 1 samples Jev's own distribution; t → 0 is its top 428// 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} 441 442/** 443 * 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. 444 * 445 * `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`. 446 * 447 * `.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: 448 * - a choice: `.choice`, `.confidence`, `.probabilities` 449 * - a score: `.score`, `.confidence`, `.probabilities` 450 * - a noul: `.noul` 451 * 452 * The options to `.every`: 453 * - `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. 454 * - `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`. 455 * - `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. 456 * - `sample`: a temperature (or `{ name: t }`) to draw each choice from Jev's probabilities instead of always its top pick. 457 * - `minConfidence`: a threshold (or `{ name: c }`) under which a choice or score plays its fallback. Not together with `sample` on the same question. 458 * - `forget: [names]`: questions left out of the history Jev is sent. 459 * - `segments`: how many sections the song lasts, when no `walk` form ends it. 460 * 461 * 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. 462 * 463 * 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`). 464 * 465 * All of Jev's text goes in 'single quotes'. The REPL turns "double quotes" and backticks into mini-notation, and jev() refuses them. 466 * @name jev 467 * @tags jev 468 * @param {Object} request `{ state, questions }`, TypeSafe's request 469 * @returns {Object} `{ every(cycles, options) }`, which returns the answers 470 * @example 471 * setcps(.5) 472 * const djJev = jev({ 473 * state: { title: 'NIGHT BUS', what: 'a chord pad over drums', key: 'A minor' }, 474 * questions: { 475 * chord: choice('Pick the chord for the next four bars', { 476 * home: 'A minor, where the song rests', 477 * lift: 'F major, a lift', 478 * tense: 'E major, pulling back home', 479 * }), 480 * energy: score('How hard should the drums hit?', ['soft: a kick a bar', 'steady: four on the floor']), 481 * hats: noul('Should the next four bars add eighth-note hats?'), 482 * }, 483 * }) 484 * const dj = djJev.every(4, { fallback: { energy: 1, hats: 0 }, segments: 8 }) 485 * stack( 486 * note(dj.chord.choice.pick({ home: "a2,c3,e3", lift: "f2,a2,c3", tense: "e2,g#2,b2" })).s('sawtooth').lpf(1000), 487 * s(dj.energy.score.round().pick(["bd", "bd*4"])), 488 * s("hh*8").mask(dj.hats.noul.gt(.5)), 489 * ) 490 */ 491// `decide(state, questions, { begins })` resolves to Jev's answers, keyed 492// like `questions`: the relay in the page (jev.mjs), a stub in tests. 493// `begins` is the cycle the section asked about begins at, so the page can 494// tell the relay how far ahead each call went out. `hear(from, 495// to)` summarises what the page actually played between two cycles 496// (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; 529 530 // Strudel's side: ask every `every` cycles, with these fallbacks. 531 // - fallback: what plays without a usable answer (see the header). 532 // - after: { name: answer } from another jev() on the same cadence: 533 // this one is asked only once those are decided, with them in its 534 // state, since questions in one request cannot see each other. 535 // - sample: a temperature (or { name: t }) to sample each choice from 536 // Jev's probabilities instead of always taking its top pick. 537 // - minConfidence: a threshold (or { name: c }) under which a choice 538 // or score plays its fallback instead. Not with `sample` on the same 539 // question: sampling acts on the probabilities, and a close call is 540 // exactly where it should differ from the top pick. TypeSafe: "If you 541 // have a specific statistical algorithm in mind, you should probably 542 // 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 }); 617 618 // 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}`); 622 623 // ── 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 }; 663 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() }; 670 671 // 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); 745 746 // The form's section in a segment: this jev's own form, or the one 747 // 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 }; 768 769 // ── 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 }; 881 882 // A form's section, settled here or by receive(), is always one of 883 // form.sections, and one the form allowed there: every way in is 884 // checked. Jev's answer must be an own key of the narrowed choice 885 // (`asking`, the allowed sections), or the planned section plays; 886 // a sampled pick is kept only if it is one too; a replay keeps a 887 // recorded section only if `asking` offers it; a decision from 888 // outside (receive) only if it is the song's and `offered` there; 889 // and every fallback is the start or an allowed section. walk() 890 // makes sections and the choice's options the same set. So 891 // `form.sections[x]` is never undefined for a settled x, and the 892 // "reading 'next'" TypeError seen once (2026-09-25) cannot come 893 // from a live input: it was caught only in a hot-reloaded module 894 // version mid-refactor, when this line still read `.next` without 895 // `?.` (fixed in 101498b3); follow.test.mjs and jevCore.test.mjs 896 // 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 }; 903 904 // 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 }; 1070 1071 // A segment as the recorded performance played it, under the song's 1072 // rules now: `asking` holds what may be answered here (the form's 1073 // 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 }; 1100 1101 // ── following ─────────────────────────────────── 1102 // A decision from outside is as untrusted as Jev's answer: each value 1103 // 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)) : []); 1160 1161 // One settled decision from outside, as shareDecision() makes it. 1162 // Returns whether it was taken: a segment already settled keeps what it 1163 // 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 }; 1243 1244 // ── playing ───────────────────────────────────── 1245 // What each segment plays, fixed once it has been queried with a final 1246 // decision; a segment that begins while its request is out plays its 1247 // fallbacks to the end. 1248 // The song as written (asWritten): the opening, then each section's 1249 // 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 }; 1263 1264 // The cycles a segment's section occupies as written: its own form's 1265 // 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 }; 1278 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 }; 1286 1287 // One hap per segment, whole; nothing plays after the song's end. 1288 // Segments are counted in playing time: inside an arrangement the 1289 // 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 }); 1350 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} 1422 1423/** 1424 * 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. 1425 * 1426 * Each section is one of the choice's options, exactly: 1427 * - `at`: the cycle it starts at in the song as written. 1428 * - `next`: the sections that may follow it. It must include the one that follows it as written; `[]` makes it an ending. 1429 * - `times` (optional): the most times it plays in one performance, counted in total, which bounds a loop. 1430 * 1431 * `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. 1432 * 1433 * `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`). 1434 * @name walk 1435 * @tags jev 1436 * @param {Object} answer a choice's answer from `.every(...)`, e.g. `section` in `const { section } = formJev.every(4)` 1437 * @param {Object} form `{ max = 16, start, sections: { name: { at, next, times } } }` 1438 * @returns {Object} `{ arrange(pat) }`: each section plays its own cycles of `pat` 1439 * @example 1440 * setcps(.5) 1441 * const formJev = jev({ 1442 * state: { title: 'SHORT FORM', what: 'a pad, then drums under it, then the pad alone to end' }, 1443 * questions: { 1444 * section: choice('Pick the section that plays next; the outro ends the song', { 1445 * intro: 'the pad alone', 1446 * drop: 'the pad with the drums', 1447 * outro: 'the pad fading out: the end', 1448 * }), 1449 * }, 1450 * }) 1451 * const { section } = formJev.every(4) 1452 * const form = walk(section, { 1453 * max: 8, 1454 * sections: { 1455 * intro: { at: 0, next: ['drop'] }, 1456 * drop: { at: 4, next: ['drop', 'outro'], times: 3 }, 1457 * outro: { at: 8, next: [] }, 1458 * }, 1459 * }) 1460 * form.arrange(stack( 1461 * note("<c3,e3,g3 a2,c3,e3 f2,a2,c3 g2,b2,d3>").s('sawtooth').lpf(900), 1462 * s("bd*4, ~ sd").mask("<0!4 1!4 0!4>"), 1463 * )) 1464 */ 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}