diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index bb5149b807..157e44b470 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -126,6 +126,10 @@ Traps from this tick: - Lab scripts run under `sh`, which is dash: `$((10#$x))` fails with "expecting EOF". Strip leading zeros with `sed 's/^0*//'` and default with `${i:-0}`. - The Mac hook reads the whole Bash command. A heredoc PR body that names the report command gets refused. Write the body with the Write tool and pass `--body-file`. +**Holding the address when the voice runs long (t27#10629).** If an overlay must stay on screen for a minimum time and must also stay off a crossfade, then the crossfade has to move; the overlay cannot. Work out in frames the latest cut that still leaves the last scene its minimum: cut 1 is capped at `BEAT_CUT1_LAST_MS` = 13249. Prove it is the latest with a one-ms-later control. Only the voices the reel speeds up are affected, so the `cmp` diff is limited to those voices and to crossfade 1's offset. The cost is real: the crossfade starts up to 184 ms under the task's last word, and lesson 1's measured voice (1300/4200/6800) is one of the affected voices. Write a test that bounds that overlap. + +- Before trusting a "fast voice" grid result, work out which fixture voices are fast. `voice_room_ms(3)` is 12250 ms, so the measured lesson 1 voice (raw 12300) is already sped up. A change aimed at "fast voices" therefore changes the reel everyone actually sees. + ## Show a spec (the core: exporter for what it reads, by hand for the rest) 1. Load the spec: `mcp__t27__t27_spec` with `path` and `source: true`, or the file from a diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 6555450c57..ba43902687 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:30f4f017bc4dd4c32c1ec195d83f827f10e9d260f30056ebd6f444cf034ca3ea", - "gen_hash_rust": "sha256:cd6858c82060106802b3cced271e84811d8ee02b8c4f2ed18ba75cf8068861eb", - "gen_hash_verilog": "sha256:d9d8bbee11770df310cf863654c914b318ea03940780b5a9e116cec3f4e33991", - "gen_hash_zig": "sha256:f5762a5368a29d6b2978d7ea365deed7c9c7a0d4994814a3d6586c039fdcbf25", + "gen_hash_c": "sha256:97c582c40a1e76a50e35c68414ce2653739784f9b0e3b58006deede027640fb3", + "gen_hash_rust": "sha256:f3b086a0827512fdebe957c9bc73b30592914373a1dd9137e57efb851ebf720f", + "gen_hash_verilog": "sha256:cc184e56898034ed556e2e7c9d111c028f56d60bb6adb21952a6b105d9baa0c9", + "gen_hash_zig": "sha256:aa16bbf44971ec5a6eacbd80389bca963b739a76f888cf401df14cb51abea29d", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T19:36:34Z", + "sealed_at": "2026-10-18T20:05:15Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:b1ca187bbf04f6595be7dbf25b6a656286b07a86c50def9a0605c9ec56f017ec", + "spec_hash": "sha256:a380a3fec6de690924f36c45f40ba7aac1e002f4482fdb23d71e472357b16f19", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 25, - "total": 25, + "passed": 27, + "total": 27, "vacuous": 0 }, "toolchain": { diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 105c8f3c2b..77feba9cdc 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -209,6 +209,7 @@ {"path": "specs/claw/t27f_gate.t27", "t27b": "pass"}, {"path": "specs/claw/t27f_gate_pins.t27", "t27b": "pass"}, {"path": "specs/claw/text.t27", "t27b": "pass"}, + {"path": "specs/claw/token_shape.t27", "t27b": "pass"}, {"path": "specs/compiler/c_array_repeat_wrapper.t27", "t27b": "pass"}, {"path": "specs/compiler/c_fixed_array_return.t27", "t27b": "pass"}, {"path": "specs/compiler/codegen_conformance.t27", "t27b": "pass"}, diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index fcc8cd1800..8cc273e699 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -46,9 +46,11 @@ // the same places, and only their windows change with the beat. The cards are still on their // own frames over a crossfade: token_floor_mux prints their enable= and this file does not reach // it. Both cuts are centred on a sentence change, so a crossfade can begin under the last words -// of a phrase: mode 5 then drops a caption with too little room left (the cost tests), and on a -// voice the reel had to speed up it holds the address for less than REEL_CTA_MIN_MS. The scenes -// themselves are R3. +// of a phrase: mode 5 then drops a caption with too little room left (the cost tests). Cut 1 is +// capped at BEAT_CUT1_LAST_MS so mode 5 holds the address for REEL_CTA_MIN_MS (t27#10629): on a +// voice the reel had to speed up, lesson 1's measured voice among them, crossfade 1 then starts +// up to 184 ms before the task's last word ends, in modes 4 and 5 alike. The scenes themselves +// are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -709,11 +711,21 @@ pub fn beat_frames() -> i32 { // the open floor, a crossfade, and the main beat alone for BEAT_MIN_SOLO_MS. pub const BEAT_SPOKEN_MIN_MS : i32 = 2750; +// The latest cut 1 that still leaves the close beat's own frames REEL_CTA_MIN_MS to the reel's end +// (t27#10629): its crossfade starts on frame 389, so the close beat owns frames 405..449, 45 frames, +// 1500 ms. Mode 5 shows the address on those frames only, so a later cut holds it for less. +pub const BEAT_CUT1_LAST_MS : i32 = 13249; + // Cut k of lesson t's reel in language lang: 0 open to main, 1 main to close. Cut 0 is the goal's // start, held to the open floor, but never so late that the main beat loses its scene: when the // voice reaches the address before BEAT_SPOKEN_MIN_MS the open beat gives way, not the main one. +// Cut 1 is where the address takes over, but never after BEAT_CUT1_LAST_MS: on a voice the reel +// had to speed up the close beat comes in early, so the address keeps its hold in mode 5. pub fn beat_cut_ms(t: usize, lang: usize, m0: i32, m1: i32, m2: i32, k: usize) -> i32 { - const c1 = cta_from(t, lang, m0, m1, m2); + var c1 : i32 = cta_from(t, lang, m0, m1, m2); + if (c1 > BEAT_CUT1_LAST_MS) { + c1 = BEAT_CUT1_LAST_MS; + } if (k == 0) { var s : i32 = phrase_start(m0, m1, m2, 1); if (s < BEAT_OPEN_FLOOR_MS) { @@ -1217,8 +1229,10 @@ test "on lesson 1's measured voice the cuts fall where the sentence changes" { const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); assert(c0 == phrase_start(M_EN0, M_EN1, M_EN2, 1)); assert(c0 > BEAT_OPEN_FLOOR_MS); - assert(c1 == cta_from(0, 0, M_EN0, M_EN1, M_EN2)); - // the open beat ends after the title is spoken, and the close starts after the task is + // the reel speeds this voice up to the address's floor, so cut 1 is capped (t27#10629) + assert(cta_from(0, 0, M_EN0, M_EN1, M_EN2) > BEAT_CUT1_LAST_MS); + assert(c1 == BEAT_CUT1_LAST_MS); + // the open beat ends after the title is spoken, and the close is at least half in after the task is assert(c0 > phrase_start(M_EN0, M_EN1, M_EN2, 0) + played(M_EN0, M_EN1, M_EN2, 0)); assert(c1 >= phrase_start(M_EN0, M_EN1, M_EN2, 2) + played(M_EN0, M_EN1, M_EN2, 2)); } @@ -1708,9 +1722,40 @@ test "the cost on lesson 1's measured voice: a caption across a crossfade with n assert(dropped > 0); } -test "the cost: on a voice the reel speeds up, mode 5 holds the address for less than REEL_CTA_MIN_MS" { - var short : usize = 0; - var least : i32 = beat_frames(); +// ---- the address hold's tests (t27#10629) ---- + +// The voice's last spoken ms: the end of the last phrase, before its caption's tail. +fn spoken_end(m0: i32, m1: i32, m2: i32) -> i32 { + const n = phrases_for(m0, m1, m2); + return phrase_start(m0, m1, m2, n - 1) + played(m0, m1, m2, n - 1); +} + +// How many frames mode 5 shows the address: from its first kept frame to the reel's last. +fn cta_held(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> i32 { + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); + return beat_frames() - beat_first_lo(lo, beat_frames() - 1, f0, f1); +} + +test "BEAT_CUT1_LAST_MS is the latest cut 1 whose close beat owns REEL_CTA_MIN_MS" { + const f1 = beat_xfade_frame(BEAT_CUT1_LAST_MS); + assert(f1 == 389); + const own = beat_frames() - beat_scene_lo(BEAT_CLOSE, 0, f1); + assert(own == 45); + assert(own * 1000 >= REEL_CTA_MIN_MS * BASE_FPS); + // one ms later the crossfade starts a frame later and the close beat owns 44 frames + const g1 = beat_xfade_frame(BEAT_CUT1_LAST_MS + 1); + assert(g1 == 390); + assert((beat_frames() - beat_scene_lo(BEAT_CLOSE, 0, g1)) * 1000 < REEL_CTA_MIN_MS * BASE_FPS); + // the reel's address starts no later than the close beat could + assert(BEAT_CUT1_LAST_MS < REEL_MS - REEL_CTA_MIN_MS); +} + +test "mode 5 holds the address for REEL_CTA_MIN_MS, every lesson, both languages, every voice the clock accepts" { + var voices : usize = 0; + var moved : usize = 0; + var short_before : usize = 0; var t : usize = 0; while (t < TOPICS) { var lang : usize = 0; @@ -1725,15 +1770,26 @@ test "the cost: on a voice the reel speeds up, mode 5 holds the address for less const m1 = BEAT_GRID_M1[j]; const m2 = BEAT_GRID_M2[k]; if (voice_fits(m0, m1, m2)) { - const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); - const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); - const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); - const held = beat_frames() - beat_first_lo(lo, beat_frames() - 1, f0, f1); - if (held < least) { - least = held; + voices = voices + 1; + const cta = cta_from(t, lang, m0, m1, m2); + const c1 = beat_cut_ms(t, lang, m0, m1, m2, 1); + assert(cta_held(t, lang, m0, m1, m2) * 1000 >= REEL_CTA_MIN_MS * BASE_FPS); + if (c1 < cta) { + moved = moved + 1; + assert(c1 == BEAT_CUT1_LAST_MS); + } else { + assert(c1 == cta); + } + // control: the cut centred on the address, as before t27#10629 + const lo = beat_frame_from(cta); + var old_lo : i32 = beat_scene_lo(BEAT_CLOSE, 0, beat_xfade_frame(cta)); + if (lo > old_lo) { + old_lo = lo; } - if (held * 1000 < REEL_CTA_MIN_MS * BASE_FPS) { - short = short + 1; + if ((beat_frames() - old_lo) * 1000 < REEL_CTA_MIN_MS * BASE_FPS) { + short_before = short_before + 1; + // every voice the old cut held short is one the cap moves + assert(c1 < cta); } } k = k + 1; @@ -1746,7 +1802,55 @@ test "the cost: on a voice the reel speeds up, mode 5 holds the address for less } t = t + 1; } - assert(short > 0); - // never less than the close beat's own frames: its solo floor less the crossfade's last frame - assert(least >= MIN_SCENE_FRAMES - 1); + assert(voices > 0); + assert(short_before > 0); + assert(moved >= short_before); + // most voices end early enough that the cut stays on the address + assert(moved < voices); +} + +test "the cost: on a voice the cap moves, crossfade 1 starts under the task's last words, by at most 184 ms" { + const bound = REEL_MS - REEL_CTA_MIN_MS - CAPTION_TAIL_MS - beat_frame_ms(beat_xfade_frame(BEAT_CUT1_LAST_MS)); + assert(bound == 184); + var under : usize = 0; + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + var i : usize = 0; + while (i < 4) { + var j : usize = 0; + while (j < 4) { + var k : usize = 0; + while (k < 5) { + const m0 = BEAT_GRID_M0[i]; + const m1 = BEAT_GRID_M1[j]; + const m2 = BEAT_GRID_M2[k]; + if (voice_fits(m0, m1, m2)) { + const c1 = beat_cut_ms(t, lang, m0, m1, m2, 1); + const x1 = beat_frame_ms(beat_xfade_frame(c1)); + const e = spoken_end(m0, m1, m2); + if (x1 < e) { + under = under + 1; + // only a moved cut starts under the voice, and never by more than the bound + assert(c1 < cta_from(t, lang, m0, m1, m2)); + assert(e - x1 <= bound); + } + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(under > 0); + // lesson 1's measured voice is one of them: the reel speeds it up to the address's floor + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + assert(c1 == BEAT_CUT1_LAST_MS); + assert(cta_from(0, 0, M_EN0, M_EN1, M_EN2) > BEAT_CUT1_LAST_MS); + assert(spoken_end(M_EN0, M_EN1, M_EN2) > beat_frame_ms(beat_xfade_frame(c1))); }