From 29b982fbe2719f3d1c6729e0e5479f23658eaeaa Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 4 Nov 2026 17:40:39 -0500 Subject: [PATCH] joule-moq-web: a turn a later schedule replaces ends at that schedule's from, and a performer's page keeps its guard before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The room page took a turn's end from its own schedule only. When a lead's new schedule took effect in the middle of a turn, the page of the performer holding it went on sending up to the new schedule's `from`, past its guard (co-performance §8). The relay judged its last objects on arrival, under the schedule they reached it in, and forwarded them. The room's clock, a hop later, logged them after the `from`, under the new schedule. Where the new schedule refuses and gives that turn to someone else, the room with the clock's log then refuses as `turn_violated`. How often depends on that hop: rarely on a quiet host, and once in #619's browser run, on the shared build host under load (the room e2e's refusing schedule takes effect in the middle of the marking schedule's sixth turn, which the other holder held). Now: - A turn ends at its segment's boundary, or at the `from` of a later schedule, if that comes first (§6.1: "A later schedule replaces an earlier one from its `from` on"). - The guard applies before either. - The turns ahead step from the cut turn to the new schedule's first turn. Before, they jumped to the old turn's end and skipped the new schedule's turns up to it. Tests: - `web/room/co.test.mjs` (new, run by `e2e/run.sh` with the page's other module tests): six cases. Against the old `co.js`, three of them fail: the turn's end, the guard before the `from`, and the turns ahead. - The room e2e names an object it refuses: its arrival, and how far it is from the nearest turn boundary or schedule `from`. If the check fails again, it says which race it was. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-web/e2e/room.e2e.mjs | 13 ++- .../crates/joule-moq-web/e2e/run.sh | 2 +- .../crates/joule-moq-web/web/room/co.js | 27 +++++-- .../crates/joule-moq-web/web/room/co.test.mjs | 81 +++++++++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.test.mjs diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 01cfe74ad..8377519fa 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -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); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index 25c3cf8fc..0db6ba69d 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -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=() diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.js index cc84b229e..74d0ba674 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.js @@ -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; diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.test.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.test.mjs new file mode 100644 index 000000000..0e4f44a69 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/co.test.mjs @@ -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"); +}); -- 2.54.0 (Apple Git-157)