feat(mgc): rendezvous planner — same-time landing across all modes

Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.

Architecture (src/movement/):
- machineProfile.js   – pure snapshot of a registered child (state,
                        position, velocityPctPerS, ladder timings,
                        flowAt / positionForFlow). Reads timings from
                        child.state.config.time (the actual storage
                        location — previous fallback paths silently
                        produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js   – seconds-to-target per machine; handles
                        idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
                        command is delayed so its move finishes at t*.
                        Startup execsequence fires at 0; its flowmovement
                        is gated by max(ladderS, t* − rampS) so a fast
                        pump waits before ramping rather than landing
                        early. useRendezvous=false collapses to all
                        fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
                        every command whose fireAtTickN ≤ floor(elapsed/tickS).
                        tick() no longer awaits pending fireCommand
                        promises — the synchronous prologue of
                        handleInput claims the latest-wins gate, which
                        is what race-favouring relies on.

Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
  _optimalControl. Builds profiles, calls movementScheduler.plan,
  replans the executor, ticks once. Reads
  config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
  flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
  Same-time landing now applies in BOTH modes.

Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
  config.planner.useRendezvous. Default ON.

Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
  diagnostic — drives a 3-pump mixed-state dispatch and asserts both
  convergence to the demand setpoint AND same-time landing within
  one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
  assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
  multi-startup case proving the fast pumps wait so all three land
  together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.

Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
  helper; every command now checks isValidActionForMode +
  isValidSourceForMode before dispatching. Status-level commands
  (set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
  Contracts,Examples,Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-17 19:43:55 +02:00
parent 26e92b54f7
commit 472402c62d
26 changed files with 3048 additions and 280 deletions

View File

@@ -0,0 +1,136 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const MovementExecutor = require('../../src/movement/movementExecutor');
function mkSchedule(commands, tStarS = 0, tickS = 1) {
return { tStarS, tickS, commands };
}
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
test('executor: throws if fireCommand callback missing', () => {
assert.throws(() => new MovementExecutor({}), TypeError);
});
test('executor: fires commands whose fireAtTickN <= cursor', async () => {
const fired = [];
const ex = new MovementExecutor({
fireCommand: (c) => fired.push(c),
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 },
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 },
]));
let firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'A');
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 0);
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'B');
await ex.tick(); await ex.tick();
firedThisTick = await ex.tick();
assert.equal(firedThisTick.length, 1);
assert.equal(firedThisTick[0].machineId, 'C');
assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']);
assert.equal(ex.pending(), 0);
});
test('executor: replan drops unfired commands and resets cursor', async () => {
const fired = [];
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 },
]));
await ex.tick(); // A fires
assert.deepEqual(fired, ['A']);
assert.equal(ex.pending(), 1);
ex.replan(mkSchedule([
{ machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 },
{ machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 },
]));
assert.equal(ex.cursor(), 0, 'cursor reset on replan');
await ex.tick(); // X fires
assert.deepEqual(fired, ['A', 'X']);
await ex.tick(); await ex.tick(); await ex.tick();
assert.ok(!fired.includes('B'), 'old B move was dropped by replan');
assert.ok(fired.includes('Y'), 'new Y move fired after delay');
});
test('executor: fires only once per command even across many ticks', async () => {
const fired = [];
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
]));
for (let i = 0; i < 5; i++) await ex.tick();
assert.deepEqual(fired, ['A']);
});
test('executor: catches fireCommand errors and continues', async () => {
const fired = [];
const ex = new MovementExecutor({
fireCommand: (c) => {
if (c.machineId === 'B') throw new Error('boom');
fired.push(c.machineId);
},
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 },
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 },
]));
await ex.tick();
// B's error must not block A or C.
assert.deepEqual(fired, ['A', 'C']);
});
test('executor: empty / null schedule is safe to tick', async () => {
const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger });
assert.deepEqual(await ex.tick(), []);
ex.replan({ commands: [] });
assert.deepEqual(await ex.tick(), []);
});
test('executor: tick fires commands synchronously and does NOT await their promises', async () => {
// Contract: tick() returns as soon as every due fireCommand has been
// invoked. It does NOT wait for the returned promises to resolve.
// This matters because a flowmovement-after-startup resolves only
// after the pump's entire ramp completes — awaiting it would freeze
// the executor's wall-clock progression and drag every delayed
// command in the schedule forward by that duration.
const order = [];
let resolveFire;
const firePromise = new Promise((r) => { resolveFire = r; });
const ex = new MovementExecutor({
fireCommand: (c) => {
order.push(`fire-start-${c.machineId}`);
return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); });
},
logger: noopLogger,
});
ex.replan(mkSchedule([
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
]));
const tickPromise = ex.tick().then(() => order.push('tick-resolved'));
// Wait one microtask cycle: tick should already have resolved even
// though fire is still pending.
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(order, ['fire-start-A', 'tick-resolved'],
'tick must resolve immediately after invoking fireCommand — not wait for its promise');
resolveFire();
await tickPromise;
// The fire's tail runs in the background and lands after tick resolved.
assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']);
});