joule-moq-web: a turn a later schedule replaces ends at that schedule's from #620

Merged
dcharlot merged 1 commit from wai/co-floor-schedule-change into main 2026-11-04 18:26:10 -05:00
4 changed files with 113 additions and 8 deletions

View file

@ -1755,7 +1755,18 @@ async function scenarioCo(run) {
const floor = onHost.floor || [];
const out = floor.filter((f) => f.floor === "out_of_turn");
const lateF = floor.filter((f) => f.late);
check(`${label}: the room with the clock's log verifies (co-performance §9): accept`, onHost.outcome === "accept", onHost.outcome === "accept" ? `${objects.length} objects, ${floor.length} placed` : `${onHost.reason} at ${JSON.stringify(onHost.at)}`);
// A refusal at an object names it, its arrival and the turn boundary or schedule `from`
// nearest it: one a hop past an edge is the clock's arrival trailing the relay's (§8, §12).
const refusedAt = async (r) => {
const o = Array.isArray(r.at) && r.at[0] === "object" ? objects[r.at[1]] : null;
if (!o) return `${r.reason} at ${JSON.stringify(r.at)}`;
const ss = (await co(V[0].p)).schedules;
const edges = ss.flatMap((s) => [s.from, ...s.segments.flatMap((g) => Array.from({ length: g.turns + 1 }, (_, i) => g.start + i * g.turn_us))]);
const e = edges.reduce((b, x) => (Math.abs(o.arrival - x) < Math.abs(o.arrival - b) ? x : b), edges[0]);
const who = o.principal === A.principal ? "the lead" : o.principal === B.principal ? "the AI participant" : o.principal.slice(0, 12);
return `${r.reason} at ${JSON.stringify(r.at)}: ${who}'s object, arrived ${o.arrival} µs, ${o.arrival - e} µs from the edge at ${e}; the schedules from ${ss.map((s) => `${s.from} (${s.mode})`).join(", ")}`;
};
check(`${label}: the room with the clock's log verifies (co-performance §9): accept`, onHost.outcome === "accept", onHost.outcome === "accept" ? `${objects.length} objects, ${floor.length} placed` : await refusedAt(onHost));
check(`${label}: the page's verification of the room is the host's`, JSON.stringify(inPage) === JSON.stringify(onHost), inPage.outcome);
check(`${label}: every object out of turn and every late object is marked`, out.length > 0 && out.every((f) => f.marks & 1) && lateF.length > 0 && lateF.every((f) => f.marks & 2), `${out.length} out of turn, ${lateF.length} late`);
const intents = (onHost.intents || []).map((i) => i.outcome);

View file

@ -73,7 +73,7 @@ fi
BROWSER_BIN=${BROWSER_BIN:-}
step "unit tests of the page's modules"
node --test "$CRATE/e2e/compose.test.mjs" "$CRATE/e2e/remote_input.test.mjs" "$CRATE/web/moq/capsule.test.mjs" "$CRATE/web/moq/media.test.mjs" "$CRATE/web/moq/session.test.mjs" "$CRATE/web/ads/presentation.test.mjs" "$CRATE/web/room/privacy-pass.test.mjs" "$CRATE/web/room/binding.test.mjs" "$CRATE/web/room/cowatch.test.mjs" "$CRATE/web/room/remote.test.mjs" "$CRATE/web/room/markup.test.mjs" "$CRATE/web/room/endpoint.test.mjs" "$CRATE/web/room/termination.test.mjs"
node --test "$CRATE/e2e/compose.test.mjs" "$CRATE/e2e/remote_input.test.mjs" "$CRATE/web/moq/capsule.test.mjs" "$CRATE/web/moq/media.test.mjs" "$CRATE/web/moq/session.test.mjs" "$CRATE/web/ads/presentation.test.mjs" "$CRATE/web/room/privacy-pass.test.mjs" "$CRATE/web/room/binding.test.mjs" "$CRATE/web/room/cowatch.test.mjs" "$CRATE/web/room/co.test.mjs" "$CRATE/web/room/remote.test.mjs" "$CRATE/web/room/markup.test.mjs" "$CRATE/web/room/endpoint.test.mjs" "$CRATE/web/room/termination.test.mjs"
step "end-to-end"
browser=()

View file

@ -89,15 +89,28 @@ export class Co {
// ------------------------------------------------------------------------------ the floor
scheduleAt(x) {
for (let i = this.schedules.length - 1; i >= 0; i--) if (this.schedules[i].from <= x) return this.schedules[i];
return null;
// The schedule in effect at `x` (§6.1): of those whose `from` is at or below it, the last
// stated; its index in the room's order, or -1.
scheduleIndexAt(x) {
for (let i = this.schedules.length - 1; i >= 0; i--) if (this.schedules[i].from <= x) return i;
return -1;
}
// The turn running at room-clock `x` (§6.2), with its holder at `x` (§6.3).
scheduleAt(x) {
const k = this.scheduleIndexAt(x);
return k < 0 ? null : this.schedules[k];
}
// The turn running at room-clock `x` (§6.2), with its holder at `x` (§6.3). A turn ends at its
// segment's next boundary, or at the `from` of a later schedule, which replaces its own from
// then on (§6.1), if that comes first: the guard a performer keeps before a turn's end (§8)
// keeps it before either, or its last objects reach the relay under one schedule and the room
// clock, a hop later, under the next.
turnAt(x) {
const s = this.scheduleAt(x);
if (!s) return null;
const k = this.scheduleIndexAt(x);
if (k < 0) return null;
const s = this.schedules[k];
const replaced = this.schedules.slice(k + 1).reduce((m, r) => Math.min(m, r.from), Infinity);
for (let i = 0; i < s.segments.length; i++) {
const g = s.segments[i];
const L = g.turn_us;
@ -106,7 +119,7 @@ export class Co {
const n = Math.floor((x - g.start) / L);
const scheduled = g.holders[n % g.holders.length];
const h = this.handovers.filter((h) => h.schedule === s.digest && h.segment === i && h.turn === n && h.at <= x).sort((a, b) => a.at - b.at || a.order - b.order).pop();
return { schedule: s, segment: i, index: n, start: g.start + n * L, end: g.start + (n + 1) * L, scheduled, holder: h ? h.to : scheduled, holders: g.holders, role: g.pairs ? (n % 2 ? "response" : "call") : "solo", mode: s.mode, of: g.turns };
return { schedule: s, segment: i, index: n, start: g.start + n * L, end: Math.min(g.start + (n + 1) * L, replaced), scheduled, holder: h ? h.to : scheduled, holders: g.holders, role: g.pairs ? (n % 2 ? "response" : "call") : "solo", mode: s.mode, of: g.turns };
}
}
return null;

View file

@ -0,0 +1,81 @@
// The room page's co-performance floor (co.js, `node --test web/room/co.test.mjs`): the turn
// running at an instant ends at its segment's boundary, or at the `from` of a later schedule
// that replaces its own (co-performance §6.1), whichever comes first; and a performer's page
// keeps its guard (§8) before either, so that its last objects reach the relay and the room's
// clock, a hop later, under the same schedule.
import test from "node:test";
import assert from "node:assert/strict";
import { Co, GUARD_US } from "./co.js";
const A = "aa".repeat(32);
const B = "bb".repeat(32);
/** A page of `principal`, holding `schedules` in the room's order, with no records else. */
function page(principal, schedules) {
const co = new Co({ wai: { coPolicy: () => "{}" }, me: () => ({ principal }), policyBytes: null });
co.schedules = schedules.map((s, i) => ({ digest: `s${i}`, order: i + 1, ...s }));
return co;
}
// Six turns of 5 s from 0, A then B, under mark; then, from 7 s, two turns of 4 s, A then B,
// under refuse. B holds the first schedule's second turn, [5 s, 10 s), when the second takes
// effect.
const MARK = { mode: "mark", from: 0, segments: [{ start: 0, turn_us: 5_000_000, turns: 6, pairs: 0, holders: [A, B] }] };
const REFUSE = { mode: "refuse", from: 7_000_000, segments: [{ start: 7_000_000, turn_us: 4_000_000, turns: 2, pairs: 0, holders: [A, B] }] };
test("a turn a later schedule replaces ends at that schedule's from", () => {
const co = page(B, [MARK, REFUSE]);
const t = co.turnAt(6_900_000);
assert.equal(t.holder, B);
assert.equal(t.start, 5_000_000);
assert.equal(t.end, 7_000_000);
assert.equal(t.mode, "mark");
const u = co.turnAt(7_000_000);
assert.equal(u.holder, A);
assert.equal(u.mode, "refuse");
assert.equal(u.start, 7_000_000);
assert.equal(u.end, 11_000_000);
});
test("a turn no later schedule replaces ends at its segment's boundary", () => {
const co = page(B, [MARK]);
assert.equal(co.turnAt(6_900_000).end, 10_000_000);
assert.equal(co.turnAt(1_000_000).end, 5_000_000);
});
test("the holder of a replaced turn stops its guard before the next schedule's from", () => {
const co = page(B, [MARK, REFUSE]);
assert.equal(co.sends(7_000_000 - GUARD_US - 1), true);
assert.equal(co.sends(7_000_000 - GUARD_US), false);
assert.equal(co.sends(6_999_999), false);
// From the from on, B does not hold the floor until its turn of the new schedule, guarded.
assert.equal(co.sends(9_000_000), false);
assert.equal(co.sends(11_000_000 + GUARD_US - 1), false);
assert.equal(co.sends(11_000_000 + GUARD_US), true);
});
test("the holder of the next schedule's first turn starts its guard after the from", () => {
const co = page(A, [MARK, REFUSE]);
assert.equal(co.sends(7_000_000 + GUARD_US - 1), false);
assert.equal(co.sends(7_000_000 + GUARD_US), true);
});
test("the turns ahead step from the replaced turn to the next schedule's", () => {
const co = page(B, [MARK, REFUSE]);
const ahead = co.turnsAhead(6_000_000, 3).map((t) => [t.holder, t.start, t.end, t.mode]);
assert.deepEqual(ahead, [
[B, 5_000_000, 7_000_000, "mark"],
[A, 7_000_000, 11_000_000, "refuse"],
[B, 11_000_000, 15_000_000, "refuse"],
]);
});
test("a schedule stated later governs even where an earlier-stated one's from is later", () => {
// §6.1: the schedule in effect is the last stated of those whose from has come. REFUSE was
// stated first; MARK, stated after it, governs from 0 on and nothing replaces it.
const co = page(B, [REFUSE, MARK]);
const t = co.turnAt(6_900_000);
assert.equal(t.mode, "mark");
assert.equal(t.end, 10_000_000);
assert.equal(co.turnAt(8_000_000).mode, "mark");
});