diff --git a/specs/OWNERS.md b/specs/OWNERS.md index 2cbe2afd95..02869bc61b 100644 --- a/specs/OWNERS.md +++ b/specs/OWNERS.md @@ -28,7 +28,7 @@ | `docs/` | **Z-Zeta** (with **T-Queen** for `chapters/queen.t27`) | The system documentation of `t27.ai/#/docs` as `.t27` specs (`docs/system.t27`, `KIND = "docs"`; `docs/chapters/.t27`, `KIND = "docs-chapter"`): chapter ids, sections, sources, the figure and table the site generates; English prose in `docs/system/.md`, Russian via `i18n/docs-ru.t27` | | `ui/` | **T-Queen** | The site's viewport contract as a `.t27` spec (`ui/viewport.t27`, `KIND = "viewport"`): tiers by CSS viewport width, panes per tier, the 44 px touch minimum, the six-size QA matrix and the Queen rail capacity read from `Queen.css`; the site (gHashTag/trinity) generates `viewport.generated.ts` / `.css` from a vendored copy and evaluates the spec's own test blocks; `ui/queen_evidence.t27` (`module ui_queen_evidence`): the evidence constants of the Queen's FFN and Ternary Check panel, from which the site generates `queenEvidence.generated.ts`; canonical here since S10, byte-identical to the copy the site carried alone | | `catalog/` | **T-Queen** | How the site's spec catalog and the Queen map find the repositories that write `.t27` (`catalog/discovery.t27`, `KIND = "discovery"`): the GitHub owners scanned, the exclusions, what makes a candidate qualify (a declaration in the compiler's AST, not a file extension) and the limits; the site (gHashTag/trinity) vendors a byte-identical copy and `scripts/discover-t27-worlds.mjs` reads it through the compiler wasm before touching GitHub | -| `t27f/` | **T-Queen** | The T27F topic-to-value engine of gHashTag/trinity#1677 (`t27f/topic_engine.t27`, module `T27fTopicEngine`): the 3 x 9 = 27 lesson shape, intake and gap rules, keyword routing to one domain, the Bee dispatch cap, the evidence and approval gates, and progression that is never money; driven by the Claude Code skill `.claude/skills/t27f/SKILL.md` through the t27 MCP server | +| `t27f/` | **T-Queen** | The T27F topic-to-value engine of gHashTag/trinity#1677 (`t27f/topic_engine.t27`, module `T27fTopicEngine`): the 3 x 9 = 27 lesson shape, intake and gap rules, keyword routing to one domain, the Bee dispatch cap, the evidence and approval gates, and progression that is never money; driven by the Claude Code skill `.claude/skills/t27f/SKILL.md` through the t27 MCP server; `t27f/reel.t27` (module `T27fReel`): the video format of a topic map and the still views of a Claude Code answer -- honeycomb geometry, focus+context camera, scene catalog, per-target limits, gap styling, the posting gate and the channel per host; numbers only, a host draws them | | `trinity/` | **T-Queen** + **A-Architect** | The project manifest of gHashTag/trinity as `.t27` specs (`trinity/project.t27`, `KIND = "project"`; `trinity/capabilities/.t27`, `KIND = "capability"`): the pinned consumer revision, profiles, dispositions and evidence tags, dialect and build counts, dependency pins and the work packages of gHashTag/trinity#988; each card names its owner, canonical spec, implementation and generated paths, build targets, acceptance command and evidence; `tools/trinity_manifest.py` derives the inventory from a clean pinned checkout and holds the cards to it; `trinity/compiler_matrix.t27` (`KIND = "compiler-matrix"`) names the pinned compiler, the backends, the stages and the features of the executable subset with their fixtures under `bootstrap/tests/fixtures/trinity_matrix/`, and `tools/trinity_compiler_matrix.py` measures them; `trinity/build_graph.t27` (`KIND = "build-graph"`) declares the consumer's toolchain, pins, profiles, generated-file boundaries and receipt policy, and `tools/trinity_build_receipt.py` derives the graph, writes the receipts and judges drift; the consumer vendors a byte-identical copy and generates its manifest from it (S12). Shared contracts stay in their own subtrees (`numeric/`, `vsa/`, `queen/`, `tools/`, `docs/`, `ui/`) and are cited, never copied | Each subtree with substantial churn should keep a local **`OWNERS.md`** (see below). diff --git a/specs/t27f/reel.t27 b/specs/t27f/reel.t27 new file mode 100644 index 0000000000..945445d19b --- /dev/null +++ b/specs/t27f/reel.t27 @@ -0,0 +1,663 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27f/reel.t27 -- the T27F reel: one video format for a topic map, and the views it is cut from +; (gHashTag/trinity#1648 honeycomb focus+context, gHashTag/trinity#1677 the 27-lesson map). +; A reel is a short ordered list of scenes over the 27-cell honeycomb of a journey. Every frame of it +; is a pure function of (reel, frame number, preset): which scene plays, where the camera stands, where +; each cell lands on screen and how it is styled. The same numbers drive three outputs: +; - a video (GIF for X, MP4) rendered frame by frame; +; - one still per scene, for a chat answer in Claude Code; +; - a live player on t27.ai that seeks to any frame. +; Nothing here draws pixels or builds markup. This file decides the numbers; a host turns them into +; pixels. Two measured limits of t27c (2026-10-17, lab, t27c 90fc25b3b) make that split necessary, +; not a choice: gen-js and gen-ts lower declarations but not function bodies, and the Zig backend +; refuses to concatenate strings at run time. gen-c lowers these bodies. +; Rules the format holds, each with a test below: +; - focus+context: zooming into a cell keeps every neighbour cell inside the frame (#1648); +; - no jump: a scene starts where the previous one ended and eases to its own camera; +; - honesty: a GAP cell is never drawn in a verified style, and an unknown status is drawn unknown; +; - an allowlist: a scene kind outside the catalog makes the whole reel invalid, never skipped; +; - limits per target: a reel too long for its target is refused, not cut; +; - posting a reel is publishing: it needs a human yes (T27fTopicEngine ACTION_PUBLISH). +; Where a still reaches the user in Claude Code (documented 2026-10-17, code.claude.com/docs/en/mcp +; and the MCP Apps quickstart): the terminal CLI does not render MCP Apps, and an image block in a tool +; result reaches the model but is not documented to reach the user. A file on disk with its path in the +; answer reaches the user everywhere; the desktop Code tab opens it in its Browser pane. CHANNEL_* below. +; Not claimed: no host imports this file yet, no renderer exists, no reel has been rendered. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module T27fReel; + +/// The honeycomb is the 27-lesson map of T27fTopicEngine: one row per lens, nine cells per row. +pub const ROWS : i32 = 3; +pub const COLS : i32 = 9; +pub const CELL_COUNT : i32 = 27; +pub const NO_CELL : i32 = -1; + +/// Pointy-top hexagons in world units. HEX_W is round(sqrt(3) * HEX_R); rows step by 1.5 * HEX_R and +/// odd rows shift right by half a cell. +pub const HEX_R : i32 = 100; +pub const HEX_W : i32 = 173; +pub const HALF_W : i32 = 87; +pub const ROW_STEP : i32 = 150; + +/// The camera that shows the whole map: centre and half width in world units. +pub const OVERVIEW_CX : i32 = 822; +pub const OVERVIEW_CY : i32 = 250; +pub const OVERVIEW_HW : i32 = 880; +/// The camera on one cell: half width. The focused cell fills about a quarter of the frame width and +/// its six neighbours stay in view (test focus_keeps_neighbours). +pub const FOCUS_HW : i32 = 340; + +/// Frame presets, width x height in pixels. +pub const PRESET_WIDE : u8 = 0; +pub const PRESET_SQUARE : u8 = 1; +pub const PRESET_TALL : u8 = 2; +pub const PRESET_COUNT : u8 = 3; +pub const PRESET_W : [3]i32 = [1280, 1080, 1080]; +pub const PRESET_H : [3]i32 = [720, 1080, 1920]; + +/// Scene kinds: the catalog. A reel may use these and nothing else. +pub const SCENE_TITLE : u8 = 0; +pub const SCENE_OVERVIEW : u8 = 1; +pub const SCENE_LENS : u8 = 2; +pub const SCENE_FOCUS : u8 = 3; +pub const SCENE_EVIDENCE : u8 = 4; +pub const SCENE_CALL : u8 = 5; +pub const SCENE_KIND_COUNT : u8 = 6; +pub const SCENE_NAMES : str = "title,overview,lens,focus,evidence,call"; + +/// Timing. The timeline counts base frames; a target samples every FRAME_STEP-th of them. +pub const BASE_FPS : i32 = 30; +pub const TRANSITION_FRAMES : i32 = 15; +pub const MIN_SCENE_FRAMES : i32 = 30; +pub const MAX_SCENES : i32 = 9; +pub const END_OF_REEL : i32 = -1; + +/// Output targets. GIF and MP4 are for posting; STILLS is one frame per scene for a chat answer. +pub const TARGET_GIF : u8 = 0; +pub const TARGET_MP4 : u8 = 1; +pub const TARGET_STILLS : u8 = 2; +pub const TARGET_COUNT : u8 = 3; +pub const TARGET_FPS : [3]i32 = [15, 30, 0]; +pub const TARGET_MAX_SECONDS : [3]i32 = [30, 140, 0]; + +/// Cell styles. A host owns the palette; this file only decides which style a cell gets. +/// Statuses in are the T27fTopicEngine STATUS_* codes (gap 0, locked 1, open 2, draft 3, +/// verified 4, published 5); anything else is UNKNOWN. +pub const STYLE_GAP : u8 = 0; +pub const STYLE_MUTED : u8 = 1; +pub const STYLE_OPEN : u8 = 2; +pub const STYLE_DRAFT : u8 = 3; +pub const STYLE_VERIFIED : u8 = 4; +pub const STYLE_PUBLISHED : u8 = 5; +pub const STYLE_UNKNOWN : u8 = 6; +pub const STATUS_CODE_COUNT : u8 = 6; + +/// Where a still goes, by host. See the header for the sources. +pub const HOST_CLAUDE_CODE_CLI : u8 = 0; +pub const HOST_CLAUDE_CODE_DESKTOP : u8 = 1; +pub const HOST_MCP_APPS : u8 = 2; +pub const HOST_SITE : u8 = 3; +pub const CHANNEL_FILE_PATH : u8 = 0; +pub const CHANNEL_FILE_AND_PANE : u8 = 1; +pub const CHANNEL_UI_RESOURCE : u8 = 2; +pub const CHANNEL_LIVE_PLAYER : u8 = 3; + +/// The demo reel the tests walk: overview, the structure lens, a zoom into step 10, its evidence, the link. +pub const DEMO_COUNT : i32 = 5; +pub const DEMO_KIND : [9]u8 = [1, 2, 3, 4, 5, 0, 0, 0, 0]; +pub const DEMO_CELL : [9]i32 = [-1, -1, 10, 10, -1, -1, -1, -1, -1]; +pub const DEMO_LENS : [9]i32 = [-1, 0, -1, -1, -1, -1, -1, -1, -1]; +pub const DEMO_FRAMES : [9]i32 = [60, 75, 90, 75, 60, 0, 0, 0, 0]; + +/// --- the map --- + +pub fn cell_row(cell: i32) -> i32 { + return @divTrunc(cell, COLS); +} + +pub fn cell_col(cell: i32) -> i32 { + return @rem(cell, COLS); +} + +pub fn valid_cell(cell: i32) -> bool { + return cell >= 0 and cell < CELL_COUNT; +} + +pub fn cell_x(cell: i32) -> i32 { + const row : i32 = cell_row(cell); + var shift : i32 = 0; + if (@rem(row, 2) == 1) { + shift = HALF_W; + } + return cell_col(cell) * HEX_W + shift + HALF_W; +} + +pub fn cell_y(cell: i32) -> i32 { + return cell_row(cell) * ROW_STEP + HEX_R; +} + +/// Are two cells adjacent on the honeycomb? Odd rows are shifted right, so their diagonal neighbours +/// sit one column further right than an even row's. +pub fn neighbours(a: i32, b: i32) -> bool { + if (valid_cell(a) == false or valid_cell(b) == false or a == b) { + return false; + } + const ra : i32 = cell_row(a); + const ca : i32 = cell_col(a); + const rb : i32 = cell_row(b); + const cb : i32 = cell_col(b); + if (ra == rb) { + return cb == ca + 1 or cb == ca - 1; + } + if (rb != ra + 1 and rb != ra - 1) { + return false; + } + if (@rem(ra, 2) == 1) { + return cb == ca or cb == ca + 1; + } + return cb == ca or cb == ca - 1; +} + +pub fn neighbour_count(cell: i32) -> i32 { + var n : i32 = 0; + var k : i32 = 0; + while (k < CELL_COUNT) { + if (neighbours(cell, k)) { + n = n + 1; + } + k = k + 1; + } + return n; +} + +/// --- the camera --- + +/// Smoothstep on a permille clock: 0 at 0, 1000 at 1000, flat at both ends. +pub fn ease(t: i32) -> i32 { + var u : i64 = t; + if (u < 0) { + u = 0; + } + if (u > 1000) { + u = 1000; + } + const v : i64 = @divTrunc(u * u * (3000 - 2 * u), 1000000); + return (v) as i32; +} + +pub fn lerp(a: i32, b: i32, t: i32) -> i32 { + const d : i64 = b - a; + const s : i64 = @divTrunc(d * ease(t), 1000); + return a + (s) as i32; +} + +pub fn lens_cy(lens: i32) -> i32 { + return lens * ROW_STEP + HEX_R; +} + +/// Where a scene's camera settles: X, Y and half width. EVIDENCE stays on its cell, so the evidence +/// panel is read next to the thing it proves. +pub fn target_cx(kind: u8, cell: i32) -> i32 { + if ((kind == SCENE_FOCUS or kind == SCENE_EVIDENCE) and valid_cell(cell)) { + return cell_x(cell); + } + return OVERVIEW_CX; +} + +pub fn target_cy(kind: u8, cell: i32, lens: i32) -> i32 { + if ((kind == SCENE_FOCUS or kind == SCENE_EVIDENCE) and valid_cell(cell)) { + return cell_y(cell); + } + if (kind == SCENE_LENS and lens >= 0 and lens < ROWS) { + return lens_cy(lens); + } + return OVERVIEW_CY; +} + +pub fn target_hw(kind: u8, cell: i32) -> i32 { + if ((kind == SCENE_FOCUS or kind == SCENE_EVIDENCE) and valid_cell(cell)) { + return FOCUS_HW; + } + return OVERVIEW_HW; +} + +/// Half height follows the preset's aspect. +pub fn half_height(hw: i32, preset: u8) -> i32 { + const h : i64 = hw; + return (@divTrunc(h * PRESET_H[preset], PRESET_W[preset])) as i32; +} + +/// Is a world point inside the camera's frame? +pub fn in_view(cx: i32, cy: i32, hw: i32, preset: u8, x: i32, y: i32) -> bool { + const hh : i32 = half_height(hw, preset); + return x >= cx - hw and x <= cx + hw and y >= cy - hh and y <= cy + hh; +} + +/// World to screen, in pixels. +pub fn screen_x(cx: i32, hw: i32, preset: u8, x: i32) -> i32 { + const d : i64 = x - cx; + return (@divTrunc(d * PRESET_W[preset], 2 * hw)) as i32 + @divTrunc(PRESET_W[preset], 2); +} + +pub fn screen_y(cy: i32, hw: i32, preset: u8, y: i32) -> i32 { + const d : i64 = y - cy; + return (@divTrunc(d * PRESET_W[preset], 2 * hw)) as i32 + @divTrunc(PRESET_H[preset], 2); +} + +pub fn screen_r(hw: i32, preset: u8) -> i32 { + const r : i64 = HEX_R; + return (@divTrunc(r * PRESET_W[preset], 2 * hw)) as i32; +} + +/// --- the reel --- + +pub fn valid_kind(kind: u8) -> bool { + return kind < SCENE_KIND_COUNT; +} + +/// A scene is valid when its kind is in the catalog, it is long enough to finish its transition, and +/// a FOCUS or EVIDENCE scene names a real cell and a LENS scene a real lens. +pub fn valid_scene(kind: u8, cell: i32, lens: i32, frames: i32) -> bool { + if (valid_kind(kind) == false or frames < MIN_SCENE_FRAMES) { + return false; + } + if ((kind == SCENE_FOCUS or kind == SCENE_EVIDENCE) and valid_cell(cell) == false) { + return false; + } + if (kind == SCENE_LENS and (lens < 0 or lens >= ROWS)) { + return false; + } + return true; +} + +pub fn total_frames(count: i32, frames: [9]i32) -> i32 { + var t : i32 = 0; + var k : i32 = 0; + while (k < count) { + t = t + frames[@intCast(k)]; + k = k + 1; + } + return t; +} + +/// Does a reel of this many base frames fit a target? STILLS has no length limit: one frame per scene. +pub fn fits_target(base_frames: i32, target: u8) -> bool { + if (target >= TARGET_COUNT) { + return false; + } + if (target == TARGET_STILLS) { + return true; + } + return base_frames <= TARGET_MAX_SECONDS[target] * BASE_FPS; +} + +/// The whole reel is valid or it is not; one bad scene refuses it. +pub fn valid_reel(count: i32, kind: [9]u8, cell: [9]i32, lens: [9]i32, frames: [9]i32, target: u8) -> bool { + if (count < 1 or count > MAX_SCENES) { + return false; + } + var k : i32 = 0; + while (k < count) { + if (valid_scene(kind[@intCast(k)], cell[@intCast(k)], lens[@intCast(k)], frames[@intCast(k)]) == false) { + return false; + } + k = k + 1; + } + return fits_target(total_frames(count, frames), target); +} + +/// Which scene plays at base frame f, or END_OF_REEL. +pub fn scene_at(count: i32, frames: [9]i32, f: i32) -> i32 { + if (f < 0) { + return END_OF_REEL; + } + var start : i32 = 0; + var k : i32 = 0; + while (k < count) { + if (f < start + frames[@intCast(k)]) { + return k; + } + start = start + frames[@intCast(k)]; + k = k + 1; + } + return END_OF_REEL; +} + +pub fn scene_start(frames: [9]i32, scene: i32) -> i32 { + return total_frames(scene, frames); +} + +/// Transition progress on the permille clock: 0 at a scene's first frame, 1000 once +/// TRANSITION_FRAMES have passed. The first scene has nothing to come from and starts settled. +pub fn transition_t(frames: [9]i32, scene: i32, f: i32) -> i32 { + if (scene <= 0) { + return 1000; + } + const local : i32 = f - scene_start(frames, scene); + if (local >= TRANSITION_FRAMES) { + return 1000; + } + return @divTrunc(local * 1000, TRANSITION_FRAMES); +} + +/// The camera at base frame f: the previous scene's settled camera eased into this scene's. +pub fn camera_cx(count: i32, kind: [9]u8, cell: [9]i32, frames: [9]i32, f: i32) -> i32 { + const s : i32 = scene_at(count, frames, f); + if (s == END_OF_REEL) { + return OVERVIEW_CX; + } + const to : i32 = target_cx(kind[@intCast(s)], cell[@intCast(s)]); + if (s == 0) { + return to; + } + return lerp(target_cx(kind[@intCast(s - 1)], cell[@intCast(s - 1)]), to, transition_t(frames, s, f)); +} + +pub fn camera_cy(count: i32, kind: [9]u8, cell: [9]i32, lens: [9]i32, frames: [9]i32, f: i32) -> i32 { + const s : i32 = scene_at(count, frames, f); + if (s == END_OF_REEL) { + return OVERVIEW_CY; + } + const to : i32 = target_cy(kind[@intCast(s)], cell[@intCast(s)], lens[@intCast(s)]); + if (s == 0) { + return to; + } + return lerp(target_cy(kind[@intCast(s - 1)], cell[@intCast(s - 1)], lens[@intCast(s - 1)]), to, transition_t(frames, s, f)); +} + +pub fn camera_hw(count: i32, kind: [9]u8, cell: [9]i32, frames: [9]i32, f: i32) -> i32 { + const s : i32 = scene_at(count, frames, f); + if (s == END_OF_REEL) { + return OVERVIEW_HW; + } + const to : i32 = target_hw(kind[@intCast(s)], cell[@intCast(s)]); + if (s == 0) { + return to; + } + return lerp(target_hw(kind[@intCast(s - 1)], cell[@intCast(s - 1)]), to, transition_t(frames, s, f)); +} + +/// The frame a STILLS target takes from scene s: its last frame, after the camera has settled. +pub fn still_frame(frames: [9]i32, scene: i32) -> i32 { + return scene_start(frames, scene) + frames[@intCast(scene)] - 1; +} + +/// Base frames a target samples: every FRAME_STEP-th. +pub fn frame_step(target: u8) -> i32 { + if (target >= TARGET_COUNT or TARGET_FPS[target] <= 0) { + return 0; + } + return @divTrunc(BASE_FPS, TARGET_FPS[target]); +} + +/// --- honesty and gates --- + +/// The style of a cell from its lesson status. Only VERIFIED and PUBLISHED get their own styles; a gap +/// is drawn as a gap, and a code this file does not know is drawn unknown, never as progress. +pub fn cell_style(status: u8) -> u8 { + if (status == 0) { + return STYLE_GAP; + } + if (status == 1) { + return STYLE_MUTED; + } + if (status == 2) { + return STYLE_OPEN; + } + if (status == 3) { + return STYLE_DRAFT; + } + if (status == 4) { + return STYLE_VERIFIED; + } + if (status == 5) { + return STYLE_PUBLISHED; + } + return STYLE_UNKNOWN; +} + +pub fn shows_progress(style: u8) -> bool { + return style == STYLE_VERIFIED or style == STYLE_PUBLISHED; +} + +/// Posting a GIF or an MP4 is publishing, so it needs a human yes; a still for the chat does not. +pub fn export_needs_approval(target: u8) -> bool { + return target == TARGET_GIF or target == TARGET_MP4; +} + +pub fn export_allowed(target: u8, approved: bool) -> bool { + if (target >= TARGET_COUNT) { + return false; + } + if (export_needs_approval(target)) { + return approved; + } + return true; +} + +/// How a still reaches the user on each host. +pub fn channel_for(host: u8) -> u8 { + if (host == HOST_CLAUDE_CODE_DESKTOP) { + return CHANNEL_FILE_AND_PANE; + } + if (host == HOST_MCP_APPS) { + return CHANNEL_UI_RESOURCE; + } + if (host == HOST_SITE) { + return CHANNEL_LIVE_PLAYER; + } + return CHANNEL_FILE_PATH; +} + +/// --- tests --- + +test map_geometry { + assert cell_x(0) == 87; + assert cell_y(0) == 100; + assert cell_x(9) == 174; + assert cell_y(9) == 250; + assert cell_x(26) == 1471; + assert cell_y(26) == 400; + assert valid_cell(27) == false; + assert valid_cell(NO_CELL) == false; +} + +test honeycomb_neighbours { + // an inner cell of the middle row has six neighbours, a corner has two or three + assert neighbour_count(13) == 6; + assert neighbour_count(0) == 2; + assert neighbour_count(8) == 3; + assert neighbours(10, 1) == true; + assert neighbours(10, 2) == true; + assert neighbours(10, 19) == true; + assert neighbours(10, 20) == true; + assert neighbours(10, 0) == false; + assert neighbours(1, 10) == true; + assert neighbours(10, 10) == false; +} + +test focus_keeps_neighbours { + // #1648: zooming into a cell must not detach it from the map; on every preset all six + // neighbours of an inner cell stay in the frame + var p : u8 = 0; + while (p < PRESET_COUNT) { + var k : i32 = 0; + var seen : i32 = 0; + while (k < CELL_COUNT) { + if (neighbours(13, k) and in_view(cell_x(13), cell_y(13), FOCUS_HW, p, cell_x(k), cell_y(k))) { + seen = seen + 1; + } + k = k + 1; + } + assert seen == 6; + p = p + 1; + } +} + +test focus_enlarges_the_cell { + // the focused cell is drawn bigger than in the overview, at least twice as big + assert screen_r(FOCUS_HW, PRESET_WIDE) >= 2 * screen_r(OVERVIEW_HW, PRESET_WIDE); + assert screen_x(cell_x(13), FOCUS_HW, PRESET_WIDE, cell_x(13)) == 640; + assert screen_y(cell_y(13), FOCUS_HW, PRESET_WIDE, cell_y(13)) == 360; +} + +test overview_shows_the_whole_map { + var p : u8 = 0; + while (p < PRESET_COUNT) { + var k : i32 = 0; + while (k < CELL_COUNT) { + assert in_view(OVERVIEW_CX, OVERVIEW_CY, OVERVIEW_HW, p, cell_x(k), cell_y(k)); + k = k + 1; + } + p = p + 1; + } +} + +test ease_is_bounded_and_monotone { + assert ease(0) == 0; + assert ease(1000) == 1000; + assert ease(500) == 500; + assert ease(-5) == 0; + assert ease(2000) == 1000; + var t : i32 = 0; + while (t < 1000) { + assert ease(t + 10) >= ease(t); + t = t + 10; + } +} + +test demo_reel_is_valid { + assert valid_reel(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_LENS, DEMO_FRAMES, TARGET_GIF); + assert valid_reel(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_LENS, DEMO_FRAMES, TARGET_MP4); + assert valid_reel(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_LENS, DEMO_FRAMES, TARGET_STILLS); + assert total_frames(DEMO_COUNT, DEMO_FRAMES) == 360; + assert frame_step(TARGET_GIF) == 2; + assert frame_step(TARGET_MP4) == 1; + assert frame_step(TARGET_STILLS) == 0; +} + +test unknown_scene_refuses_the_reel { + const kind : [9]u8 = [1, 7, 3, 0, 0, 0, 0, 0, 0]; + const cell : [9]i32 = [-1, -1, 10, -1, -1, -1, -1, -1, -1]; + const lens : [9]i32 = [-1, -1, -1, -1, -1, -1, -1, -1, -1]; + const frames : [9]i32 = [60, 60, 60, 0, 0, 0, 0, 0, 0]; + assert valid_reel(3, kind, cell, lens, frames, TARGET_STILLS) == false; +} + +test malformed_scenes_refuse_the_reel { + const lens : [9]i32 = [-1, -1, -1, -1, -1, -1, -1, -1, -1]; + const frames : [9]i32 = [60, 60, 0, 0, 0, 0, 0, 0, 0]; + // a focus with no cell + const k1 : [9]u8 = [1, 3, 0, 0, 0, 0, 0, 0, 0]; + const c1 : [9]i32 = [-1, -1, -1, -1, -1, -1, -1, -1, -1]; + assert valid_reel(2, k1, c1, lens, frames, TARGET_STILLS) == false; + // a focus on a cell past the map + const c2 : [9]i32 = [-1, 27, -1, -1, -1, -1, -1, -1, -1]; + assert valid_reel(2, k1, c2, lens, frames, TARGET_STILLS) == false; + // a lens scene with no lens + const k3 : [9]u8 = [1, 2, 0, 0, 0, 0, 0, 0, 0]; + assert valid_reel(2, k3, c1, lens, frames, TARGET_STILLS) == false; + // a scene too short to finish its transition + const short : [9]i32 = [60, 20, 0, 0, 0, 0, 0, 0, 0]; + assert valid_reel(2, k1, c2, lens, short, TARGET_STILLS) == false; + // no scenes, or more than the format holds + assert valid_reel(0, k1, c1, lens, frames, TARGET_STILLS) == false; + assert valid_reel(10, k1, c1, lens, frames, TARGET_STILLS) == false; +} + +test too_long_is_refused_not_cut { + const kind : [9]u8 = [1, 1, 1, 1, 1, 1, 1, 1, 1]; + const cell : [9]i32 = [-1, -1, -1, -1, -1, -1, -1, -1, -1]; + const lens : [9]i32 = [-1, -1, -1, -1, -1, -1, -1, -1, -1]; + // 9 x 120 base frames = 36 s: over the GIF cap of 30 s, inside MP4's 140 s + const frames : [9]i32 = [120, 120, 120, 120, 120, 120, 120, 120, 120]; + assert valid_reel(9, kind, cell, lens, frames, TARGET_GIF) == false; + assert valid_reel(9, kind, cell, lens, frames, TARGET_MP4) == true; + assert fits_target(900, TARGET_GIF) == true; + assert fits_target(901, TARGET_GIF) == false; +} + +test timeline_lookup { + assert scene_at(DEMO_COUNT, DEMO_FRAMES, 0) == 0; + assert scene_at(DEMO_COUNT, DEMO_FRAMES, 59) == 0; + assert scene_at(DEMO_COUNT, DEMO_FRAMES, 60) == 1; + assert scene_at(DEMO_COUNT, DEMO_FRAMES, 135) == 2; + assert scene_at(DEMO_COUNT, DEMO_FRAMES, 359) == 4; + assert scene_at(DEMO_COUNT, DEMO_FRAMES, 360) == END_OF_REEL; + assert scene_at(DEMO_COUNT, DEMO_FRAMES, -1) == END_OF_REEL; + assert still_frame(DEMO_FRAMES, 2) == 224; +} + +test camera_never_jumps { + // at every scene boundary the camera starts where the previous scene settled, and it reaches the + // new scene's camera once the transition is over + var s : i32 = 1; + while (s < DEMO_COUNT) { + const b : i32 = scene_start(DEMO_FRAMES, s); + assert camera_cx(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, b) == camera_cx(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, b - 1); + assert camera_cy(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_LENS, DEMO_FRAMES, b) == camera_cy(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_LENS, DEMO_FRAMES, b - 1); + assert camera_hw(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, b) == camera_hw(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, b - 1); + const settled : i32 = b + TRANSITION_FRAMES; + assert camera_hw(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, settled) == target_hw(DEMO_KIND[@intCast(s)], DEMO_CELL[@intCast(s)]); + s = s + 1; + } +} + +test zoom_lands_on_the_cell { + // the demo zooms into step 10 in scene 2; its still is centred on that cell at focus scale + const f : i32 = still_frame(DEMO_FRAMES, 2); + assert camera_cx(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, f) == cell_x(10); + assert camera_cy(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_LENS, DEMO_FRAMES, f) == cell_y(10); + assert camera_hw(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, f) == FOCUS_HW; + // halfway through the transition the camera is between overview and focus + const mid : i32 = scene_start(DEMO_FRAMES, 2) + 7; + const hw : i32 = camera_hw(DEMO_COUNT, DEMO_KIND, DEMO_CELL, DEMO_FRAMES, mid); + assert hw < OVERVIEW_HW; + assert hw > FOCUS_HW; +} + +test gaps_never_look_verified { + assert cell_style(0) == STYLE_GAP; + assert shows_progress(cell_style(0)) == false; + assert shows_progress(cell_style(3)) == false; + assert shows_progress(cell_style(4)) == true; + assert shows_progress(cell_style(5)) == true; + assert cell_style(6) == STYLE_UNKNOWN; + assert cell_style(255) == STYLE_UNKNOWN; + assert shows_progress(cell_style(255)) == false; +} + +test posting_needs_a_human_yes { + assert export_allowed(TARGET_GIF, false) == false; + assert export_allowed(TARGET_MP4, false) == false; + assert export_allowed(TARGET_GIF, true) == true; + assert export_allowed(TARGET_STILLS, false) == true; + assert export_allowed(TARGET_COUNT, true) == false; +} + +test stills_reach_the_user { + // the CLI gets a file path; the desktop Code tab opens the same file in its pane; only an MCP Apps + // host gets a ui resource + assert channel_for(HOST_CLAUDE_CODE_CLI) == CHANNEL_FILE_PATH; + assert channel_for(HOST_CLAUDE_CODE_DESKTOP) == CHANNEL_FILE_AND_PANE; + assert channel_for(HOST_MCP_APPS) == CHANNEL_UI_RESOURCE; + assert channel_for(HOST_SITE) == CHANNEL_LIVE_PLAYER; + assert channel_for(9) == CHANNEL_FILE_PATH; +} + +invariant catalog_is_closed { + assert SCENE_KIND_COUNT == 6 and valid_kind(SCENE_CALL) and valid_kind(SCENE_KIND_COUNT) == false; +} + +invariant map_is_the_27_lessons { + assert ROWS * COLS == CELL_COUNT and CELL_COUNT == 27; +} + +invariant transition_fits_every_scene { + assert TRANSITION_FRAMES < MIN_SCENE_FRAMES; +} + +invariant targets_divide_the_base_rate { + assert @rem(BASE_FPS, TARGET_FPS[TARGET_GIF]) == 0 and @rem(BASE_FPS, TARGET_FPS[TARGET_MP4]) == 0; +}