feat(phase0): complete shared contracts and milestone plan
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Deterministic executable model for the XZBT 0.1 GC3 resolution contract.
|
||||
*
|
||||
* This is a Phase 0 contract oracle, not the production runtime resolver.
|
||||
*/
|
||||
|
||||
export function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function bindingValue(source, { scale = 1, offset = 0, clamp: limits } = {}) {
|
||||
let result = source * scale + offset;
|
||||
if (limits) result = clamp(result, limits[0], limits[1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function smoothBinding(previous, input, dtSeconds, tauSeconds) {
|
||||
if (tauSeconds === 0 || previous === undefined) return input;
|
||||
const alpha = 1 - Math.exp(-dtSeconds / tauSeconds);
|
||||
return previous + alpha * (input - previous);
|
||||
}
|
||||
|
||||
export function easingValue(name, t) {
|
||||
const bounded = clamp(t, 0, 1);
|
||||
switch (name) {
|
||||
case 'linear':
|
||||
return bounded;
|
||||
case 'ease-in':
|
||||
return bounded * bounded;
|
||||
case 'ease-out':
|
||||
return 1 - (1 - bounded) * (1 - bounded);
|
||||
case 'ease-in-out':
|
||||
return bounded < 0.5
|
||||
? 2 * bounded * bounded
|
||||
: 1 - ((-2 * bounded + 2) ** 2) / 2;
|
||||
default:
|
||||
throw new RangeError(`Unsupported easing: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function lerp(from, to, amount) {
|
||||
return from + (to - from) * amount;
|
||||
}
|
||||
|
||||
export function attackValue(origin, target, progress, easing = 'linear') {
|
||||
return lerp(origin, target, easingValue(easing, progress));
|
||||
}
|
||||
|
||||
export function releaseValue(releaseStart, currentLower, progress, easing = 'linear') {
|
||||
return lerp(currentLower, releaseStart, 1 - easingValue(easing, progress));
|
||||
}
|
||||
|
||||
export function selectWinningOverride(overrides) {
|
||||
return overrides
|
||||
.filter((override) => override.live !== false)
|
||||
.reduce((winner, candidate) => {
|
||||
if (!winner) return candidate;
|
||||
if (candidate.priority !== winner.priority) {
|
||||
return candidate.priority > winner.priority ? candidate : winner;
|
||||
}
|
||||
return candidate.activationSequence > winner.activationSequence ? candidate : winner;
|
||||
}, null);
|
||||
}
|
||||
|
||||
export function resolveNumericTarget({
|
||||
base,
|
||||
binding,
|
||||
automation,
|
||||
overrides = [],
|
||||
modulation = 0,
|
||||
safetyClamp = [-Infinity, Infinity]
|
||||
}) {
|
||||
const afterBinding = binding ?? base;
|
||||
const lower = automation ?? afterBinding;
|
||||
const winner = selectWinningOverride(overrides);
|
||||
const afterOverride = winner ? winner.value : lower;
|
||||
const beforeClamp = afterOverride + modulation;
|
||||
return {
|
||||
base,
|
||||
afterBinding,
|
||||
lower,
|
||||
winner: winner?.id ?? null,
|
||||
afterOverride,
|
||||
beforeClamp,
|
||||
resolved: clamp(beforeClamp, safetyClamp[0], safetyClamp[1])
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** Phase 0 executable contract model for XZBT 0.1 GC4. */
|
||||
|
||||
const UINT32_RANGE = 0x1_0000_0000;
|
||||
const STEP_MS = 1000 / 60;
|
||||
|
||||
function rotateLeft(value, count) {
|
||||
return ((value << count) | (value >>> (32 - count))) >>> 0;
|
||||
}
|
||||
|
||||
function multiply32(left, right) {
|
||||
return Math.imul(left, right) >>> 0;
|
||||
}
|
||||
|
||||
export function fnv1a32(text) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const byte of new TextEncoder().encode(text)) {
|
||||
hash ^= byte;
|
||||
hash = multiply32(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function splitMix32(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x9e3779b9) >>> 0;
|
||||
let value = state;
|
||||
value = multiply32(value ^ (value >>> 16), 0x21f0aaad);
|
||||
value = multiply32(value ^ (value >>> 15), 0x735a2d97);
|
||||
return (value ^ (value >>> 15)) >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveStreamState(rootSeed, domain, stableInstanceKey) {
|
||||
if (!Number.isInteger(rootSeed) || rootSeed < 0 || rootSeed >= UINT32_RANGE) {
|
||||
throw new RangeError('Root seed must be an unsigned 32-bit integer.');
|
||||
}
|
||||
const hash = fnv1a32(`xzbt-0.1\0${rootSeed}\0${domain}\0${stableInstanceKey}`);
|
||||
const expand = splitMix32(hash);
|
||||
const state = [expand(), expand(), expand(), expand()];
|
||||
if (state.every((word) => word === 0)) state[3] = 1;
|
||||
return state;
|
||||
}
|
||||
|
||||
export class Xoshiro128StarStar {
|
||||
constructor(state) {
|
||||
if (!Array.isArray(state) || state.length !== 4 || state.some((word) => !Number.isInteger(word))) {
|
||||
throw new TypeError('xoshiro128** state must contain four integer words.');
|
||||
}
|
||||
this.state = state.map((word) => word >>> 0);
|
||||
if (this.state.every((word) => word === 0)) throw new RangeError('xoshiro128** state cannot be all zero.');
|
||||
}
|
||||
|
||||
nextUint32() {
|
||||
const state = this.state;
|
||||
const result = multiply32(rotateLeft(multiply32(state[1], 5), 7), 9);
|
||||
const temporary = (state[1] << 9) >>> 0;
|
||||
state[2] ^= state[0];
|
||||
state[3] ^= state[1];
|
||||
state[1] ^= state[2];
|
||||
state[0] ^= state[3];
|
||||
state[2] ^= temporary;
|
||||
state[3] = rotateLeft(state[3], 11);
|
||||
for (let index = 0; index < 4; index++) state[index] >>>= 0;
|
||||
return result >>> 0;
|
||||
}
|
||||
|
||||
nextFloat() {
|
||||
return this.nextUint32() / UINT32_RANGE;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRandomStream(rootSeed, domain, stableInstanceKey) {
|
||||
return new Xoshiro128StarStar(deriveStreamState(rootSeed, domain, stableInstanceKey));
|
||||
}
|
||||
|
||||
export class FixedStepClock {
|
||||
constructor({ stepMs = STEP_MS, maxTicksPerTurn = 8, maxElapsedMs = 250 } = {}) {
|
||||
this.stepMs = stepMs;
|
||||
this.maxTicksPerTurn = maxTicksPerTurn;
|
||||
this.maxElapsedMs = maxElapsedMs;
|
||||
this.tickIndex = 0;
|
||||
this.accumulatorMs = 0;
|
||||
this.lastNowMs = undefined;
|
||||
this.pauseReasons = new Set();
|
||||
}
|
||||
|
||||
setPauseReason(reason, active, nowMs) {
|
||||
const wasPaused = this.pauseReasons.size > 0;
|
||||
if (active) this.pauseReasons.add(reason);
|
||||
else this.pauseReasons.delete(reason);
|
||||
const isPaused = this.pauseReasons.size > 0;
|
||||
if (wasPaused !== isPaused) this.lastNowMs = nowMs;
|
||||
}
|
||||
|
||||
advance(nowMs) {
|
||||
if (this.lastNowMs === undefined) {
|
||||
this.lastNowMs = nowMs;
|
||||
return { ticks: [], stalled: false };
|
||||
}
|
||||
if (this.pauseReasons.size > 0) {
|
||||
this.lastNowMs = nowMs;
|
||||
return { ticks: [], stalled: false };
|
||||
}
|
||||
|
||||
const observed = Math.max(0, nowMs - this.lastNowMs);
|
||||
this.lastNowMs = nowMs;
|
||||
const accepted = Math.min(observed, this.maxElapsedMs);
|
||||
this.accumulatorMs += accepted;
|
||||
const available = Math.floor((this.accumulatorMs + 1e-9) / this.stepMs);
|
||||
const count = Math.min(available, this.maxTicksPerTurn);
|
||||
const ticks = [];
|
||||
for (let index = 0; index < count; index++) ticks.push(++this.tickIndex);
|
||||
this.accumulatorMs -= count * this.stepMs;
|
||||
|
||||
const stalled = observed > this.maxElapsedMs || available > this.maxTicksPerTurn;
|
||||
if (available > this.maxTicksPerTurn) {
|
||||
this.accumulatorMs = Math.min(this.accumulatorMs, this.stepMs - 1e-9);
|
||||
}
|
||||
return { ticks, stalled };
|
||||
}
|
||||
}
|
||||
|
||||
export function planAudioUnlock(invocations, logicalSeconds) {
|
||||
return invocations.map((invocation) => {
|
||||
if (invocation.start < logicalSeconds) {
|
||||
if (invocation.kind === 'one-shot') return { id: invocation.id, action: 'skip' };
|
||||
if (invocation.end === undefined || invocation.end > logicalSeconds) {
|
||||
return { id: invocation.id, action: 'start-continuous', phase: logicalSeconds - invocation.start, rampMs: 20 };
|
||||
}
|
||||
return { id: invocation.id, action: 'dispose' };
|
||||
}
|
||||
return { id: invocation.id, action: 'schedule', delay: invocation.start - logicalSeconds };
|
||||
});
|
||||
}
|
||||
|
||||
export const GC4_CONSTANTS = Object.freeze({
|
||||
stepMs: STEP_MS,
|
||||
maxTicksPerTurn: 8,
|
||||
maxElapsedMs: 250,
|
||||
audioLookaheadMs: 100,
|
||||
unlockRampMs: 20
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/** Phase 0 executable contract model for XZBT 0.1 GC5. */
|
||||
|
||||
export const DISPATCH_BUDGET = 1024;
|
||||
export const TERMINATION_HOOK_LIMIT = 256;
|
||||
export const CLEANUP_TICKS = 5 * 60;
|
||||
|
||||
export class ConditionTrigger {
|
||||
constructor(holdTicks = 0) {
|
||||
this.holdTicks = holdTicks;
|
||||
this.armed = false;
|
||||
this.trueTicks = 0;
|
||||
}
|
||||
|
||||
evaluate(value) {
|
||||
if (!value) {
|
||||
this.armed = true;
|
||||
this.trueTicks = 0;
|
||||
return false;
|
||||
}
|
||||
if (!this.armed) return false;
|
||||
this.trueTicks++;
|
||||
if (this.trueTicks < Math.max(1, this.holdTicks)) return false;
|
||||
this.armed = false;
|
||||
this.trueTicks = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeferredStartQueue {
|
||||
constructor(defaultExpiryTicks = 5 * 60 * 60) {
|
||||
this.defaultExpiryTicks = defaultExpiryTicks;
|
||||
this.requests = new Map();
|
||||
}
|
||||
|
||||
defer({ definitionId, priority = 50, creationTick, documentOrder, expiryTicks, sampledInputs }) {
|
||||
if (this.requests.has(definitionId)) return false;
|
||||
this.requests.set(definitionId, {
|
||||
definitionId,
|
||||
priority,
|
||||
creationTick,
|
||||
documentOrder,
|
||||
expiresAt: creationTick + (expiryTicks ?? this.defaultExpiryTicks),
|
||||
sampledInputs
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
dispatch(tick, isEligible) {
|
||||
for (const [id, request] of this.requests) {
|
||||
if (tick >= request.expiresAt) this.requests.delete(id);
|
||||
}
|
||||
const ordered = [...this.requests.values()].sort((left, right) =>
|
||||
right.priority - left.priority || left.creationTick - right.creationTick || left.documentOrder - right.documentOrder
|
||||
);
|
||||
const started = [];
|
||||
for (const request of ordered) {
|
||||
if (!isEligible(request)) continue;
|
||||
started.push(request);
|
||||
this.requests.delete(request.definitionId);
|
||||
}
|
||||
return started;
|
||||
}
|
||||
}
|
||||
|
||||
export function drainDispatch(initialQueue, expand, budget = DISPATCH_BUDGET) {
|
||||
const queue = [...initialQueue];
|
||||
const processed = [];
|
||||
let consumed = 0;
|
||||
while (queue.length > 0 && consumed < budget) {
|
||||
const unit = queue.shift();
|
||||
consumed++;
|
||||
processed.push(unit);
|
||||
const added = expand(unit) || [];
|
||||
queue.push(...added);
|
||||
}
|
||||
return {
|
||||
processed,
|
||||
consumed,
|
||||
exhausted: queue.length > 0,
|
||||
discarded: queue,
|
||||
failedOwnerIds: [...new Set(queue.map((unit) => unit.ownerId).filter(Boolean))]
|
||||
};
|
||||
}
|
||||
|
||||
export class OwnershipModel {
|
||||
constructor() {
|
||||
this.tickIndex = 0;
|
||||
this.state = new Map();
|
||||
this.owners = new Map();
|
||||
this.resources = new Map();
|
||||
this.diagnostics = [];
|
||||
this.nextResourceId = 1;
|
||||
}
|
||||
|
||||
startScenario(id, { onStart = [], onComplete = [], onCancel = [] } = {}) {
|
||||
const owner = { id, status: 'STARTING', blocked: false, onComplete, onCancel, terminalCause: null };
|
||||
this.owners.set(id, owner);
|
||||
const succeeded = this.executeActions(onStart, id, 'ordinary');
|
||||
if (succeeded) owner.status = 'ACTIVE';
|
||||
return owner;
|
||||
}
|
||||
|
||||
executeActions(actions, ownerId, phase = 'ordinary') {
|
||||
const owner = this.owners.get(ownerId);
|
||||
for (let index = 0; index < actions.length; index++) {
|
||||
const action = actions[index];
|
||||
if (phase === 'ordinary' && owner?.blocked) return false;
|
||||
try {
|
||||
if (phase === 'termination' && !['set', 'sound', 'fail'].includes(action.type)) {
|
||||
throw new Error(`Action '${action.type}' is not permitted in a termination hook.`);
|
||||
}
|
||||
if (action.type === 'set') {
|
||||
this.state.set(action.target, action.value);
|
||||
} else if (action.type === 'sound') {
|
||||
if (action.continuous || action.ownership === 'persistent') {
|
||||
throw new Error('Termination hooks permit only nonpersistent one-shot sounds.');
|
||||
}
|
||||
this.createResource(ownerId, { kind: 'termination-sound', releaseTicks: action.releaseTicks ?? 0 });
|
||||
} else if (action.type === 'resource') {
|
||||
this.createResource(ownerId, action);
|
||||
} else if (action.type === 'event') {
|
||||
if (!this.executeActions(action.actions || [], ownerId, phase)) return false;
|
||||
} else if (action.type === 'fail') {
|
||||
throw new Error(action.message || 'injected failure');
|
||||
}
|
||||
} catch (error) {
|
||||
this.diagnostics.push({ code: 'ERR_ACTION_FAILURE', ownerId, phase, index, message: error.message });
|
||||
if (phase === 'termination') continue;
|
||||
if (action.critical) {
|
||||
this.terminate(ownerId, 'failure');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
createResource(ownerId, { kind = 'generic', releaseTicks = 0, ownership = 'scenario' } = {}) {
|
||||
const effectiveOwner = ownership === 'persistent' ? 'performance' : ownerId;
|
||||
const id = `resource-${this.nextResourceId++}`;
|
||||
this.resources.set(id, { id, kind, ownerId: effectiveOwner, releaseTicks, cleanupAt: null });
|
||||
return id;
|
||||
}
|
||||
|
||||
terminate(ownerId, cause) {
|
||||
const owner = this.owners.get(ownerId);
|
||||
if (!owner || owner.blocked) return;
|
||||
owner.blocked = true;
|
||||
owner.terminalCause = cause;
|
||||
const hook = cause === 'complete' ? owner.onComplete : owner.onCancel;
|
||||
this.executeActions(hook.slice(0, TERMINATION_HOOK_LIMIT), ownerId, 'termination');
|
||||
if (hook.length > TERMINATION_HOOK_LIMIT) {
|
||||
this.diagnostics.push({ code: 'ERR_DISPATCH_BUDGET', ownerId, phase: 'termination' });
|
||||
}
|
||||
|
||||
for (const resource of this.resources.values()) {
|
||||
if (resource.ownerId !== ownerId) continue;
|
||||
const boundedRelease = Math.min(resource.releaseTicks, CLEANUP_TICKS);
|
||||
if (boundedRelease === 0) this.resources.delete(resource.id);
|
||||
else {
|
||||
resource.ownerId = `cleanup:${ownerId}`;
|
||||
resource.cleanupAt = this.tickIndex + boundedRelease;
|
||||
resource.forceAt = this.tickIndex + CLEANUP_TICKS;
|
||||
resource.forceRequired = resource.releaseTicks > CLEANUP_TICKS;
|
||||
}
|
||||
}
|
||||
owner.status = cause === 'complete' ? 'COMPLETED' : cause === 'failure' ? 'FAILED' : 'CANCELLED';
|
||||
}
|
||||
|
||||
tick(count = 1) {
|
||||
for (let step = 0; step < count; step++) {
|
||||
this.tickIndex++;
|
||||
for (const resource of [...this.resources.values()]) {
|
||||
if (!resource.cleanupAt || this.tickIndex < resource.cleanupAt) continue;
|
||||
if (resource.forceRequired && this.tickIndex >= resource.forceAt) {
|
||||
this.diagnostics.push({ code: 'WARN_CLEANUP_FORCED', resourceId: resource.id });
|
||||
}
|
||||
this.resources.delete(resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ownedResourceCount(ownerId) {
|
||||
return [...this.resources.values()].filter((resource) => resource.ownerId === ownerId).length;
|
||||
}
|
||||
}
|
||||
+136
-13
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* XZBT 0.1 Structural & Semantic Exhibit Validator
|
||||
* Zero external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.2).
|
||||
* Zero external dependencies. Conforms to XZBT Format Specification 0.1 (Revision 0.3).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -11,6 +11,20 @@ const ID_REGEX = /^[a-z][a-z0-9_-]*$/;
|
||||
const REF_PATH_REGEX = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
||||
const DURATION_REGEX = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/;
|
||||
|
||||
const RUNTIME_SIGNAL_TYPES = new Map([
|
||||
['signals.time.elapsed', 'number'],
|
||||
['signals.time.delta', 'number'],
|
||||
['signals.audio.low', 'number'],
|
||||
['signals.audio.mid', 'number'],
|
||||
['signals.audio.high', 'number'],
|
||||
['signals.audio.energy', 'number'],
|
||||
['signals.pointer.x', 'number'],
|
||||
['signals.pointer.y', 'number'],
|
||||
['signals.viewport.width', 'number'],
|
||||
['signals.viewport.height', 'number'],
|
||||
['signals.scenario.active', 'boolean']
|
||||
]);
|
||||
|
||||
const ALLOWED_ROOT_KEYS = new Set([
|
||||
'xzbt',
|
||||
'meta',
|
||||
@@ -58,6 +72,16 @@ const ALLOWED_STATE_KEYS = new Set([
|
||||
'max'
|
||||
]);
|
||||
|
||||
const ALLOWED_BINDING_KEYS = new Set([
|
||||
'source',
|
||||
'target',
|
||||
'scale',
|
||||
'offset',
|
||||
'clamp',
|
||||
'smoothing',
|
||||
'when'
|
||||
]);
|
||||
|
||||
const OPERATOR_ARITY = {
|
||||
abs: 1,
|
||||
negate: 1,
|
||||
@@ -389,37 +413,92 @@ export class ExhibitValidator {
|
||||
}
|
||||
|
||||
const graph = new Map(); // target -> [sources]
|
||||
const targetWriters = new Map();
|
||||
|
||||
this.doc.bindings.forEach((binding, idx) => {
|
||||
const path = `$.bindings[${idx}]`;
|
||||
if (typeof binding !== 'object' || binding === null) {
|
||||
if (typeof binding !== 'object' || binding === null || Array.isArray(binding)) {
|
||||
this.addError('ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof binding.from !== 'string' || !REF_PATH_REGEX.test(binding.from)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.from`, `Invalid from reference: '${binding.from}'.`);
|
||||
for (const key of Object.keys(binding)) {
|
||||
if (!ALLOWED_BINDING_KEYS.has(key)) {
|
||||
this.addError('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unrecognized field in BindingSpec: '${key}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof binding.source !== 'string' || !REF_PATH_REGEX.test(binding.source)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.source`, `Invalid source reference: '${binding.source}'.`);
|
||||
} else {
|
||||
this.resolveReference(binding.from, `${path}.from`);
|
||||
this.resolveReference(binding.source, `${path}.source`);
|
||||
}
|
||||
|
||||
if (typeof binding.to !== 'string' || !REF_PATH_REGEX.test(binding.to)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.to`, `Invalid to reference: '${binding.to}'.`);
|
||||
if (typeof binding.target !== 'string' || !REF_PATH_REGEX.test(binding.target)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', `${path}.target`, `Invalid target reference: '${binding.target}'.`);
|
||||
} else {
|
||||
this.validateBindingTarget(binding.target, `${path}.target`);
|
||||
}
|
||||
|
||||
if (binding.transform) {
|
||||
this.validateValueSpec(binding.transform, `${path}.transform`);
|
||||
if (binding.scale !== undefined && (typeof binding.scale !== 'number' || !Number.isFinite(binding.scale))) {
|
||||
this.addError('ERR_TYPE_MISMATCH', `${path}.scale`, 'Binding scale must be a finite number.');
|
||||
}
|
||||
|
||||
if (binding.smoothing) {
|
||||
if (binding.offset !== undefined && (typeof binding.offset !== 'number' || !Number.isFinite(binding.offset))) {
|
||||
this.addError('ERR_TYPE_MISMATCH', `${path}.offset`, 'Binding offset must be a finite number.');
|
||||
}
|
||||
|
||||
if (binding.clamp !== undefined) {
|
||||
if (
|
||||
!Array.isArray(binding.clamp) ||
|
||||
binding.clamp.length !== 2 ||
|
||||
!binding.clamp.every((value) => typeof value === 'number' && Number.isFinite(value))
|
||||
) {
|
||||
this.addError('ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'Binding clamp must be two finite numbers.');
|
||||
} else if (binding.clamp[0] > binding.clamp[1]) {
|
||||
this.addError('ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'Binding clamp minimum cannot exceed maximum.');
|
||||
}
|
||||
}
|
||||
|
||||
if (binding.smoothing !== undefined) {
|
||||
if (typeof binding.smoothing !== 'string' || !DURATION_REGEX.test(binding.smoothing)) {
|
||||
this.addError('ERR_INVALID_DURATION', `${path}.smoothing`, `Invalid duration: '${binding.smoothing}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof binding.from === 'string' && typeof binding.to === 'string') {
|
||||
if (!graph.has(binding.to)) graph.set(binding.to, []);
|
||||
graph.get(binding.to).push(binding.from);
|
||||
if (binding.when !== undefined) {
|
||||
this.validateConditionSpec(binding.when, `${path}.when`);
|
||||
}
|
||||
|
||||
const sourceType = this.getReferenceType(binding.source);
|
||||
const targetType = this.getReferenceType(binding.target);
|
||||
if (sourceType && targetType) {
|
||||
const usesNumericTransform =
|
||||
binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined ||
|
||||
(binding.smoothing !== undefined && binding.smoothing !== '0ms');
|
||||
const sourceNumeric = sourceType === 'number' || sourceType === 'integer';
|
||||
const targetNumeric = targetType === 'number' || targetType === 'integer';
|
||||
if (usesNumericTransform && (!sourceNumeric || !targetNumeric)) {
|
||||
this.addError('ERR_TYPE_MISMATCH', path, 'Binding transforms and smoothing require numeric source and target types.');
|
||||
} else if (!usesNumericTransform && sourceType !== targetType && !(sourceNumeric && targetNumeric)) {
|
||||
this.addError('ERR_TYPE_MISMATCH', path, `Binding source type '${sourceType}' does not match target type '${targetType}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof binding.source === 'string' && typeof binding.target === 'string') {
|
||||
if (!graph.has(binding.target)) graph.set(binding.target, []);
|
||||
graph.get(binding.target).push(binding.source);
|
||||
|
||||
const priorWriter = targetWriters.get(binding.target);
|
||||
if (priorWriter !== undefined) {
|
||||
this.addError(
|
||||
'ERR_CONFLICTING_BINDING',
|
||||
`${path}.target`,
|
||||
`Binding target '${binding.target}' is already written by $.bindings[${priorWriter}].`
|
||||
);
|
||||
} else {
|
||||
targetWriters.set(binding.target, idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -454,6 +533,41 @@ export class ExhibitValidator {
|
||||
}
|
||||
}
|
||||
|
||||
validateBindingTarget(refPath, location) {
|
||||
const parts = refPath.split('.');
|
||||
const namespace = parts[0];
|
||||
|
||||
if (namespace === 'parameters' || namespace === 'state') {
|
||||
if (parts.length !== 2) {
|
||||
this.addError('ERR_UNSUPPORTED_TARGET', location, `Target '${refPath}' is not a scalar ${namespace} target.`);
|
||||
return;
|
||||
}
|
||||
this.resolveReference(refPath, location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (namespace === 'audio' && parts.length === 4 && parts[1] === 'buses' && parts[3] === 'gain') {
|
||||
const busId = parts[2];
|
||||
if (!this.doc.audio?.buses?.[busId]) {
|
||||
this.addError('ERR_INVALID_REFERENCE', location, `Target '${refPath}' refers to a missing audio bus '${busId}'.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.addError('ERR_UNSUPPORTED_TARGET', location, `Target '${refPath}' is not exposed by the shared 0.1 target registry.`);
|
||||
}
|
||||
|
||||
getReferenceType(refPath) {
|
||||
if (typeof refPath !== 'string') return null;
|
||||
const parts = refPath.split('.');
|
||||
if (parts[0] === 'parameters' && parts.length === 2) return this.doc.parameters?.[parts[1]]?.type || null;
|
||||
if (parts[0] === 'state' && parts.length === 2) return this.doc.state?.[parts[1]]?.type || null;
|
||||
if (parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain') return 'number';
|
||||
if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES.get(refPath) || null;
|
||||
if (parts[0] === 'modulators') return 'number';
|
||||
return null;
|
||||
}
|
||||
|
||||
resolveReference(refPath, location) {
|
||||
const parts = refPath.split('.');
|
||||
const namespace = parts[0];
|
||||
@@ -476,6 +590,15 @@ export class ExhibitValidator {
|
||||
`Reference '${refPath}' refers to non-existent state variable '${stateId}'.`
|
||||
);
|
||||
}
|
||||
} else if (namespace === 'signals') {
|
||||
if (!RUNTIME_SIGNAL_TYPES.has(refPath)) {
|
||||
this.addError('ERR_INVALID_REFERENCE', location, `Reference '${refPath}' is not a declared 0.1 runtime signal.`);
|
||||
}
|
||||
} else if (namespace === 'modulators') {
|
||||
const modulatorId = parts[1];
|
||||
if (!this.doc.modulators || !this.doc.modulators[modulatorId]) {
|
||||
this.addError('ERR_INVALID_REFERENCE', location, `Reference '${refPath}' refers to non-existent modulator '${modulatorId}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user