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.
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.
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"),
)
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))
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)),
)
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.
The performance jev()s declared from now on replay (see the header).
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.
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.
The listeners' reactions to a section, as totals counted elsewhere (a party's room): every jev() of the playing song hears them.
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.
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.
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 asdecidedThisSection.allow: { name: (decided) => [options] }: narrows a choice per section from whatafterdecided, plusplayingNow(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 withsampleon the same question.forget: [names]: questions left out of the history Jev is sent.segments: how many sections the song lasts, when nowalkform 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
sampleon 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 }
── 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.
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 };
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}