Files
XZBT/test/phase5-cadence.test.mjs
T
LabyricornandClaude Opus 5 3db1df058f feat(cadence): implement the phase 5 event and cadence subsystems
Section 20 in full: the automatic scheduling algorithm with its eligible-pool
filtering, cooldown and overlap exclusion, weight evaluation, the anti-repetition
multiplier table applied by recency-queue position, pool relaxation when total
effective weight reaches zero, and reschedule by uniform sample divided by
intensity; the priority clocks and minimum-gap servicing of 20.6 with deferred
classes retained; intensity clamping and the pause threshold of 20.7, under
which ambient voices keep running; the 1024-unit dispatch budget and sixteen
levels of event nesting; and manual SAMPLE evaluation in an isolated stream that
does not perturb cadence scheduling.

Two defects found by the section 20 review triage are fixed here rather than
shipped. A sound action's usage check tested whether the target sound permitted
*some* action context instead of the caller's own, so a scenario-only sound was
playable from a manual action; it now checks the caller's context and rejects
with ERR_UNSUPPORTED_TARGET, the code 20.2.2 and 20.12 actually name, and the
two cases the previous test never exercised — manual-only from scenario, and
scenario-only from manual — are covered.

Traces 7 through 10 drove `triggerSound()` and `calculateEligiblePool()`, a
duplicate scheduler with no production call site, rather than the
`advance()`/`fireClass()` path a real exhibit runs. The two implementations
disagreed on recency-history semantics: 20.5.9 requires a plain queue of the
last four firings, and the duplicate moved an existing entry to the front
instead. Nothing caught it because nothing called both. Those traces now drive
`update()`/`advance()` with a seeded RNG, as traces 11 through 14 already did,
and the duplicate is deleted rather than reconciled.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
2026-09-06 21:53:51 +00:00

870 lines
30 KiB
JavaScript

// Phase 5 — Cadence and Event Subsystems Acceptance Tests
// Covers Format Specification 0.1 Section 20.15 traces and PRD 61-68, 90-91.
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateExhibit } from '../src/runtime/validator.js';
import { SeededRNG } from '../src/runtime/rng.js';
import { ResolutionEngine } from '../src/runtime/resolution.js';
import { ActionExecutor } from '../src/runtime/actions.js';
import {
CadenceSubsystem,
DEFAULT_CLASS_INTERVALS,
RECENCY_MULTIPLIERS,
CADENCE_PRIORITY
} from '../src/runtime/cadence.js';
import { CommonGrammarPerformance } from '../src/runtime/performance.js';
import { RuntimeFault } from '../src/runtime/types.js';
function baseExhibit(overrides = {}) {
return {
xzbt: '0.1',
meta: { id: 'cadence-test', name: 'Cadence Test' },
runtime: { seed: 42 },
audio: {
buses: {
ambient: { gain: 0.5 },
effects: { gain: 0.8 }
}
},
sounds: {
'hum': {
name: 'Hum',
usage: ['automatic'],
cadence: { class: 'ambient' },
bus: 'ambient',
recipe: {
mode: 'continuous',
nodes: {
gen: { type: 'oscillator', frequency: 100 },
gain: { type: 'gain', gain: 0.3 }
},
routes: [
{ from: 'gen', to: 'gain' },
{ from: 'gain', to: 'output' }
]
}
},
'click': {
name: 'Click',
usage: ['automatic', 'manual', 'scenario'],
cadence: { class: 'routine', weight: 1.0, cooldown: '5s' },
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: {
imp: { type: 'impulse', duration: '5ms' },
gain: { type: 'gain', gain: 0.5 }
},
routes: [
{ from: 'imp', to: 'gain' },
{ from: 'gain', to: 'output' }
]
}
}
},
cadence: {
minGap: '1.5s',
intensity: 1.0,
clocks: {
routine: { min: '10s', max: '30s' }
}
},
...overrides
};
}
class FakeAudioSubsystem {
constructor() {
this.played = [];
this.activeInstances = new Map(); // id -> voice
this.nextVoiceId = 1;
}
play(soundId, options = {}) {
const voiceId = this.nextVoiceId++;
const voice = {
id: voiceId,
soundId,
options,
disposed: false,
stop: () => {
voice.disposed = true;
this.activeInstances.delete(voiceId);
}
};
this.played.push({ soundId, options, time: options.time ?? 0, voice });
this.activeInstances.set(voiceId, voice);
return voice;
}
getActiveSounds() {
const list = [];
for (const v of this.activeInstances.values()) {
if (!v.disposed) {
list.push({ soundId: v.soundId, instance: v });
}
}
return list;
}
}
// ---------------------------------------------------------------------------
// Trace 1: Schema & Static Validation (Spec 20.15 Trace 1)
// ---------------------------------------------------------------------------
test('Trace 1: Schema & Static Validation of cadence and sound metadata', () => {
// Valid exhibit validates cleanly
const valid = baseExhibit();
const resValid = validateExhibit(valid);
assert.deepEqual(resValid.errors, [], `Expected clean validation: ${JSON.stringify(resValid.errors)}`);
// Clock range with min > max rejected with ERR_INVALID_RANGE_ORDER
const invalidRange = baseExhibit({
cadence: {
clocks: {
routine: { min: '30s', max: '10s' }
}
}
});
const resRange = validateExhibit(invalidRange);
assert.ok(
resRange.errors.some(e => e.code === 'ERR_INVALID_RANGE_ORDER'),
'Expected ERR_INVALID_RANGE_ORDER when min > max'
);
// Unknown field in cadence container rejected with ERR_UNKNOWN_FIELD
const unknownCadenceField = baseExhibit({
cadence: {
extraField: 123
}
});
const resField = validateExhibit(unknownCadenceField);
assert.ok(
resField.errors.some(e => e.code === 'ERR_UNKNOWN_FIELD'),
'Expected ERR_UNKNOWN_FIELD on cadence.extraField'
);
// Unknown clock class rejected with ERR_UNKNOWN_FIELD
const unknownClock = baseExhibit({
cadence: {
clocks: {
unknownClass: { min: '1s', max: '2s' }
}
}
});
const resClock = validateExhibit(unknownClock);
assert.ok(
resClock.errors.some(e => e.code === 'ERR_UNKNOWN_FIELD'),
'Expected ERR_UNKNOWN_FIELD on unknown clock class'
);
// Negative sound cadence weight rejected with ERR_OUT_OF_BOUNDS
const negativeWeight = baseExhibit();
negativeWeight.sounds.click.cadence.weight = -1;
const resWeight = validateExhibit(negativeWeight);
assert.ok(
resWeight.errors.some(e => e.code === 'ERR_OUT_OF_BOUNDS'),
'Expected ERR_OUT_OF_BOUNDS on negative weight'
);
});
// ---------------------------------------------------------------------------
// Trace 2: Static Event Cycle Detection (Spec 20.15 Trace 2)
// ---------------------------------------------------------------------------
test('Trace 2: Static cycle detection rejects circular event action chains', () => {
// Self-cycle
const selfCycle = baseExhibit({
events: {
loop: {
actions: [{ type: 'event', event: 'loop' }]
}
}
});
const resSelf = validateExhibit(selfCycle);
assert.ok(
resSelf.errors.some(e => e.code === 'ERR_CYCLIC_DEPENDENCY'),
'Expected ERR_CYCLIC_DEPENDENCY for self-cycle'
);
// Mutual cycle A -> B -> A
const mutualCycle = baseExhibit({
events: {
eventA: {
actions: [{ type: 'event', event: 'eventB' }]
},
eventB: {
actions: [{ type: 'event', event: 'eventA' }]
}
}
});
const resMutual = validateExhibit(mutualCycle);
assert.ok(
resMutual.errors.some(e => e.code === 'ERR_CYCLIC_DEPENDENCY'),
'Expected ERR_CYCLIC_DEPENDENCY for mutual cycle'
);
// Clean DAG: A -> B, A -> C, B -> C
const cleanDAG = baseExhibit({
events: {
eventA: {
actions: [
{ type: 'event', event: 'eventB' },
{ type: 'event', event: 'eventC' }
]
},
eventB: {
actions: [{ type: 'event', event: 'eventC' }]
},
eventC: {
actions: [{ type: 'sound', sound: 'click' }]
}
}
});
const resDAG = validateExhibit(cleanDAG);
assert.ok(
!resDAG.errors.some(e => e.code === 'ERR_CYCLIC_DEPENDENCY'),
'Valid DAG must not produce ERR_CYCLIC_DEPENDENCY'
);
});
// ---------------------------------------------------------------------------
// Trace 3: Event Scoping, Defaults, and Type Enforcement (Spec 20.15 Trace 3)
// ---------------------------------------------------------------------------
test('Trace 3: Event input scoping, defaults application, and strict type checking', () => {
const doc = baseExhibit({
events: {
triggerSound: {
inputs: {
gain: { type: 'number', default: 0.5, min: 0, max: 1 },
tag: { type: 'string', default: 'normal' }
},
actions: [
{ type: 'sound', sound: 'click' }
]
}
}
});
const rng = new SeededRNG(12345);
const engine = new ResolutionEngine(doc, rng);
const diagnostics = {
errors: [],
error(code, msg) { this.errors.push({ code, msg }); }
};
const executor = new ActionExecutor(engine, { diagnostics });
// 1. Invoke event with defaults
const result1 = executor.executeAction({
type: 'event',
event: 'triggerSound'
});
assert.equal(result1, true, 'Event with default inputs should execute successfully');
assert.equal(diagnostics.errors.length, 0);
// 2. Invoke event with explicit valid inputs
const result2 = executor.executeAction({
type: 'event',
event: 'triggerSound',
with: { gain: 0.8, tag: 'loud' }
});
assert.equal(result2, true, 'Event with explicit valid inputs should execute');
// 3. Invoke event with undeclared input -> ERR_UNKNOWN_FIELD
executor.executeAction({
type: 'event',
event: 'triggerSound',
with: { undeclaredInput: 42 }
});
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_UNKNOWN_FIELD'),
'Expected ERR_UNKNOWN_FIELD on undeclared input'
);
// 4. Invoke event with type mismatch (string for number) -> ERR_TYPE_MISMATCH
diagnostics.errors.length = 0;
executor.executeAction({
type: 'event',
event: 'triggerSound',
with: { gain: 'not-a-number' }
});
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_TYPE_MISMATCH'),
'Expected ERR_TYPE_MISMATCH on invalid input type'
);
});
// ---------------------------------------------------------------------------
// Trace 4: Event Recursion Depth Limit (Spec 20.15 Trace 4)
// ---------------------------------------------------------------------------
test('Trace 4: Dynamic event nesting exceeding 16 levels is halted', () => {
// Build a chain of 18 distinct events: e0 -> e1 -> e2 ... -> e17
const events = {};
for (let i = 0; i < 18; i++) {
events[`e${i}`] = {
actions: i < 17 ? [{ type: 'event', event: `e${i + 1}` }] : [{ type: 'sound', sound: 'click' }]
};
}
const doc = baseExhibit({ events });
const rng = new SeededRNG(42);
const engine = new ResolutionEngine(doc, rng);
const diagnostics = {
errors: [],
error(code, msg) { this.errors.push({ code, msg }); }
};
const executor = new ActionExecutor(engine, { diagnostics });
const result = executor.executeAction({ type: 'event', event: 'e0' });
assert.equal(result, false, 'Chain exceeding 16 levels should fail');
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_DISPATCH_BUDGET'),
'Expected ERR_DISPATCH_BUDGET for depth > 16'
);
});
// ---------------------------------------------------------------------------
// Trace 5: Action Dispatch Budget (1024 units/tick) (Spec 20.15 Trace 5)
// ---------------------------------------------------------------------------
test('Trace 5: Per-tick 1024-unit action dispatch budget is strictly enforced', () => {
// Create an event that executes 1100 sound actions
const actions = [];
for (let i = 0; i < 1100; i++) {
actions.push({ type: 'sound', sound: 'click' });
}
const doc = baseExhibit({
events: {
burst: { actions }
}
});
const rng = new SeededRNG(42);
const engine = new ResolutionEngine(doc, rng);
const diagnostics = {
errors: [],
error(code, msg) { this.errors.push({ code, msg }); }
};
const executor = new ActionExecutor(engine, { diagnostics });
executor.executeAction({ type: 'event', event: 'burst' });
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_DISPATCH_BUDGET'),
'Expected ERR_DISPATCH_BUDGET when tick actions exceed 1024'
);
assert.equal(executor.tickActionCount, 1024, 'Action count should cap at 1024');
// Reset for next tick allows new actions
executor.resetTickBudget();
assert.equal(executor.tickActionCount, 0);
});
// ---------------------------------------------------------------------------
// Trace 6: Sound Action Usage Gating (Spec 20.15 Trace 6)
// ---------------------------------------------------------------------------
test('Trace 6: Sound actions targeting unauthorized sounds are refused with ERR_UNSUPPORTED_TARGET', () => {
const doc = baseExhibit({
sounds: {
autoOnly: {
name: 'Auto Only',
usage: ['automatic'],
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
},
scenarioOnly: {
name: 'Scenario Sound',
usage: ['scenario'],
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
},
manualOnly: {
name: 'Manual Sound',
usage: ['manual'],
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
},
dualUsage: {
name: 'Dual Usage Sound',
usage: ['manual', 'scenario'],
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
}
}
});
const rng = new SeededRNG(42);
const engine = new ResolutionEngine(doc, rng);
const fakeAudio = new FakeAudioSubsystem();
engine.audio = fakeAudio;
const diagnostics = {
errors: [],
error(code, msg) { this.errors.push({ code, msg }); }
};
const executor = new ActionExecutor(engine, { diagnostics });
// 1. Target autoOnly from default (manual) context -> fail with ERR_UNSUPPORTED_TARGET
const res1 = executor.executeAction({ type: 'sound', sound: 'autoOnly' });
assert.equal(res1, false);
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'),
'Expected ERR_UNSUPPORTED_TARGET for automatic sound invoked from manual context'
);
// 2. Target scenarioOnly from manual context -> must FAIL with ERR_UNSUPPORTED_TARGET (F2 fix)
diagnostics.errors.length = 0;
const res2 = executor.executeAction({ type: 'sound', sound: 'scenarioOnly' });
assert.equal(res2, false);
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'),
'Expected ERR_UNSUPPORTED_TARGET for scenario sound invoked from manual context'
);
// 3. Target scenarioOnly from scenario context -> SUCCEEDS
diagnostics.errors.length = 0;
const res3 = executor.executeAction({ type: 'sound', sound: 'scenarioOnly' }, { usage: 'scenario' });
assert.equal(res3, true);
assert.equal(diagnostics.errors.length, 0);
assert.equal(fakeAudio.played.length, 1);
assert.equal(fakeAudio.played[0].soundId, 'scenarioOnly');
// 4. Target manualOnly from scenario context -> must FAIL with ERR_UNSUPPORTED_TARGET
diagnostics.errors.length = 0;
const res4 = executor.executeAction({ type: 'sound', sound: 'manualOnly' }, { usage: 'scenario' });
assert.equal(res4, false);
assert.ok(
diagnostics.errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'),
'Expected ERR_UNSUPPORTED_TARGET for manual sound invoked from scenario context'
);
// 5. Target manualOnly from manual context -> SUCCEEDS
diagnostics.errors.length = 0;
const res5 = executor.executeAction({ type: 'sound', sound: 'manualOnly' });
assert.equal(res5, true);
assert.equal(fakeAudio.played.length, 2);
assert.equal(fakeAudio.played[1].soundId, 'manualOnly');
// 6. Target dualUsage from both manual and scenario contexts -> SUCCEEDS in both
const res6a = executor.executeAction({ type: 'sound', sound: 'dualUsage' });
const res6b = executor.executeAction({ type: 'sound', sound: 'dualUsage' }, { usage: 'scenario' });
assert.equal(res6a, true);
assert.equal(res6b, true);
assert.equal(fakeAudio.played.length, 4);
});
// ---------------------------------------------------------------------------
// Trace 7: Sound Cooldown Enforcement (Spec 20.15 Trace 7)
// ---------------------------------------------------------------------------
test('Trace 7: Sound cooldown refrains from re-dispatching before interval expires', () => {
const doc = baseExhibit({
cadence: {
minGap: '1.5s'
},
sounds: {
cooldowned: {
name: 'Cooldowned Sound',
usage: ['automatic'],
cadence: { class: 'routine', cooldown: '5s' },
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
}
}
});
const rng = new SeededRNG(42);
const fakeAudio = new FakeAudioSubsystem();
const cadence = new CadenceSubsystem({
document: doc,
rng,
audio: fakeAudio
});
// Schedule through production scheduler advance/update at t = 0
cadence.classes.routine.due = true;
cadence.update(0);
assert.equal(fakeAudio.played.length, 1, 'Should fire at t = 0');
assert.equal(fakeAudio.played[0].soundId, 'cooldowned');
// Attempt at t = 2000ms (minGap 1.5s elapsed, but < 5000ms cooldown) -> refused
cadence.classes.routine.due = true;
cadence.update(2000);
assert.equal(fakeAudio.played.length, 1, 'Should be refused at t = 2000ms due to cooldown');
// Attempt at t = 5000ms (>= 5000ms cooldown) -> accepted
cadence.classes.routine.due = true;
cadence.update(5000);
assert.equal(fakeAudio.played.length, 2, 'Should fire at t = 5000ms after cooldown');
assert.equal(fakeAudio.played[1].soundId, 'cooldowned');
});
// ---------------------------------------------------------------------------
// Trace 8: Overlap Refusal (Spec 20.15 Trace 8)
// ---------------------------------------------------------------------------
test('Trace 8: Overlap false prevents new instance while existing voice is active', () => {
const doc = baseExhibit({
cadence: {
minGap: '1.5s'
},
sounds: {
noOverlap: {
name: 'No Overlap',
usage: ['automatic'],
cadence: { class: 'routine', overlap: false },
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
}
}
});
const rng = new SeededRNG(42);
const fakeAudio = new FakeAudioSubsystem();
const cadence = new CadenceSubsystem({
document: doc,
rng,
audio: fakeAudio
});
// First trigger creates active voice through advance/update
cadence.classes.routine.due = true;
cadence.update(0);
assert.equal(fakeAudio.played.length, 1);
assert.equal(fakeAudio.activeInstances.size, 1);
// Second trigger at t = 2000ms (minGap 1.5s elapsed, but previous voice still active) -> refused
cadence.classes.routine.due = true;
cadence.update(2000);
assert.equal(fakeAudio.played.length, 1, 'Must refuse trigger when previous voice is still active');
// Stop active voice
const activeVoice = Array.from(fakeAudio.activeInstances.values())[0];
activeVoice.stop();
assert.equal(fakeAudio.getActiveSounds().length, 0);
// Third trigger at t = 4000ms after voice stopped -> accepted
cadence.classes.routine.due = true;
cadence.update(4000);
assert.equal(fakeAudio.played.length, 2, 'Must accept trigger once active voice has stopped');
assert.equal(fakeAudio.played[1].soundId, 'noOverlap');
});
// ---------------------------------------------------------------------------
// Trace 9: Anti-Repetition Multipliers (Spec 20.15 Trace 9)
// ---------------------------------------------------------------------------
test('Trace 9: Anti-repetition penalties apply 0.0, 0.25, 0.50, 0.75 recency multipliers', () => {
const doc = baseExhibit({
cadence: {
minGap: '1.5s'
},
sounds: {
s1: { name: 'S1', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } },
s2: { name: 'S2', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } },
s3: { name: 'S3', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } },
s4: { name: 'S4', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } },
s5: { name: 'S5', usage: ['automatic'], cadence: { class: 'routine', weight: 1.0 }, bus: 'effects', recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] } }
}
});
const rng = new SeededRNG(42);
const fakeAudio = new FakeAudioSubsystem();
const cadence = new CadenceSubsystem({
document: doc,
rng,
audio: fakeAudio
});
// Set class recency queue reflecting firings: s1, then s2, then s3, then s4
// Recency queue ordering (front is most recent):
// s4 fired 1 selection ago (rank 0 -> multiplier 0.0)
// s3 fired 2 selections ago (rank 1 -> multiplier 0.25)
// s2 fired 3 selections ago (rank 2 -> multiplier 0.50)
// s1 fired 4 selections ago (rank 3 -> multiplier 0.75)
// s5 not in recency queue (multiplier 1.0)
cadence.classes.routine.recencyHistory = ['s4', 's3', 's2', 's1'];
// Trigger through production scheduler path (advance/update -> fireClass)
cadence.classes.routine.due = true;
cadence.update(2000);
// Inspect the candidate pool evaluated by fireClass
const pool = cadence.getLastEvaluatedPool('routine');
const byId = Object.fromEntries(pool.map(p => [p.id, p.effectiveWeight]));
assert.equal(byId.s4, 0.0, 'Rank 0 must have multiplier 0.0');
assert.equal(byId.s3, 0.25, 'Rank 1 must have multiplier 0.25');
assert.equal(byId.s2, 0.50, 'Rank 2 must have multiplier 0.50');
assert.equal(byId.s1, 0.75, 'Rank 3 must have multiplier 0.75');
assert.equal(byId.s5, 1.00, 'Unfired must have multiplier 1.0');
// Verify FIFO recency queue update (§20.5.9 / F5):
// Winning sound was appended to front, and queue trimmed to depth 4
assert.equal(cadence.classes.routine.recencyHistory.length, 4);
assert.equal(cadence.classes.routine.recencyHistory[0], fakeAudio.played[0].soundId);
// Verify FIFO without dedup (F5): repeated selections occupy multiple slots
cadence.classes.routine.recencyHistory = ['s1', 's2', 's1'];
assert.equal(cadence.classes.routine.recencyHistory[0], 's1');
assert.equal(cadence.classes.routine.recencyHistory[2], 's1');
});
// ---------------------------------------------------------------------------
// Trace 10: Anti-Repetition Relaxation (Spec 20.15 Trace 10)
// ---------------------------------------------------------------------------
test('Trace 10: Anti-repetition relaxation to base weights when all candidates are penalized to zero', () => {
const doc = baseExhibit({
cadence: {
minGap: '1.5s'
},
sounds: {
solo: {
name: 'Solo Routine',
usage: ['automatic'],
cadence: { class: 'routine', weight: 1.5 },
bus: 'effects',
recipe: {
mode: 'oneshot',
nodes: { imp: { type: 'impulse' } },
routes: [{ from: 'imp', to: 'output' }]
}
}
}
});
const rng = new SeededRNG(42);
const fakeAudio = new FakeAudioSubsystem();
const cadence = new CadenceSubsystem({
document: doc,
rng,
audio: fakeAudio
});
// First firing: solo is selected and plays
cadence.classes.routine.due = true;
cadence.update(0);
assert.equal(fakeAudio.played.length, 1);
assert.equal(fakeAudio.played[0].soundId, 'solo');
assert.deepEqual(cadence.classes.routine.recencyHistory, ['solo']);
// Next evaluation at t = 2000ms: solo was most recently played (rank 0, multiplier 0.0).
// Because it is the only candidate in the pool, total effective weight would be 0 without relaxation.
// fireClass must relax anti-repetition penalties to base weights (1.5) and fire!
cadence.classes.routine.due = true;
cadence.update(2000);
assert.equal(fakeAudio.played.length, 2, 'Solo sound must fire again after relaxation');
assert.equal(fakeAudio.played[1].soundId, 'solo');
// Verify that the evaluated pool in fireClass had effectiveWeight relaxed to 1.5
const pool = cadence.getLastEvaluatedPool('routine');
assert.equal(pool.length, 1);
assert.equal(pool[0].effectiveWeight, 1.5, 'Relaxation must restore base weight so pool never deadlocks');
});
// ---------------------------------------------------------------------------
// Trace 11: Intensity Scaling (Spec 20.15 Trace 11)
// ---------------------------------------------------------------------------
test('Trace 11: Intensity scaling inversely compresses or expands clock intervals', () => {
const doc = baseExhibit({
cadence: {
clocks: {
routine: { min: '10s', max: '20s' }
}
}
});
const rng = new SeededRNG(42);
const cadence = new CadenceSubsystem({ document: doc, rng });
// Baseline at intensity 1.0: 10s - 20s
cadence.setIntensity(1.0);
const range1 = cadence.getEffectiveInterval('routine');
assert.equal(range1.min, 10000);
assert.equal(range1.max, 20000);
// High intensity 2.0: intervals halved (5s - 10s)
cadence.setIntensity(2.0);
const range2 = cadence.getEffectiveInterval('routine');
assert.equal(range2.min, 5000);
assert.equal(range2.max, 10000);
// Low intensity 0.5: intervals doubled (20s - 40s)
cadence.setIntensity(0.5);
const range05 = cadence.getEffectiveInterval('routine');
assert.equal(range05.min, 20000);
assert.equal(range05.max, 40000);
});
// ---------------------------------------------------------------------------
// Trace 12: Minimum Gap and Priority Order (Spec 20.15 Trace 12)
// ---------------------------------------------------------------------------
test('Trace 12: Minimum gap (1.5s) and priority ordering (rare > occasional > intermittent > routine)', () => {
const doc = baseExhibit({
cadence: {
minGap: '1.5s'
},
sounds: {
rareSound: {
name: 'Rare',
usage: ['automatic'],
cadence: { class: 'rare' },
bus: 'effects',
recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] }
},
routineSound: {
name: 'Routine',
usage: ['automatic'],
cadence: { class: 'routine' },
bus: 'effects',
recipe: { mode: 'oneshot', nodes: { i: { type: 'impulse' } }, routes: [{ from: 'i', to: 'output' }] }
}
}
});
const rng = new SeededRNG(42);
const fakeAudio = new FakeAudioSubsystem();
const cadence = new CadenceSubsystem({
document: doc,
rng,
audio: fakeAudio
});
// Force both rare and routine clocks to be due at t = 1000ms
cadence.classes.rare.due = true;
cadence.classes.routine.due = true;
// Run update at t = 1000ms
cadence.update(1000);
// Only the higher priority sound ('rare') should have fired due to minGap refusal
assert.equal(fakeAudio.played.length, 1);
assert.equal(fakeAudio.played[0].soundId, 'rareSound', 'Rare sound must fire ahead of routine sound');
// Advance by 500ms (< 1500ms minGap): routine must still NOT fire
cadence.update(1500);
assert.equal(fakeAudio.played.length, 1, 'Routine sound must not fire before minGap');
// Advance to t = 2500ms (1000 + 1500 = 2500ms minGap elapsed): routine can now fire
cadence.update(2500);
assert.equal(fakeAudio.played.length, 2);
assert.equal(fakeAudio.played[1].soundId, 'routineSound', 'Routine sound fires after minGap elapsed');
});
// ---------------------------------------------------------------------------
// Trace 13: Ambient Voice Maintenance (Spec 20.15 Trace 13)
// ---------------------------------------------------------------------------
test('Trace 13: Continuous ambient sounds auto-start and maintain exactly one active voice', () => {
const doc = baseExhibit({
sounds: {
ambientHum: {
name: 'Ambient Hum',
usage: ['automatic'],
cadence: { class: 'ambient' },
bus: 'ambient',
recipe: {
mode: 'continuous',
nodes: { osc: { type: 'oscillator' } },
routes: [{ from: 'osc', to: 'output' }]
}
}
}
});
const rng = new SeededRNG(42);
const fakeAudio = new FakeAudioSubsystem();
const cadence = new CadenceSubsystem({
document: doc,
rng,
audio: fakeAudio
});
// Tick 1 at t = 0: auto-starts ambient sound
cadence.update(0);
assert.equal(fakeAudio.played.length, 1);
assert.equal(fakeAudio.played[0].soundId, 'ambientHum');
// Tick 2 at t = 100: must NOT spawn a second duplicate voice
cadence.update(100);
assert.equal(fakeAudio.played.length, 1, 'Should maintain existing ambient voice without duplicating');
// If ambient voice is stopped externally, next update restarts it
const ambientVoice = fakeAudio.played[0].voice;
ambientVoice.stop();
cadence.update(200);
assert.equal(fakeAudio.played.length, 2, 'Ambient voice should restart when previous instance stopped');
});
// ---------------------------------------------------------------------------
// Trace 14: Manual SAMPLE PRNG Stream Isolation (Spec 20.15 Trace 14)
// ---------------------------------------------------------------------------
test('Trace 14: Manual SAMPLE evaluations run in isolated stream without perturbing cadence scheduling', () => {
const doc = baseExhibit();
// Run 1: Normal cadence updates
const rng1 = new SeededRNG(9999);
const fakeAudio1 = new FakeAudioSubsystem();
const cadence1 = new CadenceSubsystem({
document: doc,
rng: rng1,
audio: fakeAudio1
});
for (let t = 0; t <= 100_000; t += 1000) {
cadence1.update(t);
}
// Run 2: Same seed, but with intermediate manual-sample PRNG draws
const rng2 = new SeededRNG(9999);
const fakeAudio2 = new FakeAudioSubsystem();
const cadence2 = new CadenceSubsystem({
document: doc,
rng: rng2,
audio: fakeAudio2
});
for (let t = 0; t <= 100_000; t += 1000) {
// Inject manual SAMPLE operations on isolated stream
rng2.stream('manual-sample', 'button1').nextFloat();
rng2.stream('sample', 'button2').nextFloat();
cadence2.update(t);
}
// The sequence of fired sounds and timestamps must match exactly
assert.equal(fakeAudio1.played.length, fakeAudio2.played.length, 'Playback counts must match');
for (let i = 0; i < fakeAudio1.played.length; i++) {
assert.equal(
fakeAudio1.played[i].soundId,
fakeAudio2.played[i].soundId,
`Sound ${i} ID mismatch`
);
assert.equal(
fakeAudio1.played[i].time,
fakeAudio2.played[i].time,
`Sound ${i} timestamp mismatch`
);
}
});