feat(phase0): complete shared contracts and milestone plan

This commit is contained in:
2026-09-05 12:08:36 -07:00
parent ee774d0307
commit 63895c4fe7
23 changed files with 1433 additions and 161 deletions
+4 -4
View File
@@ -18,12 +18,12 @@
},
"bindings": [
{
"from": "parameters.activity",
"to": "state.energy"
"source": "parameters.activity",
"target": "state.energy"
},
{
"from": "state.energy",
"to": "parameters.activity"
"source": "state.energy",
"target": "parameters.activity"
}
]
}
+11 -7
View File
@@ -18,13 +18,17 @@
},
"bindings": [
{
"from": "parameters.activity",
"to": "state.energy",
"transform": {
"op": "add",
"args": [
1.0
]
"source": "parameters.activity",
"target": "state.energy",
"when": {
"op": "gt",
"left": {
"op": "add",
"args": [
1.0
]
},
"right": 0
}
}
]
+2 -2
View File
@@ -12,8 +12,8 @@
},
"bindings": [
{
"from": "parameters.non_existent_param",
"to": "parameters.activity"
"source": "parameters.non_existent_param",
"target": "parameters.activity"
}
]
}
+3 -9
View File
@@ -48,15 +48,9 @@
},
"bindings": [
{
"from": "parameters.activity",
"to": "state.energy",
"transform": {
"op": "multiply",
"args": [
{ "ref": "parameters.activity" },
1.5
]
},
"source": "parameters.activity",
"target": "state.energy",
"scale": 1.5,
"smoothing": "250ms"
}
]
+23
View File
@@ -0,0 +1,23 @@
{
"tickSeconds": 0.016666666666666666,
"traces": {
"directBusOverride": [
{ "tick": 0, "storedActivity": 0.4, "binding": 0.4, "override": 0.8, "resolved": 0.8 },
{ "tick": 1, "storedActivity": 0.2, "binding": 0.2, "override": 0.8, "resolved": 0.8 },
{ "tick": 2, "storedActivity": 0.2, "binding": 0.2, "releaseProgress": 0.5, "resolved": 0.5 },
{ "tick": 3, "storedActivity": 0.2, "binding": 0.2, "releaseProgress": 1, "resolved": 0.2 }
],
"parameterOverrideFeedsBinding": [
{ "tick": 0, "storedActivity": 0.3, "activityOverride": 0.9, "resolvedActivity": 0.9, "busGain": 0.95 },
{ "tick": 1, "storedActivity": 0.2, "activityOverride": 0.9, "resolvedActivity": 0.9, "busGain": 0.95 },
{ "tick": 2, "storedActivity": 0.2, "releaseProgress": 0.5, "resolvedActivity": 0.55, "busGain": 0.775 },
{ "tick": 3, "storedActivity": 0.2, "releaseProgress": 1, "resolvedActivity": 0.2, "busGain": 0.6 }
],
"automationMaskedThenReleased": [
{ "tick": 0, "automation": 0.25, "override": 0.75, "resolved": 0.75 },
{ "tick": 1, "automation": 0.45, "override": 0.75, "resolved": 0.75 },
{ "tick": 2, "automation": 0.65, "releaseProgress": 0.5, "resolved": 0.7 },
{ "tick": 3, "automation": 0.85, "releaseProgress": 1, "resolved": 0.85 }
]
}
}
+6
View File
@@ -0,0 +1,6 @@
[
{ "tick": 1, "low": 0.1, "mid": 0.2, "high": 0.3, "energy": 0.2 },
{ "tick": 2, "low": 0.8, "mid": 0.4, "high": 0.1, "energy": 0.55 },
{ "tick": 3, "low": 0.3, "mid": 0.7, "high": 0.5, "energy": 0.6 },
{ "tick": 4, "low": 0.0, "mid": 0.1, "high": 0.2, "energy": 0.1 }
]
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
import {
attackValue,
bindingValue,
releaseValue,
resolveNumericTarget,
selectWinningOverride,
smoothBinding
} from '../tools/gc3-resolution-model.mjs';
const fixture = JSON.parse(readFileSync(new URL('./fixtures/gc3/resolution-traces.json', import.meta.url)));
const closeTo = (actual, expected, epsilon = 1e-12) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
test('direct bus override masks a live binding and releases toward its current value', () => {
for (const row of fixture.traces.directBusOverride) {
const overrides = row.override === undefined
? []
: [{ id: 'direct', priority: 0, activationSequence: 1, value: row.override }];
let resolved = resolveNumericTarget({ base: 0, binding: row.binding, overrides, safetyClamp: [0, 1] }).resolved;
if (row.releaseProgress !== undefined) resolved = releaseValue(0.8, row.binding, row.releaseProgress);
closeTo(resolved, row.resolved);
}
});
test('a parameter override feeds a downstream binding in the same tick and preserves user edits', () => {
for (const row of fixture.traces.parameterOverrideFeedsBinding) {
let activity = row.activityOverride ?? row.storedActivity;
if (row.releaseProgress !== undefined) activity = releaseValue(0.9, row.storedActivity, row.releaseProgress);
const gain = bindingValue(activity, { scale: 0.5, offset: 0.5, clamp: [0, 1] });
closeTo(activity, row.resolvedActivity);
closeTo(gain, row.busGain);
}
});
test('priority and activation sequence decide competition independently of scope', () => {
const overrides = [
{ id: 'scenario', scope: 'scenario', priority: 4, activationSequence: 2, value: 0.4 },
{ id: 'duration-low', scope: 'duration', priority: 3, activationSequence: 99, value: 0.9 }
];
assert.equal(selectWinningOverride(overrides).id, 'scenario');
overrides.push({ id: 'duration-tie', scope: 'duration', priority: 4, activationSequence: 3, value: 0.7 });
assert.equal(selectWinningOverride(overrides).id, 'duration-tie');
});
test('automation continues while masked and becomes the live release destination', () => {
for (const row of fixture.traces.automationMaskedThenReleased) {
const overrides = row.override === undefined
? []
: [{ id: 'mask', priority: 1, activationSequence: 1, value: row.override }];
let resolved = resolveNumericTarget({ base: 0, automation: row.automation, overrides }).resolved;
if (row.releaseProgress !== undefined) resolved = releaseValue(0.75, row.automation, row.releaseProgress);
closeTo(resolved, row.resolved);
}
});
test('legal modulation follows override and safety clamp runs last', () => {
const result = resolveNumericTarget({
base: 0.2,
overrides: [{ id: 'boost', priority: 0, activationSequence: 1, value: 0.9 }],
modulation: 0.3,
safetyClamp: [0, 1]
});
assert.equal(result.afterOverride, 0.9);
closeTo(result.beforeClamp, 1.2);
assert.equal(result.resolved, 1);
});
test('binding smoothing is exact, and re-enable initializes without replaying disabled time', () => {
const first = smoothBinding(undefined, 0.8, fixture.tickSeconds, 0.05);
assert.equal(first, 0.8);
const second = smoothBinding(first, 0.2, fixture.tickSeconds, 0.05);
closeTo(second, 0.8 + (1 - Math.exp(-fixture.tickSeconds / 0.05)) * (0.2 - 0.8));
assert.equal(smoothBinding(undefined, 0.35, fixture.tickSeconds, 0.05), 0.35);
});
test('interrupted transitions begin from the visible value without cancelling lower overrides', () => {
closeTo(attackValue(0.2, 0.8, 0.5, 'linear'), 0.5);
const lower = { id: 'lower', priority: 1, activationSequence: 1, value: 0.5 };
const higher = { id: 'higher', priority: 2, activationSequence: 2, value: attackValue(0.5, 1, 0, 'linear') };
assert.equal(selectWinningOverride([lower, higher]).value, 0.5);
higher.live = false;
assert.equal(selectWinningOverride([lower, higher]).id, 'lower');
});
test('validator rejects conflicting writers, cycles, unsupported targets, and draft aliases', () => {
const base = {
xzbt: '0.1',
meta: { id: 'gc3-validation', name: 'GC3 validation' },
parameters: { activity: { type: 'number', default: 0.5 } },
state: { energy: { type: 'number', initial: 0.2 } }
};
const cases = [
{
code: 'ERR_CONFLICTING_BINDING',
bindings: [
{ source: 'parameters.activity', target: 'state.energy' },
{ source: 'parameters.activity', target: 'state.energy', when: { op: 'gt', left: 1, right: 0 } }
]
},
{
code: 'ERR_CYCLIC_DEPENDENCY',
bindings: [
{ source: 'parameters.activity', target: 'state.energy' },
{ source: 'state.energy', target: 'parameters.activity' }
]
},
{
code: 'ERR_UNSUPPORTED_TARGET',
bindings: [{ source: 'parameters.activity', target: 'signals.time.elapsed' }]
},
{
code: 'ERR_UNKNOWN_FIELD',
bindings: [{ from: 'parameters.activity', to: 'state.energy' }]
}
];
for (const entry of cases) {
const result = new ExhibitValidator({ ...base, bindings: entry.bindings }).validate();
assert.equal(result.valid, false);
assert.ok(result.errors.some((error) => error.code === entry.code), JSON.stringify(result.errors));
}
});
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
createRandomStream,
deriveStreamState,
FixedStepClock,
GC4_CONSTANTS,
planAudioUnlock
} from '../tools/gc4-time-model.mjs';
function simulateFrames(frameDurationMs, durationMs) {
const clock = new FixedStepClock();
const decisions = [];
clock.advance(0);
for (let now = frameDurationMs; now <= durationMs + 1e-7; now += frameDurationMs) {
for (const tick of clock.advance(Math.min(now, durationMs)).ticks) {
if (tick % 10 === 0) decisions.push(tick);
}
}
return { tickIndex: clock.tickIndex, decisions };
}
test('render frequency does not change fixed-tick decisions', () => {
const at60 = simulateFrames(1000 / 60, 1000);
const at120 = simulateFrames(1000 / 120, 1000);
assert.deepEqual(at60, at120);
assert.equal(at60.tickIndex, 60);
assert.deepEqual(at60.decisions, [10, 20, 30, 40, 50, 60]);
});
test('long stalls have bounded work and do not replay discarded wall time', () => {
const clock = new FixedStepClock();
clock.advance(0);
const stalled = clock.advance(1000);
assert.equal(stalled.stalled, true);
assert.equal(stalled.ticks.length, GC4_CONSTANTS.maxTicksPerTurn);
assert.ok(clock.accumulatorMs < GC4_CONSTANTS.stepMs);
});
test('visibility restoration does not clear explicit pause', () => {
const clock = new FixedStepClock();
clock.advance(0);
clock.advance(GC4_CONSTANTS.stepMs);
assert.equal(clock.tickIndex, 1);
clock.setPauseReason('explicit', true, 20);
clock.setPauseReason('hidden', true, 20);
clock.advance(10_000);
clock.setPauseReason('hidden', false, 10_000);
clock.advance(11_000);
assert.equal(clock.tickIndex, 1);
clock.setPauseReason('explicit', false, 11_000);
clock.advance(11_000 + GC4_CONSTANTS.stepMs);
assert.equal(clock.tickIndex, 2);
});
test('unlock skips pre-unlock one-shots and aligns a live continuous sound', () => {
const plan = planAudioUnlock([
{ id: 'expired', kind: 'one-shot', start: 1, end: 1.2 },
{ id: 'partial', kind: 'one-shot', start: 4.9, end: 5.2 },
{ id: 'ambience', kind: 'continuous', start: 2 },
{ id: 'future', kind: 'one-shot', start: 5.05, end: 5.2 }
], 5);
assert.deepEqual(plan, [
{ id: 'expired', action: 'skip' },
{ id: 'partial', action: 'skip' },
{ id: 'ambience', action: 'start-continuous', phase: 3, rampMs: 20 },
{ id: 'future', action: 'schedule', delay: 0.04999999999999982 }
]);
});
test('PRNG state and output are stable known vectors', () => {
assert.deepEqual(deriveStreamState(42, 'scenario', 'storm:1'), [
471422921, 3327820418, 120898024, 15893603
]);
const stream = createRandomStream(42, 'scenario', 'storm:1');
assert.deepEqual([
stream.nextUint32(), stream.nextUint32(), stream.nextUint32(), stream.nextUint32()
], [4101533927, 4149236977, 2113172072, 1672809915]);
});
test('manual sampling does not perturb cadence or scenario streams', () => {
const cadenceA = createRandomStream(7, 'cadence', 'routine:1');
const cadenceB = createRandomStream(7, 'cadence', 'routine:1');
const scenarioA = createRandomStream(7, 'scenario', 'surge:1');
const scenarioB = createRandomStream(7, 'scenario', 'surge:1');
const manual = createRandomStream(7, 'manual-sample', 'warning:1');
const before = [cadenceA.nextUint32(), scenarioA.nextUint32()];
for (let index = 0; index < 100; index++) manual.nextUint32();
const after = [cadenceA.nextUint32(), scenarioA.nextUint32()];
assert.deepEqual(before, [cadenceB.nextUint32(), scenarioB.nextUint32()]);
assert.deepEqual(after, [cadenceB.nextUint32(), scenarioB.nextUint32()]);
});
test('audio-reactive decisions use the same deterministic signal fixture at every render rate', () => {
const signals = JSON.parse(readFileSync(new URL('./fixtures/gc4/deterministic-signals.json', import.meta.url)));
const decide = () => signals.filter((signal) => signal.energy >= 0.5).map((signal) => signal.tick);
assert.deepEqual(decide(), [2, 3]);
assert.deepEqual(decide(), decide());
});
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CLEANUP_TICKS,
ConditionTrigger,
DeferredStartQueue,
DISPATCH_BUDGET,
drainDispatch,
OwnershipModel
} from '../tools/gc5-lifecycle-model.mjs';
test('resources created through nested events inherit the scenario owner', () => {
const runtime = new OwnershipModel();
runtime.startScenario('scenario-1');
runtime.executeActions([{ type: 'event', actions: [{ type: 'event', actions: [{ type: 'resource', kind: 'sound' }] }] }], 'scenario-1');
assert.equal(runtime.ownedResourceCount('scenario-1'), 1);
});
test('owner termination bounds duration override release and eventually disposes it', () => {
const runtime = new OwnershipModel();
runtime.startScenario('scenario-1');
runtime.executeActions([{ type: 'resource', kind: 'override', releaseTicks: CLEANUP_TICKS * 10 }], 'scenario-1');
runtime.terminate('scenario-1', 'cancel');
assert.equal(runtime.ownedResourceCount('scenario-1'), 0);
assert.equal(runtime.resources.size, 1);
runtime.tick(CLEANUP_TICKS);
assert.equal(runtime.resources.size, 0);
assert.ok(runtime.diagnostics.some((entry) => entry.code === 'WARN_CLEANUP_FORCED'));
});
test('persistent set survives a later critical ordinary-action failure', () => {
const runtime = new OwnershipModel();
runtime.startScenario('scenario-1', { onCancel: [{ type: 'set', target: 'state.cleanup-ran', value: true }] });
runtime.executeActions([
{ type: 'set', target: 'state.mode', value: 'changed' },
{ type: 'resource', kind: 'visual' },
{ type: 'fail', critical: true }
], 'scenario-1');
assert.equal(runtime.state.get('state.mode'), 'changed');
assert.equal(runtime.state.get('state.cleanup-ran'), true);
assert.equal(runtime.owners.get('scenario-1').status, 'FAILED');
assert.equal(runtime.resources.size, 0);
});
test('startup and termination-hook failures still complete cleanup with the original terminal cause', () => {
const runtime = new OwnershipModel();
runtime.startScenario('scenario-1', {
onStart: [{ type: 'resource', kind: 'audio' }, { type: 'fail', critical: true }],
onCancel: [{ type: 'fail', critical: true }, { type: 'set', target: 'state.after-hook-failure', value: 1 }]
});
assert.equal(runtime.owners.get('scenario-1').status, 'FAILED');
assert.equal(runtime.state.get('state.after-hook-failure'), 1);
assert.equal(runtime.resources.size, 0);
assert.equal(runtime.diagnostics.filter((entry) => entry.code === 'ERR_ACTION_FAILURE').length, 2);
});
test('condition trigger requires a false tick before initial and subsequent firing', () => {
const trigger = new ConditionTrigger(2);
assert.equal(trigger.evaluate(true), false);
assert.equal(trigger.evaluate(false), false);
assert.equal(trigger.evaluate(true), false);
assert.equal(trigger.evaluate(true), true);
assert.equal(trigger.evaluate(true), false);
assert.equal(trigger.evaluate(false), false);
assert.equal(trigger.evaluate(true), false);
assert.equal(trigger.evaluate(true), true);
});
test('deferred queue keeps one request per definition, expires, rechecks, and orders deterministically', () => {
const queue = new DeferredStartQueue(10);
assert.equal(queue.defer({ definitionId: 'low', priority: 10, creationTick: 1, documentOrder: 0, sampledInputs: 0.2 }), true);
assert.equal(queue.defer({ definitionId: 'low', priority: 99, creationTick: 2, documentOrder: 0, sampledInputs: 0.9 }), false);
queue.defer({ definitionId: 'high-late', priority: 80, creationTick: 2, documentOrder: 2 });
queue.defer({ definitionId: 'high-early', priority: 80, creationTick: 1, documentOrder: 1 });
const started = queue.dispatch(3, (request) => request.definitionId !== 'low');
assert.deepEqual(started.map((request) => request.definitionId), ['high-early', 'high-late']);
assert.equal(queue.requests.get('low').sampledInputs, 0.2);
assert.deepEqual(queue.dispatch(11, () => true), []);
assert.equal(queue.requests.size, 0);
});
test('feedback is bounded and leaves cleanup capacity outside the ordinary budget', () => {
const result = drainDispatch([{ path: 'event.loop', ownerId: 'scenario-1' }], (unit) => [unit]);
assert.equal(result.exhausted, true);
assert.equal(result.consumed, DISPATCH_BUDGET);
assert.equal(result.discarded.length, 1);
assert.deepEqual(result.failedOwnerIds, ['scenario-1']);
const runtime = new OwnershipModel();
runtime.startScenario('scenario-1');
runtime.executeActions([{ type: 'resource' }], 'scenario-1');
runtime.terminate('scenario-1', 'failure');
assert.equal(runtime.resources.size, 0);
});
test('repeated completion, cancellation, and failure cycles return counters to baseline', () => {
const runtime = new OwnershipModel();
for (let index = 0; index < 30; index++) {
const id = `scenario-${index}`;
runtime.startScenario(id);
runtime.executeActions([
{ type: 'resource', kind: 'sound', releaseTicks: 2 },
{ type: 'event', actions: [{ type: 'resource', kind: 'visual', releaseTicks: 3 }] }
], id);
runtime.terminate(id, index % 3 === 0 ? 'complete' : index % 3 === 1 ? 'cancel' : 'failure');
runtime.tick(3);
}
assert.equal(runtime.resources.size, 0);
assert.equal(runtime.owners.size, 30);
assert.ok([...runtime.owners.values()].every((owner) => ['COMPLETED', 'CANCELLED', 'FAILED'].includes(owner.status)));
});