feat(scenario): implement the phase 6 scenario director

Add Format Specification section 21 (Scenario Model 0.1) and the runtime
that realizes it: the scenario director, seven trigger classes, timelines
with repeats and branches, admission and concurrency control, nested
ownership with bounded cleanup, and the production GC5 resource counters.

Exhibit E provides a reproducible forty-minute long-scenario reference and
scenario-challenge.xzbt covers the PRD 131 cases. A standalone
acceptance/soak page is built by tools/build-scenario-acceptance.mjs.

252 automated checks pass. The two-hour real-duration development soak
required by the GC6 schedule remains pending.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
This commit is contained in:
2026-09-06 22:54:31 +00:00
co-authored by Claude Opus 5
parent c4332363a9
commit 1cde2f9f68
29 changed files with 15553 additions and 54 deletions
+44
View File
@@ -0,0 +1,44 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bundleRuntime } from './build-xzbt.mjs';
import { longScenarioExhibit, scenarioChallenge } from './scenario-fixtures.mjs';
import { validateExhibit } from '../src/runtime/validator.js';
export function buildScenarioAcceptance() {
const root = fileURLToPath(new URL('..', import.meta.url));
const workload = structuredClone(longScenarioExhibit);
workload.meta = { id: 'phase6-soak', name: 'Phase 6 development soak', version: '1.0.0' };
workload.runtime.seed = 4206;
workload.scenarios.pulse = { trigger: { type: 'interval', every: '20s' }, duration: '5s', onStart: [
{ type: 'event', event: 'signal' }, { type: 'sound', sound: 'drone' },
{ type: 'override', target: 'parameters.activity', value: 0.9, scope: 'scenario', transition: { out: '2s' } }
], timeline: [] };
const fixtures = [longScenarioExhibit, scenarioChallenge, workload];
mkdirSync(resolve(root, 'prototypes/phase6'), { recursive: true });
for (const document of fixtures) {
const result = validateExhibit(document);
if (!result.valid) throw new Error(JSON.stringify(result.errors));
writeFileSync(resolve(root, document === workload ? 'prototypes/phase6/workload-v1.xzbt' : `exhibits/${document.meta.id}.xzbt`), JSON.stringify(document, null, 2) + '\n');
}
const runtime = bundleRuntime(false);
const data = JSON.stringify(fixtures).replace(/</g, '\\u003c');
const identity = JSON.stringify({ workloadSha256: createHash('sha256').update(JSON.stringify(workload)).digest('hex'), runtimeSha256: createHash('sha256').update(runtime).digest('hex') });
const script = readFileSync(resolve(root, 'prototypes/phase6/acceptance.js'), 'utf8');
const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>XZBT Scenario Acceptance</title><style>
body{margin:0;background:#101d25;color:#e4efed;font:15px system-ui}main{max-width:1200px;margin:auto;padding:24px}h1{font-size:24px}button,select,textarea{font:inherit;background:#203b45;color:inherit;border:1px solid #6c9999;border-radius:5px;padding:9px;margin:4px}button:disabled{opacity:.45}canvas{width:100%;height:52vh;display:block;background:#101d25}#scenarios{display:flex;flex-wrap:wrap;gap:12px}article{border:1px solid #48636a;padding:12px}output,pre{font:13px ui-monospace;white-space:pre-wrap}textarea{display:block;width:95%;min-height:55px}label{display:block;margin-top:10px}.hint{color:#b5cbc7}
</style><main><h1>Scenario acceptance / XZBT 0.1</h1>
<p class="hint">Start or cancel a scenario, inspect its status, or run the frozen two-hour development workload. Acceleration verifies logical behavior; the soak requires real elapsed time and audio output.</p>
<select id="fixture" aria-label="Exhibit"></select><button id="restart">Restart</button><button id="audio">Start audio</button><button id="pause">Pause</button><button id="accelerate">Advance 40 logical minutes</button><button id="soak">Start two-hour soak</button><button id="export">Export evidence</button>
<canvas id="canvas" aria-label="Scenario visuals"></canvas><div id="scenarios"></div>
<button id="event">Invoke request event</button><button id="condition">Set condition true</button>
<p><output id="status" aria-live="polite"></output></p><pre id="counters"></pre>
<label>Reference computer: CPU, GPU/driver, RAM, OS, power mode<textarea id="environment"></textarea></label>
<label>Listening and display observations, including any clicks, clipping or glitches<textarea id="observations"></textarea></label>
<pre id="diagnostics"></pre></main><script>'use strict'; (() => {${runtime}\nconst scenarioFixtures = ${data}; const scenarioBuild = ${identity};\n${script}\n})();</script></html>`;
const path = resolve(root, 'prototypes/phase6/XZBT-scenario-acceptance.html');
writeFileSync(path, html); return { path, html, fixtures };
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) console.log(buildScenarioAcceptance().path);
+2
View File
@@ -27,6 +27,7 @@ const sourceFiles = Object.freeze([
'src/runtime/visual-systems.js',
'src/runtime/visual-validation.js',
'src/runtime/cadence-validation.js',
'src/runtime/scenario-validation.js',
'src/runtime/validator.js',
'src/runtime/persistence.js',
'src/runtime/library.js',
@@ -40,6 +41,7 @@ const sourceFiles = Object.freeze([
'src/runtime/visual-subsystem.js',
'src/runtime/actions.js',
'src/runtime/cadence.js',
'src/runtime/scenario.js',
'src/runtime/performance.js',
'src/runtime/activation.js',
'src/runtime/audio-engine.js',
+85
View File
@@ -0,0 +1,85 @@
const mutation = (target, value) => ({ type: 'set', target, value });
const addOne = target => mutation(target, { op: 'add', args: [{ ref: target }, 1] });
const override = (target, value, scope = 'scenario') => ({ type: 'override', target, value, scope, transition: { in: '1s', out: '2s', easing: 'ease-in-out' } });
export function scenarioAudio() {
return {
audio: { buses: { ambient: { gain: 0.5 }, effects: { gain: 0.5 } } },
sounds: {
bed: { name: 'Distant hum', bus: 'ambient', usage: ['automatic'], cadence: { class: 'ambient' }, recipe: {
mode: 'continuous', release: '1s', nodes: { tone: { type: 'oscillator', frequency: 55 }, trim: { type: 'gain', gain: 0.04 } }, routes: [{ from: 'tone', to: 'trim' }, { from: 'trim', to: 'output' }]
} },
drone: { name: 'Passing resonance', bus: 'ambient', usage: ['scenario'], recipe: {
mode: 'continuous', release: '2s', nodes: { tone: { type: 'oscillator', frequency: 165 }, trim: { type: 'gain', gain: 0.035 } }, routes: [{ from: 'tone', to: 'trim' }, { from: 'trim', to: 'output' }]
} },
beat: { name: 'Soft impulse', bus: 'effects', usage: ['automatic', 'scenario', 'manual'], cadence: { class: 'routine', overlap: false, cooldown: '3s' }, recipe: {
mode: 'oneshot', release: '50ms', nodes: { hit: { type: 'impulse', duration: '8ms', amplitude: 0.06 } }, routes: [{ from: 'hit', to: 'output' }]
} }
}
};
}
export function withTemporaryScenario(document) {
if (!['exhibit-a', 'exhibit-b'].includes(document.meta.id)) return document;
const result = structuredClone(document);
result.meta.description = 'Procedural audiovisual reference with cadence and a temporary scenario override; final acceptance remains Phase 9.';
result.parameters = { ...result.parameters, activity: { type: 'number', default: 0.4, min: 0, max: 1, label: 'Activity' } };
result.bindings = [...(result.bindings ?? []), { source: 'parameters.activity', target: 'visuals.camera.zoom', scale: 0.25, offset: 1 }];
result.cadence = { intensity: { ref: 'parameters.activity' } };
Object.assign(result, scenarioAudio());
result.scenarios = { disturbance: { name: 'Temporary disturbance', trigger: { type: 'random-interval', min: '30s', max: '90s' },
duration: '12s', cooldown: '20s', priority: 70, onStart: [override('parameters.activity', 0.95), { type: 'sound', sound: 'drone' }], timeline: [] } };
return result;
}
export const longScenarioExhibit = {
xzbt: '0.1', meta: { id: 'exhibit-e', name: 'Exhibit E — Long Passage', version: '0.1.0', description: 'A forty-minute procedural passage: approach, repeated signals, branching disturbance, and recovery.' },
runtime: { seed: 42 }, parameters: { activity: { type: 'number', default: 0.35, min: 0, max: 1, label: 'Activity' } },
state: { mode: { type: 'string', initial: 'normal' }, beats: { type: 'integer', initial: 0 }, journeys: { type: 'integer', initial: 0 } },
...scenarioAudio(), cadence: { intensity: { ref: 'parameters.activity' }, minGap: '1500ms' },
bindings: [{ source: 'parameters.activity', target: 'visuals.camera.zoom', scale: 0.2, offset: 1 }],
visuals: { scene: { coordinateSpace: 'virtual', width: 960, height: 540, background: '#101d25' }, systems: {
horizon: { type: 'graphic', content: {
ring: { type: 'ring', radius: 150, innerRadius: 148, position: { x: 480, y: 270 }, style: { fill: '#80beb5' } },
line: { type: 'line', position: { x: 240, y: 270 }, to: { x: 480, y: 0 }, style: { stroke: '#dfc18d', strokeWidth: 2 } }
} },
signal: { type: 'graphic', lifecycle: 'spawned', spawn: { lifetime: '4s', release: '1s' }, content: {
ring: { type: 'ring', radius: 165, innerRadius: 160, position: { x: 480, y: 270 }, style: { fill: '#efbe79' }, behaviors: [{ type: 'rotate', speed: 12 }] }
} }
} },
events: {
signal: { actions: [addOne('state.beats'), { type: 'sound', sound: 'beat' }, { type: 'spawn', target: 'visuals.systems.signal' }] },
recover: { actions: [mutation('state.mode', 'normal')] }
},
scenarios: {
journey: { name: 'Long passage', priority: 70, group: 'passage', trigger: { type: 'random-interval', min: '45m', max: '60m' },
concurrency: { mode: 'exclusive', scope: 'group', policy: 'defer' }, duration: '40m', cooldown: '5m',
onStart: [addOne('state.journeys'), mutation('state.mode', 'approach'), { type: 'sound', sound: 'drone' }, override('parameters.activity', 0.7)],
timeline: [
{ id: 'arrival', at: '1m', actions: [{ type: 'event', event: 'signal' }] },
{ after: 'arrival', delay: { random: { min: '20s', max: '50s' } }, repeat: { count: 12, every: { random: { min: '1m', max: '2m' } } }, actions: [{ type: 'event', event: 'signal' }] },
{ at: '15m', choose: [
{ weight: 3, actions: [mutation('state.mode', 'drift'), { ...override('parameters.activity', 0.5, 'duration'), duration: '5m' }] },
{ weight: 1, actions: [mutation('state.mode', 'disturbance'), { ...override('parameters.activity', 0.95, 'duration'), duration: '5m' }, { type: 'event', event: 'signal' }] }
] },
{ at: '30m', actions: [{ type: 'event', event: 'recover' }] },
{ at: '40m', actions: [mutation('state.mode', 'normal')] }
], onComplete: [mutation('state.mode', 'normal')], onCancel: [mutation('state.mode', 'normal')]
}
}
};
export const scenarioChallenge = {
...structuredClone(longScenarioExhibit),
meta: { id: 'scenario-challenge', name: 'Scenario Challenge', version: '1.0.0', description: 'Short, multi-minute, event, condition and concurrent scenario acceptance cases.' },
state: { ...longScenarioExhibit.state, armed: { type: 'boolean', initial: false } },
events: { ...longScenarioExhibit.events, request: { actions: [mutation('state.mode', 'requested')] } },
scenarios: {
brief: { name: 'Five seconds', duration: '5s', timeline: [{ at: '0ms', actions: [{ type: 'event', event: 'signal' }] }], onStart: [override('parameters.activity', 0.9)] },
minutes: { name: 'Three minutes', duration: '3m', timeline: [{ at: '10s', repeat: { count: 8, every: '20s' }, actions: [{ type: 'event', event: 'signal' }] }] },
condition: { name: 'Condition hold', trigger: { type: 'condition', when: { op: 'eq', left: { ref: 'state.armed' }, right: true }, for: '2s' }, duration: '5s', timeline: [], onStart: [override('parameters.activity', 0.6)] },
event: { name: 'Event trigger', trigger: { type: 'event', event: 'request' }, duration: '5s', timeline: [], onStart: [{ type: 'event', event: 'signal' }] },
exclusive: { name: 'Exclusive group', priority: 80, group: 'g', concurrency: { mode: 'exclusive', scope: 'group' }, duration: '10s', timeline: [], onStart: [{ type: 'sound', sound: 'drone' }] },
deferred: { name: 'Deferred group', priority: 50, group: 'g', concurrency: { mode: 'exclusive', scope: 'group', policy: 'defer' }, duration: '5s', timeline: [], onStart: [{ type: 'event', event: 'signal' }] }
}
};
+5
View File
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { validateAudioSubsystem } from '../src/runtime/audio-graph.js';
import { validateCadenceSubsystem } from '../src/runtime/cadence-validation.js';
import { validateScenarioSubsystem } from '../src/runtime/scenario-validation.js';
import { matchVisualTarget } from '../src/runtime/visual-contract.js';
import { validateVisualSubsystem } from '../src/runtime/visual-validation.js';
@@ -175,6 +176,10 @@ export class ExhibitValidator {
pushError: (errors, code, path, message) => errors.push({ code, path, message })
});
validateScenarioSubsystem(this.doc, this.errors, {
validateValueSpec: (unusedDocument, spec, path, unusedErrors, scope) => this.validateValueSpec(spec, path, scope),
validateCondition: (unusedDocument, cond, path) => this.validateConditionSpec(cond, path)
});
return this.getResult();
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Authored exhibit data only. Subject names occur here, never in renderer dispatch.
import { withTemporaryScenario } from './scenario-fixtures.mjs';
const random = (min, max) => ({ random: { min, max } });
const dot = (color = '#cfe8ff', size = 3) => ({ type: 'point', style: { fill: color, pointSize: size } });
const lineStyle = { stroke: '#82b7c7', strokeWidth: 2 };
@@ -87,5 +88,5 @@ export const visualExhibits = [
document.state = { level: { type: 'number', initial: 0.7, min: 0, max: 1 } };
document.bindings = [{ source: 'state.level', target: 'visuals.layers.display.opacity', scale: 0.7, offset: 0.3 }];
}
return { id, document };
return { id, document: withTemporaryScenario(document) };
});