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"); +});