diff --git a/examples/ai-chat-ts/src/core.ts b/examples/ai-chat-ts/src/core.ts index 9bb5afac8..f4f965d08 100644 --- a/examples/ai-chat-ts/src/core.ts +++ b/examples/ai-chat-ts/src/core.ts @@ -98,20 +98,28 @@ function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft { if (clamped === null) return d; const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT); if (nextClamped === null) return d; + // Composition bounds land in i64-classed slots: bind them, guard + // the range (an ordered comparison excludes NaN), and state + // wholeness with Math.trunc at the write; -1 stays the no-composition + // sentinel. + const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1; + const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1; return { bytes: nextClamped.text, anchor: nextClamped.selection.anchor, focus: nextClamped.selection.focus, - compStart: nextClamped.composition !== null ? nextClamped.composition.start : -1, - compEnd: nextClamped.composition !== null ? nextClamped.composition.end : -1, + compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1, + compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1, }; } + const nextStart = next.composition !== null ? next.composition.start : -1; + const nextEnd = next.composition !== null ? next.composition.end : -1; return { bytes: next.text, anchor: next.selection.anchor, focus: next.selection.focus, - compStart: next.composition !== null ? next.composition.start : -1, - compEnd: next.composition !== null ? next.composition.end : -1, + compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1, + compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1, }; } @@ -297,7 +305,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { { ...model, turns: turns, - nextId: model.nextId + 1, + nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991, phase: "sending", failReason: new Uint8Array(0), draft: composerInit(), @@ -367,7 +375,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { return [{ ...model, turns: [...model.turns, { id: model.nextId, role: "assistant", text: content }], - nextId: model.nextId + 1, + nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991, phase: "idle", chatScrollTop: SCROLL_BOTTOM, }, Cmd.none]; diff --git a/examples/soundboard-ts/dev-script.ndjson b/examples/soundboard-ts/dev-script.ndjson index 108370eb3..e4f976af4 100644 --- a/examples/soundboard-ts/dev-script.ndjson +++ b/examples/soundboard-ts/dev-script.ndjson @@ -7,7 +7,7 @@ # Play track 2 (Harvest Lot) - the transcript shows the audio_play command # with the prepared path, the hosted URL, and the manifest byte size. -{"kind":"play_track","id":2} +{"kind":"play_track","playTrackId":2} # The engine acknowledges the load; the platform's duration report (47s - # an estimate) must NOT replace the manifest total of 46944ms. @@ -24,7 +24,7 @@ # Queue Velvet Jackpot (track 16) behind the current track, then let the # current track complete: the queue wins the advance and a fresh # audio_play for track 16 appears in the transcript. -{"kind":"queue_track","id":16} +{"kind":"queue_track","queueTrackId":16} {"kind":"audio_event","state":"completed","positionMs":46944,"durationMs":47000,"playing":false,"buffering":false,"bands":{"$bytes":""}} {"kind":"audio_event","state":"loaded","positionMs":0,"durationMs":131000,"playing":true,"buffering":false,"bands":{"$bytes":""}} @@ -33,7 +33,7 @@ {"kind":"toggle_play"} # Copy Title puts the track title on the clipboard (fire-and-forget). -{"kind":"copy_title","id":16} +{"kind":"copy_title","copyTitleId":16} # Search narrows both libraries; the committed model shows the new draft. {"kind":"search_edit","edit":{"kind":"insert_text","text":{"$bytes":"night"}}} diff --git a/examples/soundboard-ts/src/core.ts b/examples/soundboard-ts/src/core.ts index b40ad49a3..eb2ddce72 100644 --- a/examples/soundboard-ts/src/core.ts +++ b/examples/soundboard-ts/src/core.ts @@ -147,20 +147,28 @@ function searchApply(d: SearchDraft, event: TextInputEvent): SearchDraft { if (clamped === null) return d; const nextClamped = applyTextInputEvent(state, clamped, MAX_SEARCH); if (nextClamped === null) return d; + // Composition bounds land in i64-classed slots: bind them, guard + // the range (an ordered comparison excludes NaN), and state + // wholeness with Math.trunc at the write; -1 stays the + // no-composition sentinel. + const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1; + const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1; return { bytes: nextClamped.text, anchor: nextClamped.selection.anchor, focus: nextClamped.selection.focus, - compStart: nextClamped.composition !== null ? nextClamped.composition.start : -1, - compEnd: nextClamped.composition !== null ? nextClamped.composition.end : -1, + compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1, + compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1, }; } + const nextStart = next.composition !== null ? next.composition.start : -1; + const nextEnd = next.composition !== null ? next.composition.end : -1; return { bytes: next.text, anchor: next.selection.anchor, focus: next.selection.focus, - compStart: next.composition !== null ? next.composition.start : -1, - compEnd: next.composition !== null ? next.composition.end : -1, + compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1, + compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1, }; } @@ -266,17 +274,20 @@ export function initialModel(): Model { export type Msg = | { readonly kind: "show_albums" } | { readonly kind: "show_songs" } - | { readonly kind: "open_album"; readonly id: number } + // Distinct source field names keep these i64 proof obligations from + // collapsing into QueueEntry.id or one another. Each wire payload + // remains the arm's single scalar. + | { readonly kind: "open_album"; readonly openAlbumId: number } | { readonly kind: "close_album" } - | { readonly kind: "play_album"; readonly id: number } + | { readonly kind: "play_album"; readonly playAlbumId: number } /// The play gesture on a track row: a different track starts fresh, the /// loaded one toggles play/pause in place. - | { readonly kind: "play_track"; readonly id: number } + | { readonly kind: "play_track"; readonly playTrackId: number } | { readonly kind: "toggle_play" } | { readonly kind: "next_track" } | { readonly kind: "prev_track" } - | { readonly kind: "queue_track"; readonly id: number } - | { readonly kind: "copy_title"; readonly id: number } + | { readonly kind: "queue_track"; readonly queueTrackId: number } + | { readonly kind: "copy_title"; readonly copyTitleId: number } | { readonly kind: "search_edit"; readonly edit: TextInputEvent } /// Every audio playback report: the load acknowledgment, position /// ticks, the one completion, failures, spectrum frames. @@ -400,14 +411,20 @@ export function visibleAlbums(model: Model): readonly AlbumCell[] { const out: AlbumCell[] = []; for (const album of ALBUMS) { if (!albumMatches(query, album)) continue; - out.push({ - id: album.id, - title: album.title, - artist: album.artist, - initials: album.initials, - cover: album.id, - playing: albumIsPlaying(model, album.id), - }); + // Ids land in i64-classed slots: bind, range-guard (an ordered + // comparison excludes NaN), and state wholeness with Math.trunc at + // the write. + const albumId = album.id; + if (albumId >= 0 && albumId <= 9007199254740991) { + out.push({ + id: Math.trunc(albumId), + title: album.title, + artist: album.artist, + initials: album.initials, + cover: Math.trunc(albumId), + playing: albumIsPlaying(model, album.id), + }); + } } return out; } @@ -682,18 +699,23 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { return [{ ...model, tab: "songs", libraryScrollTop: 0 }, Cmd.none]; case "open_album": { // Resolve the payload id against the table and store the TABLE's - // id: markup payloads arrive as wire f64s, and keeping every id in - // the model integer-classed starts here. - const album = ALBUMS.find((a) => a.id === msg.id); + // id: the compiled adapter proves this arm's i64 class before the + // update, and resolving against the table keeps model ids classed. + const album = ALBUMS.find((a) => a.id === msg.openAlbumId); if (album === undefined) return [model, Cmd.none]; // Every page change resets the controlled scroll: the offset is - // model state, so the fresh page opens at its top. - return [{ ...model, openAlbum: album.id, tab: "albums", libraryScrollTop: 0 }, Cmd.none]; + // model state, so the fresh page opens at its top. The id lands in + // an i64-classed slot: bind, range-guard, Math.trunc at the write. + const albumId = album.id; + if (albumId >= 0 && albumId <= 9007199254740991) { + return [{ ...model, openAlbum: Math.trunc(albumId), tab: "albums", libraryScrollTop: 0 }, Cmd.none]; + } + return [model, Cmd.none]; } case "close_album": return [{ ...model, openAlbum: null, libraryScrollTop: 0 }, Cmd.none]; case "play_album": { - const album = ALBUMS.find((a) => a.id === msg.id); + const album = ALBUMS.find((a) => a.id === msg.playAlbumId); if (album === undefined) return [model, Cmd.none]; const albumId = album.id; const first = TRACKS.find((t) => t.album === albumId && t.number === 1); @@ -710,11 +732,11 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { case "play_track": { // The loaded track toggles play/pause in place; a different track // starts fresh. - if (model.now === msg.id) { + if (model.now === msg.playTrackId) { if (model.playing) return [{ ...model, playing: false }, Cmd.audioPause("player")]; return [{ ...model, playing: true }, Cmd.audioResume("player")]; } - const track = TRACKS.find((t) => t.id === msg.id); + const track = TRACKS.find((t) => t.id === msg.playTrackId); if (track === undefined) return [model, Cmd.none]; return [ startedModel(model, track.id, track.durationMs), @@ -775,19 +797,25 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { ]; } case "queue_track": { - const track = TRACKS.find((t) => t.id === msg.id); + const track = TRACKS.find((t) => t.id === msg.queueTrackId); if (track === undefined) return [model, Cmd.none]; if (model.queue.some((q) => q.id === track.id)) return [model, Cmd.none]; if (model.queue.length >= MAX_QUEUE) { - return [{ ...model, queueDropped: model.queueDropped + 1 }, Cmd.none]; + return [{ ...model, queueDropped: model.queueDropped < 9007199254740991 ? model.queueDropped + 1 : 9007199254740991 }, Cmd.none]; + } + // The id lands in an i64-classed slot: bind, range-guard, + // Math.trunc at the write. + const trackId = track.id; + if (trackId >= 0 && trackId <= 9007199254740991) { + return [{ ...model, queue: [...model.queue, { id: Math.trunc(trackId) }] }, Cmd.none]; } - return [{ ...model, queue: [...model.queue, { id: track.id }] }, Cmd.none]; + return [model, Cmd.none]; } case "copy_title": { - const track = TRACKS.find((t) => t.id === msg.id); + const track = TRACKS.find((t) => t.id === msg.copyTitleId); if (track === undefined) return [model, Cmd.none]; return [ - { ...model, copiesRequested: model.copiesRequested + 1 }, + { ...model, copiesRequested: model.copiesRequested < 9007199254740991 ? model.copiesRequested + 1 : 9007199254740991 }, Cmd.clipboardWrite(track.title), ]; } @@ -797,10 +825,14 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { switch (msg.state) { case "loaded": { const next = adoptPlatformDuration(model, msg.durationMs); + // The position lands in an i64-classed slot: bind, range-guard + // (an ordered comparison excludes NaN), and state wholeness + // with Math.trunc at the write. + const loadedPos = msg.positionMs; return [{ ...next, loadPending: false, - elapsedMs: msg.positionMs, + elapsedMs: loadedPos >= 0 && loadedPos <= 9007199254740991 ? Math.trunc(loadedPos) : 0, playing: msg.playing, buffering: msg.buffering, }, Cmd.none]; @@ -810,20 +842,24 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // land before the new track's `loaded` — drop it by the guard. if (model.loadPending || model.now === null) return [model, Cmd.none]; const next = adoptPlatformDuration(model, msg.durationMs); + // The tick lands in an i64-classed slot: bind, range-guard, + // Math.trunc at the write. + const rawPos = msg.positionMs; + const tick = rawPos >= 0 && rawPos <= 9007199254740991 ? Math.trunc(rawPos) : 0; // The coarse tick corrects the rendered clock. In motion, // forward corrections apply and small backward ones hold flat // (the bar never visibly rewinds); a past-slack disagreement // snaps. With no motion the tick is simply the truth. if (next.playing && !msg.buffering) { if ( - msg.positionMs > next.elapsedMs || - next.elapsedMs - msg.positionMs > POSITION_SNAP_SLACK_MS + tick > next.elapsedMs || + next.elapsedMs - tick > POSITION_SNAP_SLACK_MS ) { - return [{ ...next, elapsedMs: msg.positionMs, buffering: msg.buffering }, Cmd.none]; + return [{ ...next, elapsedMs: tick, buffering: msg.buffering }, Cmd.none]; } return [{ ...next, buffering: msg.buffering }, Cmd.none]; } - return [{ ...next, elapsedMs: msg.positionMs, buffering: msg.buffering }, Cmd.none]; + return [{ ...next, elapsedMs: tick, buffering: msg.buffering }, Cmd.none]; } // Band-magnitude analysis frames are consciously ignored — the // soundboard's identity is the clean catalog (parity with the @@ -890,7 +926,11 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { rest -= 1000; thousandth += 1; } - const target = thousandth * permille; + // The product lands in an i64-classed slot: bind, range-guard (an + // ordered comparison excludes NaN), and state wholeness with + // Math.trunc at the write. + const scaled = thousandth * permille; + const target = scaled >= 0 && scaled <= 9007199254740991 ? Math.trunc(scaled) : 0; // The rendered clock jumps to the scrub target directly (a seek is // the one sanctioned rewind) and the engine seeks the real player. return [{ ...model, elapsedMs: target }, Cmd.audioSeek("player", target)]; @@ -923,9 +963,12 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // cadence, so each tick steps exactly one interval — a burst of // queued fires is bounded by construction — and the position // events correct any drift. The clock never passes the total. + // The advance saturates at the i64 class's provable ceiling, and + // the cap comparison replaces Math.min so the proof stays in view. const advanced = model.elapsedMs + CLOCK_TICK_MS; - const cap = model.nowDurationMs > 0 ? model.nowDurationMs : advanced; - return [{ ...model, elapsedMs: Math.min(advanced, cap) }, Cmd.none]; + const stepped = advanced >= 0 ? (advanced <= 9007199254740991 ? Math.trunc(advanced) : 9007199254740991) : 0; + const cap = model.nowDurationMs > 0 ? model.nowDurationMs : stepped; + return [{ ...model, elapsedMs: stepped <= cap ? stepped : cap }, Cmd.none]; } } } diff --git a/examples/soundboard-ts/src/library.ts b/examples/soundboard-ts/src/library.ts index e022ee413..655ea7288 100644 --- a/examples/soundboard-ts/src/library.ts +++ b/examples/soundboard-ts/src/library.ts @@ -259,9 +259,14 @@ export function trackRow(model: Model, track: TrackInfo, withAlbum: boolean): Tr subtitle = dashJoin(album.artist, album.title); } } + // Ids and numbers land in i64-classed slots: bind, range-guard (an + // ordered comparison excludes NaN), and state wholeness with + // Math.trunc at the write. + const trackId = track.id; + const trackNumber = track.number; return { - id: track.id, - number: track.number, + id: trackId >= 0 && trackId <= 9007199254740991 ? Math.trunc(trackId) : 0, + number: trackNumber >= 0 && trackNumber <= 9007199254740991 ? Math.trunc(trackNumber) : 0, title: track.title, subtitle: subtitle, duration: formatMs(track.durationMs), diff --git a/examples/soundboard-ts/src/player.ts b/examples/soundboard-ts/src/player.ts index f17999c4e..907fe010a 100644 --- a/examples/soundboard-ts/src/player.ts +++ b/examples/soundboard-ts/src/player.ts @@ -18,12 +18,15 @@ export function nowTrack(model: Model): TrackInfo | undefined { /// rule), a fresh attempt clears the degraded notices, and the stale-event /// guard goes up until the new playback's `loaded` lands. export function startedModel(model: Model, trackId: number, durationMs: number): Model { + // The id and duration land in i64-classed slots: range-guard each (an + // ordered comparison excludes NaN) and state wholeness with Math.trunc + // at the write. return { ...model, - now: trackId, + now: trackId >= 0 && trackId <= 9007199254740991 ? Math.trunc(trackId) : 0, playing: true, elapsedMs: 0, - nowDurationMs: durationMs, + nowDurationMs: durationMs >= 0 && durationMs <= 9007199254740991 ? Math.trunc(durationMs) : 0, platformDurationMs: 0, loadPending: true, buffering: false, @@ -93,9 +96,15 @@ export function localOnly(model: Model): boolean { /// Mirror the platform's duration report, and adopt it as the displayed /// total ONLY when the manifest gave none (the duration rule). export function adoptPlatformDuration(model: Model, reportedMs: number): Model { - if (reportedMs === 0) return model; - if (model.nowDurationMs === 0) { - return { ...model, platformDurationMs: reportedMs, nowDurationMs: reportedMs }; + // The report lands in i64-classed slots: range-guard it (an ordered + // comparison excludes NaN) and state wholeness with Math.trunc at the + // write; a zero or unprovable report changes nothing. + if (reportedMs > 0 && reportedMs <= 9007199254740991) { + const reported = Math.trunc(reportedMs); + if (model.nowDurationMs === 0) { + return { ...model, platformDurationMs: reported, nowDurationMs: reported }; + } + return { ...model, platformDurationMs: reported }; } - return { ...model, platformDurationMs: reportedMs }; + return model; } diff --git a/examples/system-monitor-ts/dev-script.ndjson b/examples/system-monitor-ts/dev-script.ndjson index cefcdab6e..49fb6de76 100644 --- a/examples/system-monitor-ts/dev-script.ndjson +++ b/examples/system-monitor-ts/dev-script.ndjson @@ -12,11 +12,11 @@ # The eager sample's ps block: three rows - launchd (pid 1, whose etime IS # the uptime), a busy renderer, and a quiet daemon. -{"kind":"ps_done","code":0,"output":{"$bytes":" 1 0.0 0.1 25824 02:49:06 /sbin/launchd\n 842 85.0 4.2 1048576 12-23:59:59 /usr/local/bin/renderfarm-worker\n 2048 0.0 0.0 0 05:00 /usr/libexec/idle-daemon\n"}} +{"kind":"ps_done","psCode":0,"output":{"$bytes":" 1 0.0 0.1 25824 02:49:06 /sbin/launchd\n 842 85.0 4.2 1048576 12-23:59:59 /usr/local/bin/renderfarm-worker\n 2048 0.0 0.0 0 05:00 /usr/libexec/idle-daemon\n"}} # The vm_stat block: 794612 active + 136740 wired + 58399 compressor pages # at 16384 bytes = ~15.1 GB used of the probe's 32 GiB. -{"kind":"mem_done","code":0,"output":{"$bytes":"Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 128230.\nPages active: 794612.\nPages wired down: 136740.\nPages occupied by compressor: 58399.\n"}} +{"kind":"mem_done","memCode":0,"output":{"$bytes":"Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 128230.\nPages active: 794612.\nPages wired down: 136740.\nPages occupied by compressor: 58399.\n"}} # Two seconds of virtual time fires the sample subscription once; the tick # lands while nothing is in flight, so a fresh spawn pair appears. @@ -31,9 +31,9 @@ # The confirmed SIGTERM: request copies the target out of the row, confirm # spawns exactly `/bin/kill -TERM 842` (watch the transcript), and the # kill exit lands as a status note. -{"kind":"request_kill","pid":842} +{"kind":"request_kill","requestKillPid":842} {"kind":"confirm_kill"} -{"kind":"kill_done","code":0,"output":{"$bytes":""}} +{"kind":"kill_done","killCode":0,"output":{"$bytes":""}} # Pause cancels the subscription (watch `sub cancel sample`; no tick fires # during the next advance), resume re-arms it — and because this script diff --git a/examples/system-monitor-ts/src/core.ts b/examples/system-monitor-ts/src/core.ts index c28dc04de..66e681797 100644 --- a/examples/system-monitor-ts/src/core.ts +++ b/examples/system-monitor-ts/src/core.ts @@ -116,20 +116,28 @@ function searchApply(d: SearchDraft, event: TextInputEvent): SearchDraft { if (clamped === null) return d; const nextClamped = applyTextInputEvent(state, clamped, MAX_SEARCH); if (nextClamped === null) return d; + // Composition bounds land in i64-classed slots: bind them, guard + // the range (an ordered comparison excludes NaN), and state + // wholeness with Math.trunc at the write; -1 stays the + // no-composition sentinel. + const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1; + const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1; return { bytes: nextClamped.text, anchor: nextClamped.selection.anchor, focus: nextClamped.selection.focus, - compStart: nextClamped.composition !== null ? nextClamped.composition.start : -1, - compEnd: nextClamped.composition !== null ? nextClamped.composition.end : -1, + compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1, + compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1, }; } + const nextStart = next.composition !== null ? next.composition.start : -1; + const nextEnd = next.composition !== null ? next.composition.end : -1; return { bytes: next.text, anchor: next.selection.anchor, focus: next.selection.focus, - compStart: next.composition !== null ? next.composition.start : -1, - compEnd: next.composition !== null ? next.composition.end : -1, + compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1, + compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1, }; } @@ -229,17 +237,21 @@ export type Msg = /// The boot probe's sysctl exit (collect mode: code + whole stdout). | { readonly kind: "info_done"; readonly code: number; readonly output: Bytes } | { readonly kind: "info_err"; readonly reason: Bytes } + // Distinct numeric field names keep these i64 proof obligations + // separate after structurally identical result records are lowered. + // Spawn routing matches the one number and one bytes field by type. /// The fallback probe's nproc exit. - | { readonly kind: "info2_done"; readonly code: number; readonly output: Bytes } + | { readonly kind: "info2_done"; readonly info2Code: number; readonly output: Bytes } | { readonly kind: "info2_err"; readonly reason: Bytes } /// Collected `ps` output arrived (or its stream failed). - | { readonly kind: "ps_done"; readonly code: number; readonly output: Bytes } + | { readonly kind: "ps_done"; readonly psCode: number; readonly output: Bytes } | { readonly kind: "ps_err"; readonly reason: Bytes } /// Collected memory-command output arrived. - | { readonly kind: "mem_done"; readonly code: number; readonly output: Bytes } + | { readonly kind: "mem_done"; readonly memCode: number; readonly output: Bytes } | { readonly kind: "mem_err"; readonly reason: Bytes } - /// The journaled Cmd.now stamp for the applied ps sample. - | { readonly kind: "stamped"; readonly at: number } + /// The journaled Cmd.now stamp for the applied ps sample. Its field + /// stays structurally distinct from the f64-classed tick timestamp. + | { readonly kind: "stamped"; readonly stampedAt: number } | { readonly kind: "toggle_sampling" } | { readonly kind: "search_edit"; readonly edit: TextInputEvent } | { readonly kind: "table_scrolled"; readonly scroll: ScrollState } @@ -252,15 +264,16 @@ export type Msg = /// pressable host, so the row binds this no-op (the Zig data_row is its /// own hit target and needs none). | { readonly kind: "row_pressed" } - /// Context menu: open the SIGTERM confirmation for this pid. - | { readonly kind: "request_kill"; readonly pid: number } + /// Context menu: open the SIGTERM confirmation for this pid. The two + /// pid arms use distinct fields so their i64 proofs do not collapse. + | { readonly kind: "request_kill"; readonly requestKillPid: number } | { readonly kind: "cancel_kill" } /// Dialog confirmed: spawn `/bin/kill -TERM `. | { readonly kind: "confirm_kill" } - | { readonly kind: "kill_done"; readonly code: number; readonly output: Bytes } + | { readonly kind: "kill_done"; readonly killCode: number; readonly output: Bytes } | { readonly kind: "kill_err"; readonly reason: Bytes } /// Context menu: copy the process name to the system clipboard. - | { readonly kind: "copy_name"; readonly pid: number } + | { readonly kind: "copy_name"; readonly copyNamePid: number } /// Chrome overlay geometry (the chromeMsg channel's arm). | { readonly kind: "chrome_changed"; @@ -560,6 +573,18 @@ function appliedPsSample(model: Model, sample: PsSample): Model { const cores = model.cores > 0 ? model.cores : 1; let percentTenths = intDivRound(sample.cpuSumTenths, cores); if (percentTenths > 1000) percentTenths = 1000; + // The sample's counts land in i64-classed slots: bind each value, + // range-guard it (an ordered comparison excludes NaN), and state + // wholeness with Math.trunc at the write; anything outside the + // provable window keeps the previous sample's value. + const sampleCount = sample.processCount; + const processCount = sampleCount >= 0 && sampleCount <= 9007199254740991 ? Math.trunc(sampleCount) : model.processCount; + const sampleUptime = sample.uptimeSeconds; + const uptimeSeconds = sampleUptime >= 0 && sampleUptime <= 9007199254740991 ? Math.trunc(sampleUptime) : model.uptimeSeconds; + const cpuTenths = percentTenths >= 0 && percentTenths <= 1000 ? Math.trunc(percentTenths) : model.cpuPercentTenths; + const samplesTaken = model.samplesTaken < 9007199254740991 ? model.samplesTaken + 1 : 9007199254740991; + const grownFailures = model.parseFailures + sample.skippedLines; + const parseFailures = grownFailures >= 0 && grownFailures <= 9007199254740991 ? Math.trunc(grownFailures) : model.parseFailures; // The process-count history pushes inline rather than through // pushHistory: the count stays integer-classed (it also formats into // the Processes tile), and the shared helper's value slot carries the @@ -580,28 +605,35 @@ function appliedPsSample(model: Model, sample: PsSample): Model { note: retiresNote ? new Uint8Array(0) : model.note, noteClearsOnSample: model.noteClearsOnSample && !retiresNote, psInflight: false, - processCount: sample.processCount, - uptimeSeconds: sample.uptimeSeconds, + processCount: processCount, + uptimeSeconds: uptimeSeconds, rows: sample.rows, - cpuPercentTenths: percentTenths, - samplesTaken: model.samplesTaken + 1, - parseFailures: model.parseFailures + sample.skippedLines, + cpuPercentTenths: cpuTenths, + samplesTaken: samplesTaken, + parseFailures: parseFailures, cpuHistory: pushHistory(model.cpuHistory, permilleFraction(percentTenths)), procHistory: grownProcHistory, }; } function appliedMemSample(model: Model, sample: MemSample): Model { - const totalBytes = sample.totalBytes > 0 ? sample.totalBytes : model.memTotalBytes; + // Byte counts land in i64-classed slots: bind, range-guard (an + // ordered comparison excludes NaN), and state wholeness with + // Math.trunc at the write; values outside the provable window keep + // the previous sample's. + const sampleTotal = sample.totalBytes; + const totalBytes = sampleTotal > 0 && sampleTotal <= 9007199254740991 ? Math.trunc(sampleTotal) : model.memTotalBytes; + const sampleUsed = sample.usedBytes; + const usedBytes = sampleUsed >= 0 && sampleUsed <= 9007199254740991 ? Math.trunc(sampleUsed) : model.memUsedBytes; let permille = 0; if (totalBytes > 0) { - permille = intDivRound(sample.usedBytes * 1000, totalBytes); + permille = intDivRound(usedBytes * 1000, totalBytes); if (permille > 1000) permille = 1000; } return { ...model, memInflight: false, - memUsedBytes: sample.usedBytes, + memUsedBytes: usedBytes, memTotalBytes: totalBytes, memHistory: pushHistory(model.memHistory, permilleFraction(permille)), }; @@ -615,7 +647,7 @@ function sampling(model: Model): Model { ...model, psInflight: true, memInflight: true, - sampleGeneration: model.sampleGeneration + 1, + sampleGeneration: model.sampleGeneration < 9007199254740991 ? model.sampleGeneration + 1 : 9007199254740991, }; } @@ -644,9 +676,15 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // never sits empty for a whole interval. if (msg.code === 0) { const info = parseHostInfo(msg.output, true); - if (info !== null) { + // The parsed counts land in i64-classed slots: bind, range-guard + // (an ordered comparison excludes NaN), and state wholeness with + // Math.trunc at the write; an unprovable probe falls through to + // the Linux path like any other unparseable answer. + const parsedCores = info !== null ? info.cores : -1; + const parsedMemory = info !== null ? info.memoryBytes : -1; + if (parsedCores >= 0 && parsedCores <= 9007199254740991 && parsedMemory >= 0 && parsedMemory <= 9007199254740991) { return [ - sampling({ ...model, phase: "ready", memCommand: "vmstat", cores: info.cores, memTotalBytes: info.memoryBytes }), + sampling({ ...model, phase: "ready", memCommand: "vmstat", cores: Math.trunc(parsedCores), memTotalBytes: Math.trunc(parsedMemory) }), Cmd.batch([ Cmd.spawn([asciiBytes("/bin/ps"), asciiBytes("axo"), asciiBytes("pid=,pcpu=,pmem=,rss=,etime=,comm=")], { key: "ps", @@ -676,11 +714,13 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { Cmd.spawn([asciiBytes("/usr/bin/nproc")], { key: "info", collect: true, exit: "info2_done", err: "info2_err" }), ]; case "info2_done": { - if (msg.code === 0) { + if (msg.info2Code === 0) { const info = parseHostInfo(msg.output, false); - if (info !== null) { + // Same bind-guard-trunc proof as the macOS probe above. + const parsedCores = info !== null ? info.cores : -1; + if (parsedCores >= 0 && parsedCores <= 9007199254740991) { return [ - sampling({ ...model, phase: "ready", memCommand: "meminfo", cores: info.cores }), + sampling({ ...model, phase: "ready", memCommand: "meminfo", cores: Math.trunc(parsedCores) }), Cmd.batch([ Cmd.spawn([asciiBytes("/bin/ps"), asciiBytes("axo"), asciiBytes("pid=,pcpu=,pmem=,rss=,etime=,comm=")], { key: "ps", @@ -708,7 +748,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // and counted — overlapping two ps runs would only add the load // this app measures. if (model.psInflight || model.memInflight) { - return [{ ...model, ticksSkipped: model.ticksSkipped + 1 }, Cmd.none]; + return [{ ...model, ticksSkipped: model.ticksSkipped < 9007199254740991 ? model.ticksSkipped + 1 : 9007199254740991 }, Cmd.none]; } if (model.memCommand === "vmstat") { return [ @@ -738,8 +778,8 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { ]; } case "ps_done": { - if (msg.code !== 0) { - return [withNote({ ...model, psInflight: false }, asciiBytes(`ps failed (code ${msg.code})`)), Cmd.none]; + if (msg.psCode !== 0) { + return [withNote({ ...model, psInflight: false }, asciiBytes(`ps failed (code ${msg.psCode})`)), Cmd.none]; } // The sample timestamp is a JOURNALED clock read (Cmd.now): under // session replay it resolves from the journal, so the same Msg @@ -748,26 +788,26 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { } case "ps_err": return [withNote( - { ...model, psInflight: false, parseFailures: model.parseFailures + 1 }, + { ...model, psInflight: false, parseFailures: model.parseFailures < 9007199254740991 ? model.parseFailures + 1 : 9007199254740991 }, concat3(asciiBytes("ps failed ("), msg.reason, asciiBytes(")")), ), Cmd.none]; case "mem_done": { - if (msg.code !== 0) { - return [withNote({ ...model, memInflight: false }, asciiBytes(`memory sample failed (code ${msg.code})`)), Cmd.none]; + if (msg.memCode !== 0) { + return [withNote({ ...model, memInflight: false }, asciiBytes(`memory sample failed (code ${msg.memCode})`)), Cmd.none]; } const sample = model.memCommand === "vmstat" ? parseVmStat(msg.output) : parseMeminfo(msg.output); if (sample === null) { - return [{ ...model, memInflight: false, parseFailures: model.parseFailures + 1 }, Cmd.none]; + return [{ ...model, memInflight: false, parseFailures: model.parseFailures < 9007199254740991 ? model.parseFailures + 1 : 9007199254740991 }, Cmd.none]; } return [appliedMemSample(model, sample), Cmd.none]; } case "mem_err": return [withNote( - { ...model, memInflight: false, parseFailures: model.parseFailures + 1 }, + { ...model, memInflight: false, parseFailures: model.parseFailures < 9007199254740991 ? model.parseFailures + 1 : 9007199254740991 }, concat3(asciiBytes("memory sample failed ("), msg.reason, asciiBytes(")")), ), Cmd.none]; case "stamped": - return [{ ...model, sampledAtDayMs: msg.at % 86400000 }, Cmd.none]; + return [{ ...model, sampledAtDayMs: msg.stampedAt % 86400000 }, Cmd.none]; case "toggle_sampling": { if (!model.paused) { // Pause: the subscription reconciles away after this commit. @@ -777,7 +817,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // (the Zig original samples immediately on resume too). if (model.phase !== "ready") return [{ ...model, paused: false }, Cmd.none]; if (model.psInflight || model.memInflight) { - return [{ ...model, paused: false, ticksSkipped: model.ticksSkipped + 1 }, Cmd.none]; + return [{ ...model, paused: false, ticksSkipped: model.ticksSkipped < 9007199254740991 ? model.ticksSkipped + 1 : 9007199254740991 }, Cmd.none]; } if (model.memCommand === "vmstat") { return [ @@ -821,9 +861,9 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { case "row_pressed": return [model, Cmd.none]; case "request_kill": { - const row = model.rows.find((r) => r.pid === msg.pid); + const row = model.rows.find((r) => r.pid === msg.requestKillPid); if (row === undefined) { - return [withNote(model, asciiBytes(`pid ${msg.pid} is gone (it left the sample)`)), Cmd.none]; + return [withNote(model, asciiBytes(`pid ${msg.requestKillPid} is gone (it left the sample)`)), Cmd.none]; } // Copy the target out of the row at request time, so a later // sample can never retarget a confirmation the user is reading. @@ -854,16 +894,16 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // the delivery notice retires with them instead of sitting in the // footer forever (a sample already in flight predates the kill // and cannot). - if (msg.code === 0) return [withTransientNote(model, asciiBytes("terminate request delivered")), Cmd.none]; + if (msg.killCode === 0) return [withTransientNote(model, asciiBytes("terminate request delivered")), Cmd.none]; return [withNote( model, - emDashJoin(asciiBytes(`kill failed (code ${msg.code}`), asciiBytes("not your process?)")), + emDashJoin(asciiBytes(`kill failed (code ${msg.killCode}`), asciiBytes("not your process?)")), ), Cmd.none]; } case "kill_err": return [withNote(model, concat3(asciiBytes("kill failed ("), msg.reason, asciiBytes(")"))), Cmd.none]; case "copy_name": { - const row = model.rows.find((r) => r.pid === msg.pid); + const row = model.rows.find((r) => r.pid === msg.copyNamePid); if (row === undefined) return [model, Cmd.none]; // Fire-and-forget: the clipboard op has no result routing, so the // note reports the request (the Zig original notes the outcome). diff --git a/examples/system-monitor-ts/src/parsers.ts b/examples/system-monitor-ts/src/parsers.ts index c2fb742e1..0f036e251 100644 --- a/examples/system-monitor-ts/src/parsers.ts +++ b/examples/system-monitor-ts/src/parsers.ts @@ -350,10 +350,17 @@ function parsePsLine(line: Bytes): ParsedPsLine | null { } const command = trimAsciiSpaces(line.subarray(cursor)); if (command.length === 0) return null; - return { - process: { pid: pid, cpuTenths: cpuTenths, memTenths: memTenths, rssKb: rssKb, name: basename(command) }, - etimeSeconds: etimeSeconds, - }; + // The four counts land in i64-classed slots: the range guard (an + // ordered comparison excludes NaN) plus Math.trunc prove them whole; + // a value past the provable window skips the line like any other + // malformed row. + if (pid >= 0 && pid <= 9007199254740991 && cpuTenths >= 0 && cpuTenths <= 9007199254740991 && memTenths >= 0 && memTenths <= 9007199254740991 && rssKb >= 0 && rssKb <= 9007199254740991) { + return { + process: { pid: Math.trunc(pid), cpuTenths: Math.trunc(cpuTenths), memTenths: Math.trunc(memTenths), rssKb: Math.trunc(rssKb), name: basename(command) }, + etimeSeconds: etimeSeconds, + }; + } + return null; } /// Stable descending order by %cpu tenths: a straight insertion sort, diff --git a/packages/core/sdk/text.ts b/packages/core/sdk/text.ts index 81cb6538a..2c6fd7d47 100644 --- a/packages/core/sdk/text.ts +++ b/packages/core/sdk/text.ts @@ -216,11 +216,22 @@ function nextTextWordOffset(text: Uint8Array, offset: number): number { return cursor; } +/// The selection constructor: offsets are whole byte offsets by +/// contract, proven in place — range-guarded (an ordered comparison +/// excludes NaN) and stated whole with Math.trunc. A value outside the +/// provable ±(2^53 − 1) window clamps to 0, the same floor every +/// caller's snap already applies. +function caretSelectionAt(anchor: number, focus: number): TextSelection { + const wholeAnchor = anchor >= 0 && anchor <= 9007199254740991 ? Math.trunc(anchor) : 0; + const wholeFocus = focus >= 0 && focus <= 9007199254740991 ? Math.trunc(focus) : 0; + return { anchor: wholeAnchor, focus: wholeFocus }; +} + function snapTextCaretSelection(text: Uint8Array, selection: TextSelection): TextSelection { - return { - anchor: snapTextCaretOffset(text, selection.anchor), - focus: snapTextCaretOffset(text, selection.focus), - }; + return caretSelectionAt( + snapTextCaretOffset(text, selection.anchor), + snapTextCaretOffset(text, selection.focus), + ); } function snapTextRange(text: Uint8Array, range: TextRange): TextRange { @@ -290,7 +301,7 @@ function replaceTextEditRange( ); return { text: result.text, - selection: { anchor: cursor, focus: cursor }, + selection: caretSelectionAt(cursor, cursor), composition: composition, }; } @@ -308,7 +319,7 @@ function setTextComposition( const absoluteCursor = snapTextCaretOffset(result.text, result.insertedStart + cursor); return { text: result.text, - selection: { anchor: absoluteCursor, focus: absoluteCursor }, + selection: caretSelectionAt(absoluteCursor, absoluteCursor), composition: { start: result.insertedStart, end: result.insertedEnd }, }; } @@ -320,10 +331,10 @@ function cancelTextComposition(state: TextEditState, capacity: number): TextEdit if (result === null) return null; return { text: result.text, - selection: snapTextCaretSelection(result.text, { - anchor: result.insertedStart, - focus: result.insertedStart, - }), + selection: snapTextCaretSelection( + result.text, + caretSelectionAt(result.insertedStart, result.insertedStart), + ), composition: null, }; } @@ -355,7 +366,7 @@ function deleteForwardTextEdit(state: TextEditState, capacity: number): TextEdit const caret = snapTextCaretOffset(state.text, state.selection.focus); if (caret >= state.text.length) { const len = state.text.length; - return { text: state.text, selection: { anchor: len, focus: len }, composition: null }; + return { text: state.text, selection: caretSelectionAt(len, len), composition: null }; } return replaceTextEditRange( state, @@ -394,7 +405,7 @@ function deleteWordForwardTextEdit(state: TextEditState, capacity: number): Text const caret = snapTextCaretOffset(state.text, state.selection.focus); if (caret >= state.text.length) { const len = state.text.length; - return { text: state.text, selection: { anchor: len, focus: len }, composition: null }; + return { text: state.text, selection: caretSelectionAt(len, len), composition: null }; } return replaceTextEditRange( state, @@ -425,8 +436,8 @@ function moveTextCaret(state: TextEditState, move: TextCaretMove): TextEditState target = state.text.length; } const selection: TextSelection = move.extend - ? { anchor: state.selection.anchor, focus: target } - : { anchor: target, focus: target }; + ? caretSelectionAt(state.selection.anchor, target) + : caretSelectionAt(target, target); return { text: state.text, selection: snapTextCaretSelection(state.text, selection), diff --git a/tests/compiled-core/adapters/ai_chat_adapter.ts b/tests/compiled-core/adapters/ai_chat_adapter.ts index 878c34a62..c076d7c74 100644 --- a/tests/compiled-core/adapters/ai_chat_adapter.ts +++ b/tests/compiled-core/adapters/ai_chat_adapter.ts @@ -38,10 +38,10 @@ import { finish, newSink, trap, - truncTowardZero, wBool, wBytes, wF64, + wI64, wU32, type Sink, } from "./wire.ts"; @@ -220,7 +220,13 @@ export function dispatch_number(tag: number, value: number): Uint8Array { export function dispatch_number_bytes(tag: number, value: number, payload: Uint8Array): Uint8Array { if (tag === TAG_chat_response) { - return commit(coreUpdate(committed, { kind: "chat_response", status: truncTowardZero(value), body: payload })); + // The status is an i64-classed arm: range-guard the raw f64 (an + // ordered comparison excludes NaN) and state wholeness with + // Math.trunc at the write. + if (value >= -9007199254740991 && value <= 9007199254740991) { + return commit(coreUpdate(committed, { kind: "chat_response", status: Math.trunc(value), body: payload })); + } + trap("a numeric dispatch value is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); } trapUnknownTag("number-with-bytes", tag); } @@ -309,18 +315,18 @@ export function model_snapshot(): Uint8Array { wU32(sink, model.turns.length); for (let i = 0; i < model.turns.length; i++) { const turn = model.turns[i]!; - wF64(sink, turn.id); + wI64(sink, turn.id); wEnum(sink, roleMembers, turn.role); wBytes(sink, turn.text); } - wF64(sink, model.nextId); + wI64(sink, model.nextId); wEnum(sink, phaseMembers, model.phase); wBytes(sink, model.failReason); wBytes(sink, model.draft.bytes); - wF64(sink, model.draft.anchor); - wF64(sink, model.draft.focus); - wF64(sink, model.draft.compStart); - wF64(sink, model.draft.compEnd); + wI64(sink, model.draft.anchor); + wI64(sink, model.draft.focus); + wI64(sink, model.draft.compStart); + wI64(sink, model.draft.compEnd); wBytes(sink, model.endpoint); wBytes(sink, model.modelName); wBytes(sink, model.apiKey); @@ -364,7 +370,7 @@ export function helper_call(helper: number, args: Uint8Array): Uint8Array { wU32(sink, rows.length); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; - wF64(sink, row.id); + wI64(sink, row.id); wBool(sink, row.user); wBytes(sink, row.text); } diff --git a/tests/compiled-core/adapters/host_fixture_adapter.ts b/tests/compiled-core/adapters/host_fixture_adapter.ts index 5f987eb81..10c8ea1eb 100644 --- a/tests/compiled-core/adapters/host_fixture_adapter.ts +++ b/tests/compiled-core/adapters/host_fixture_adapter.ts @@ -34,6 +34,7 @@ import { wBool, wBytes, wF64, + wI64, wU32, type Sink, } from "./wire.ts"; @@ -414,16 +415,16 @@ export function model_snapshot(): Uint8Array { const sink = newSink(); const model = committed; wBool(sink, model.polling); - wF64(sink, model.ticks); + wI64(sink, model.ticks); wF64(sink, model.lastTickAt); wF64(sink, model.stampMs); - wF64(sink, model.failures); + wI64(sink, model.failures); wBytes(sink, model.status); wBytes(sink, model.lastErr); - wF64(sink, model.saved); + wI64(sink, model.saved); wF64(sink, model.code); wF64(sink, model.firedAt); - wF64(sink, model.lines); + wI64(sink, model.lines); wBytes(sink, model.lastLine); wF64(sink, model.exitCode); wEnum(sink, audioStates, model.audioState); @@ -431,32 +432,32 @@ export function model_snapshot(): Uint8Array { wF64(sink, model.durMs); wBool(sink, model.playing); wBytes(sink, model.bands); - wF64(sink, model.audioEvents); + wI64(sink, model.audioEvents); wEnum(sink, videoStates, model.videoState); wF64(sink, model.vPosMs); wF64(sink, model.vDurMs); wBool(sink, model.vPlaying); wF64(sink, model.vW); wF64(sink, model.vH); - wF64(sink, model.videoEvents); + wI64(sink, model.videoEvents); wF64(sink, model.cover); wF64(sink, model.coverW); wF64(sink, model.coverH); wEnum(sink, imageStates, model.imageState); wF64(sink, model.imageStatus); - wF64(sink, model.imageResults); + wI64(sink, model.imageResults); wF64(sink, model.lastImageId); - wF64(sink, model.nextCover); - wF64(sink, model.topId); + wI64(sink, model.nextCover); + wI64(sink, model.topId); wF64(sink, model.fracBytes); - wF64(sink, model.wholeBytes); - wF64(sink, model.topBytes); + wI64(sink, model.wholeBytes); + wI64(sink, model.topBytes); wF64(sink, model.pastBytes); wEnum(sink, channelStates, model.chanState); - wF64(sink, model.chanEvents); - wF64(sink, model.rejectSeq); - wF64(sink, model.chanRejectAt); - wF64(sink, model.imgRejectAt); + wI64(sink, model.chanEvents); + wI64(sink, model.rejectSeq); + wI64(sink, model.chanRejectAt); + wI64(sink, model.imgRejectAt); return finish(sink); } diff --git a/tests/compiled-core/adapters/markup_adapter.ts b/tests/compiled-core/adapters/markup_adapter.ts index bbbbcd4d3..a3f73863c 100644 --- a/tests/compiled-core/adapters/markup_adapter.ts +++ b/tests/compiled-core/adapters/markup_adapter.ts @@ -36,6 +36,7 @@ import { wBool, wBytes, wF64, + wI64, wU32, type Sink, } from "./wire.ts"; @@ -151,12 +152,24 @@ export function dispatch_bytes(tag: number, payload: Uint8Array): Uint8Array { } export function dispatch_number(tag: number, value: number): Uint8Array { - if (tag === TAG_toggle) return commit(coreUpdate(committed, { kind: "toggle", id: value })); + // These arms remain f64-classed in the contract: preserve the value + // exactly instead of routing it through the integer proof below. if (tag === TAG_pick) return commit(coreUpdate(committed, { kind: "pick", id: value })); if (tag === TAG_stamped) return commit(coreUpdate(committed, { kind: "stamped", at: value })); if (tag === TAG_hover_row) return commit(coreUpdate(committed, { kind: "hover_row", id: value })); if (tag === TAG_hover_off) return commit(coreUpdate(committed, { kind: "hover_off", id: value })); - if (tag === TAG_canvas_resized) return commit(coreUpdate(committed, { kind: "canvas_resized", width: value })); + + // Integer-classed arms (ids and widths) prove in place: range-guard + // the raw f64 (an ordered comparison excludes NaN) and state + // wholeness with Math.trunc at the write. + if (tag === TAG_toggle || tag === TAG_canvas_resized) { + if (value >= -9007199254740991 && value <= 9007199254740991) { + const whole = Math.trunc(value); + if (tag === TAG_toggle) return commit(coreUpdate(committed, { kind: "toggle", taskId: whole })); + return commit(coreUpdate(committed, { kind: "canvas_resized", width: whole })); + } + trap("a numeric dispatch value is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); + } trapUnknownTag("number", tag); } @@ -273,6 +286,12 @@ function f64Payload(value: number): Uint8Array { return finish(sink); } +function i64Payload(value: number): Uint8Array { + const sink = newSink(); + wI64(sink, value); + return finish(sink); +} + function asciiString(bytes: Uint8Array): string { let out = ""; for (let i = 0; i < bytes.length; i++) out = out + String.fromCharCode(bytes[i]!); @@ -287,9 +306,10 @@ export function abi_frame_msg(width: number, height: number, timestampMs: number intervalMs: intervalMs, }); if (produced === null) return noChannelMsg(); - // The one arm this channel produces carries the frame width. + // The one arm this channel produces carries the frame width — an + // i64-classed slot, so the payload rides the i64 encoding. if (produced.kind === "canvas_resized") { - return channelEnvelope(TAG_canvas_resized, f64Payload(produced.width)); + return channelEnvelope(TAG_canvas_resized, i64Payload(produced.width)); } trap("the frame channel produced the unroutable arm " + produced.kind + " — the author module and this adapter disagree"); } @@ -388,26 +408,26 @@ export function model_snapshot(): Uint8Array { const sink = newSink(); const model = committed; wEnum(sink, filters, model.filter); - wF64(sink, model.nextId); - wF64(sink, model.doneCount); + wI64(sink, model.nextId); + wI64(sink, model.doneCount); wBytes(sink, model.banner); wOptionalF64(sink, model.selected); wU32(sink, model.tasks.length); for (let i = 0; i < model.tasks.length; i++) { const task = model.tasks[i]!; - wF64(sink, task.id); + wI64(sink, task.id); wBytes(sink, task.title); wBool(sink, task.done); } wF64(sink, model.stampMs); wBytes(sink, model.draft); - wF64(sink, model.canvasWidth); + wI64(sink, model.canvasWidth); wF64(sink, model.zoom); wF64(sink, model.zoomWindowId); wBool(sink, model.zoomFromBoard); wBool(sink, model.dark); wF64(sink, model.chromeTop); - wF64(sink, model.previewSurface); + wI64(sink, model.previewSurface); wF64(sink, model.hoveredId); return finish(sink); } diff --git a/tests/compiled-core/adapters/soundboard_adapter.ts b/tests/compiled-core/adapters/soundboard_adapter.ts index f256bc26e..0ad1658ae 100644 --- a/tests/compiled-core/adapters/soundboard_adapter.ts +++ b/tests/compiled-core/adapters/soundboard_adapter.ts @@ -64,12 +64,14 @@ import { readBool, readBytesBody, readF64, + readI64, readU32, subBytes, trap, wBool, wBytes, wF64, + wI64, wU32, type Sink, } from "./wire.ts"; @@ -140,11 +142,18 @@ export function searchText(model: Model): Uint8Array { export function visibleAlbums(model: Model): AlbumCell[] { return h_visibleAlbums(model) as AlbumCell[]; } +// Classed helper returns prove at this boundary: bind the helper's +// value, range-guard it (an ordered comparison excludes NaN), and state +// wholeness with Math.trunc at the return. export function gridColumns(model: Model): number { - return h_gridColumns(model); + const count = h_gridColumns(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function gridShownColumns(model: Model): number { - return h_gridShownColumns(model); + const count = h_gridShownColumns(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function gridTileWidth(model: Model): number { return h_gridTileWidth(model); @@ -165,16 +174,24 @@ export function openAlbumRows(model: Model): TrackRow[] { return h_openAlbumRows(model) as TrackRow[]; } export function visibleAlbumCount(model: Model): number { - return h_visibleAlbumCount(model); + const count = h_visibleAlbumCount(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function albumCount(model: Model): number { - return h_albumCount(model); + const count = h_albumCount(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function visibleTrackCount(model: Model): number { - return h_visibleTrackCount(model); + const count = h_visibleTrackCount(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function trackCount(model: Model): number { - return h_trackCount(model); + const count = h_trackCount(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function noAlbumMatches(model: Model): boolean { return h_noAlbumMatches(model); @@ -186,7 +203,9 @@ export function noMatchesLabel(model: Model): Uint8Array { return h_noMatchesLabel(model); } export function openAlbumId(model: Model): number { - return h_openAlbumId(model); + const id = h_openAlbumId(model); + if (id >= -9007199254740991 && id <= 9007199254740991) return Math.trunc(id); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function openAlbumTitle(model: Model): Uint8Array { return h_openAlbumTitle(model); @@ -195,7 +214,9 @@ export function openAlbumInitials(model: Model): Uint8Array { return h_openAlbumInitials(model); } export function openAlbumCover(model: Model): number { - return h_openAlbumCover(model); + const cover = h_openAlbumCover(model); + if (cover >= -9007199254740991 && cover <= 9007199254740991) return Math.trunc(cover); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function openAlbumMeta(model: Model): Uint8Array { return h_openAlbumMeta(model); @@ -213,7 +234,9 @@ export function nowPlayingInitials(model: Model): Uint8Array { return h_nowPlayingInitials(model); } export function nowPlayingCover(model: Model): number { - return h_nowPlayingCover(model); + const cover = h_nowPlayingCover(model); + if (cover >= -9007199254740991 && cover <= 9007199254740991) return Math.trunc(cover); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function playPauseIcon(model: Model): Uint8Array { return h_playPauseIcon(model); @@ -228,7 +251,9 @@ export function durationLabel(model: Model): Uint8Array { return h_durationLabel(model); } export function queueLen(model: Model): number { - return h_queueLen(model); + const len = h_queueLen(model); + if (len >= -9007199254740991 && len <= 9007199254740991) return Math.trunc(len); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } // The designated shape-flag exports: init returns the bare model @@ -335,14 +360,26 @@ export function dispatch_bytes(tag: number, payload: Uint8Array): Uint8Array { } export function dispatch_number(tag: number, value: number): Uint8Array { - if (tag === TAG_open_album) return commit(coreUpdate(committed, { kind: "open_album", id: value })); - if (tag === TAG_play_album) return commit(coreUpdate(committed, { kind: "play_album", id: value })); - if (tag === TAG_play_track) return commit(coreUpdate(committed, { kind: "play_track", id: value })); - if (tag === TAG_queue_track) return commit(coreUpdate(committed, { kind: "queue_track", id: value })); - if (tag === TAG_copy_title) return commit(coreUpdate(committed, { kind: "copy_title", id: value })); + // These arms remain f64-classed in the contract: preserve the value + // exactly instead of routing it through the integer proof below. if (tag === TAG_clock_tick) return commit(coreUpdate(committed, { kind: "clock_tick", at: value })); if (tag === TAG_scrubbed) return commit(coreUpdate(committed, { kind: "scrubbed", fraction: value })); if (tag === TAG_canvas_resized) return commit(coreUpdate(committed, { kind: "canvas_resized", width: value })); + + // The id arms are i64-classed and prove in place: range-guard the raw + // f64 (an ordered comparison excludes NaN) and state wholeness with + // Math.trunc at the write. + if (tag === TAG_open_album || tag === TAG_play_album || tag === TAG_play_track || tag === TAG_queue_track || tag === TAG_copy_title) { + if (value >= -9007199254740991 && value <= 9007199254740991) { + const whole = Math.trunc(value); + if (tag === TAG_open_album) return commit(coreUpdate(committed, { kind: "open_album", openAlbumId: whole })); + if (tag === TAG_play_album) return commit(coreUpdate(committed, { kind: "play_album", playAlbumId: whole })); + if (tag === TAG_play_track) return commit(coreUpdate(committed, { kind: "play_track", playTrackId: whole })); + if (tag === TAG_queue_track) return commit(coreUpdate(committed, { kind: "queue_track", queueTrackId: whole })); + return commit(coreUpdate(committed, { kind: "copy_title", copyTitleId: whole })); + } + trap("a numeric dispatch value is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); + } trapUnknownTag("number", tag); } @@ -382,15 +419,23 @@ export function dispatch_record(tag: number, fields: Uint8Array): Uint8Array { } const bandsLen = readU32(fields, 22); assertConsumed(fields, 26 + bandsLen); - return commit(coreUpdate(committed, { - kind: "audio_event", - state: audioStates[state]! as "loaded", - positionMs: readF64(fields, 4), - durationMs: readF64(fields, 12), - playing: readBool(fields, 20), - buffering: readBool(fields, 21), - bands: readBytesBody(fields, 26, bandsLen), - })); + // The clock slots are i64-classed: range-guard each decoded value (an + // ordered comparison excludes NaN) and state wholeness with Math.trunc + // at the write. + const positionMs = readI64(fields, 4); + const durationMs = readI64(fields, 12); + if (positionMs >= -9007199254740991 && positionMs <= 9007199254740991 && durationMs >= -9007199254740991 && durationMs <= 9007199254740991) { + return commit(coreUpdate(committed, { + kind: "audio_event", + state: audioStates[state]! as "loaded", + positionMs: Math.trunc(positionMs), + durationMs: Math.trunc(durationMs), + playing: readBool(fields, 20), + buffering: readBool(fields, 21), + bands: readBytesBody(fields, 26, bandsLen), + })); + } + trap("an audio clock value is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); } if (tag === TAG_library_scrolled) { assertConsumed(fields, 64); @@ -542,49 +587,49 @@ function wEnum(sink: Sink, members: string[], value: string): void { trap("a model enum slot carries an undeclared member — the author module and this adapter disagree"); } -function wOptionalF64(sink: Sink, value: number | null): void { +function wOptionalBytes(sink: Sink, value: Uint8Array | null): void { if (value === null) { wBool(sink, false); return; } wBool(sink, true); - wF64(sink, value); + wBytes(sink, value); } -function wOptionalBytes(sink: Sink, value: Uint8Array | null): void { +function wOptionalI64(sink: Sink, value: number | null): void { if (value === null) { wBool(sink, false); return; } wBool(sink, true); - wBytes(sink, value); + wI64(sink, value); } export function model_snapshot(): Uint8Array { const sink = newSink(); const model = committed; wEnum(sink, tabs, model.tab); - wOptionalF64(sink, model.openAlbum); - wOptionalF64(sink, model.now); + wOptionalI64(sink, model.openAlbum); + wOptionalI64(sink, model.now); wBool(sink, model.playing); - wF64(sink, model.elapsedMs); - wF64(sink, model.nowDurationMs); - wF64(sink, model.platformDurationMs); + wI64(sink, model.elapsedMs); + wI64(sink, model.nowDurationMs); + wI64(sink, model.platformDurationMs); wBool(sink, model.loadPending); wBool(sink, model.buffering); wBool(sink, model.assetsMissing); wBool(sink, model.streamFailed); wU32(sink, model.queue.length); for (let i = 0; i < model.queue.length; i++) { - wF64(sink, model.queue[i]!.id); + wI64(sink, model.queue[i]!.id); } - wF64(sink, model.queueDropped); + wI64(sink, model.queueDropped); wBytes(sink, model.search.bytes); - wF64(sink, model.search.anchor); - wF64(sink, model.search.focus); - wF64(sink, model.search.compStart); - wF64(sink, model.search.compEnd); - wF64(sink, model.copiesRequested); + wI64(sink, model.search.anchor); + wI64(sink, model.search.focus); + wI64(sink, model.search.compStart); + wI64(sink, model.search.compEnd); + wI64(sink, model.copiesRequested); wF64(sink, model.libraryScrollTop); wF64(sink, model.canvasWidth); wOptionalBytes(sink, model.urlBase); @@ -616,16 +661,22 @@ function numberResult(value: number): Uint8Array { return finish(sink); } +function intResult(value: number): Uint8Array { + const sink = newSink(); + wI64(sink, value); + return finish(sink); +} + function albumCellsResult(rows: AlbumCell[]): Uint8Array { const sink = newSink(); wU32(sink, rows.length); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; - wF64(sink, row.id); + wI64(sink, row.id); wBytes(sink, row.title); wBytes(sink, row.artist); wBytes(sink, row.initials); - wF64(sink, row.cover); + wI64(sink, row.cover); wBool(sink, row.playing); } return finish(sink); @@ -636,8 +687,8 @@ function trackRowsResult(rows: TrackRow[]): Uint8Array { wU32(sink, rows.length); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; - wF64(sink, row.id); - wF64(sink, row.number); + wI64(sink, row.id); + wI64(sink, row.number); wBytes(sink, row.title); wBytes(sink, row.subtitle); wBytes(sink, row.duration); @@ -656,35 +707,35 @@ export function helper_call(helper: number, args: Uint8Array): Uint8Array { if (helper === 2) return boolResult(h_detailPage(committed)); if (helper === 3) return bytesResult(h_searchText(committed)); if (helper === 4) return albumCellsResult(h_visibleAlbums(committed) as AlbumCell[]); - if (helper === 5) return numberResult(h_gridColumns(committed)); - if (helper === 6) return numberResult(h_gridShownColumns(committed)); + if (helper === 5) return intResult(gridColumns(committed)); + if (helper === 6) return intResult(gridShownColumns(committed)); if (helper === 7) return numberResult(h_gridTileWidth(committed)); if (helper === 8) return numberResult(h_gridRowWidth(committed)); if (helper === 9) return numberResult(h_gridCoverSize(committed)); if (helper === 10) return numberResult(h_gridTileHeight(committed)); if (helper === 11) return trackRowsResult(h_visibleTracks(committed) as TrackRow[]); if (helper === 12) return trackRowsResult(h_openAlbumRows(committed) as TrackRow[]); - if (helper === 13) return numberResult(h_visibleAlbumCount(committed)); - if (helper === 14) return numberResult(h_albumCount(committed)); - if (helper === 15) return numberResult(h_visibleTrackCount(committed)); - if (helper === 16) return numberResult(h_trackCount(committed)); + if (helper === 13) return intResult(visibleAlbumCount(committed)); + if (helper === 14) return intResult(albumCount(committed)); + if (helper === 15) return intResult(visibleTrackCount(committed)); + if (helper === 16) return intResult(trackCount(committed)); if (helper === 17) return boolResult(h_noAlbumMatches(committed)); if (helper === 18) return boolResult(h_noTrackMatches(committed)); if (helper === 19) return bytesResult(h_noMatchesLabel(committed)); - if (helper === 20) return numberResult(h_openAlbumId(committed)); + if (helper === 20) return intResult(openAlbumId(committed)); if (helper === 21) return bytesResult(h_openAlbumTitle(committed)); if (helper === 22) return bytesResult(h_openAlbumInitials(committed)); - if (helper === 23) return numberResult(h_openAlbumCover(committed)); + if (helper === 23) return intResult(openAlbumCover(committed)); if (helper === 24) return bytesResult(h_openAlbumMeta(committed)); if (helper === 25) return boolResult(h_idle(committed)); if (helper === 26) return bytesResult(h_nowPlayingTitle(committed)); if (helper === 27) return bytesResult(h_nowPlayingArtist(committed)); if (helper === 28) return bytesResult(h_nowPlayingInitials(committed)); - if (helper === 29) return numberResult(h_nowPlayingCover(committed)); + if (helper === 29) return intResult(nowPlayingCover(committed)); if (helper === 30) return bytesResult(h_playPauseIcon(committed)); if (helper === 31) return numberResult(h_progressFraction(committed)); if (helper === 32) return bytesResult(h_elapsedLabel(committed)); if (helper === 33) return bytesResult(h_durationLabel(committed)); - if (helper === 34) return numberResult(h_queueLen(committed)); + if (helper === 34) return intResult(queueLen(committed)); trap("helper index " + helper + " does not name an exported model helper of this core — the host and this core disagree about the contract"); } diff --git a/tests/compiled-core/adapters/system_monitor_adapter.ts b/tests/compiled-core/adapters/system_monitor_adapter.ts index 6b7d5f724..a8099f7f0 100644 --- a/tests/compiled-core/adapters/system_monitor_adapter.ts +++ b/tests/compiled-core/adapters/system_monitor_adapter.ts @@ -51,10 +51,10 @@ import { readF64, subBytes, trap, - truncTowardZero, wBool, wBytes, wF64, + wI64, wU32, type Sink, } from "./wire.ts"; @@ -168,11 +168,18 @@ export function sortDirectionLabel(model: Model): Uint8Array { export function visibleRows(model: Model): TableRow[] { return h_visibleRows(model) as TableRow[]; } +// Classed helper returns prove at this boundary: bind the helper's +// value, range-guard it (an ordered comparison excludes NaN), and state +// wholeness with Math.trunc at the return. export function matchCount(model: Model): number { - return h_matchCount(model); + const count = h_matchCount(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function shownCount(model: Model): number { - return h_shownCount(model); + const count = h_shownCount(model); + if (count >= -9007199254740991 && count <= 9007199254740991) return Math.trunc(count); + trap("a helper return is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } export function emptyTitle(model: Model): Uint8Array { return h_emptyTitle(model); @@ -313,30 +320,48 @@ export function dispatch_bytes(tag: number, payload: Uint8Array): Uint8Array { } export function dispatch_number(tag: number, value: number): Uint8Array { + // Timer ticks remain f64-classed in the contract: preserve the value + // exactly instead of routing it through the integer proof below. if (tag === TAG_tick) return commit(coreUpdate(committed, { kind: "tick", at: value })); - if (tag === TAG_stamped) return commit(coreUpdate(committed, { kind: "stamped", at: value })); - if (tag === TAG_request_kill) return commit(coreUpdate(committed, { kind: "request_kill", pid: truncTowardZero(value) })); - if (tag === TAG_copy_name) return commit(coreUpdate(committed, { kind: "copy_name", pid: truncTowardZero(value) })); + + // Integer-classed arms (the stamped timestamp and pids) prove in place: + // range-guard the raw f64 (an ordered comparison excludes NaN) and + // state wholeness with Math.trunc at the write. + if (tag === TAG_stamped || tag === TAG_request_kill || tag === TAG_copy_name) { + if (value >= -9007199254740991 && value <= 9007199254740991) { + const whole = Math.trunc(value); + if (tag === TAG_stamped) return commit(coreUpdate(committed, { kind: "stamped", stampedAt: whole })); + if (tag === TAG_request_kill) return commit(coreUpdate(committed, { kind: "request_kill", requestKillPid: whole })); + return commit(coreUpdate(committed, { kind: "copy_name", copyNamePid: whole })); + } + trap("a numeric dispatch value is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); + } trapUnknownTag("number", tag); } export function dispatch_number_bytes(tag: number, value: number, payload: Uint8Array): Uint8Array { - if (tag === TAG_info_done) { - return commit(coreUpdate(committed, { kind: "info_done", code: truncTowardZero(value), output: payload })); - } - if (tag === TAG_info2_done) { - return commit(coreUpdate(committed, { kind: "info2_done", code: truncTowardZero(value), output: payload })); - } - if (tag === TAG_ps_done) { - return commit(coreUpdate(committed, { kind: "ps_done", code: truncTowardZero(value), output: payload })); - } - if (tag === TAG_mem_done) { - return commit(coreUpdate(committed, { kind: "mem_done", code: truncTowardZero(value), output: payload })); - } - if (tag === TAG_kill_done) { - return commit(coreUpdate(committed, { kind: "kill_done", code: truncTowardZero(value), output: payload })); + // Exit codes are i64-classed: the same guard-and-trunc proof as + // dispatch_number. + if (value >= -9007199254740991 && value <= 9007199254740991) { + const code = Math.trunc(value); + if (tag === TAG_info_done) { + return commit(coreUpdate(committed, { kind: "info_done", code: code, output: payload })); + } + if (tag === TAG_info2_done) { + return commit(coreUpdate(committed, { kind: "info2_done", info2Code: code, output: payload })); + } + if (tag === TAG_ps_done) { + return commit(coreUpdate(committed, { kind: "ps_done", psCode: code, output: payload })); + } + if (tag === TAG_mem_done) { + return commit(coreUpdate(committed, { kind: "mem_done", memCode: code, output: payload })); + } + if (tag === TAG_kill_done) { + return commit(coreUpdate(committed, { kind: "kill_done", killCode: code, output: payload })); + } + trapUnknownTag("number-with-bytes", tag); } - trapUnknownTag("number-with-bytes", tag); + trap("a numeric dispatch value is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); } export function dispatch_bool(tag: number, value: number): Uint8Array { @@ -466,35 +491,35 @@ export function model_snapshot(): Uint8Array { wEnum(sink, samplerPhases, model.phase); wEnum(sink, memCommands, model.memCommand); wBool(sink, model.paused); - wF64(sink, model.ticksSkipped); + wI64(sink, model.ticksSkipped); wBool(sink, model.psInflight); wBool(sink, model.memInflight); - wF64(sink, model.samplesTaken); - wF64(sink, model.sampledAtDayMs); - wF64(sink, model.cores); - wF64(sink, model.memTotalBytes); - wF64(sink, model.cpuPercentTenths); - wF64(sink, model.memUsedBytes); - wF64(sink, model.processCount); - wF64(sink, model.uptimeSeconds); + wI64(sink, model.samplesTaken); + wI64(sink, model.sampledAtDayMs); + wI64(sink, model.cores); + wI64(sink, model.memTotalBytes); + wI64(sink, model.cpuPercentTenths); + wI64(sink, model.memUsedBytes); + wI64(sink, model.processCount); + wI64(sink, model.uptimeSeconds); wU32(sink, model.rows.length); for (let i = 0; i < model.rows.length; i++) { const row = model.rows[i]!; - wF64(sink, row.pid); - wF64(sink, row.cpuTenths); - wF64(sink, row.memTenths); - wF64(sink, row.rssKb); + wI64(sink, row.pid); + wI64(sink, row.cpuTenths); + wI64(sink, row.memTenths); + wI64(sink, row.rssKb); wBytes(sink, row.name); } - wF64(sink, model.parseFailures); + wI64(sink, model.parseFailures); wNumbers(sink, model.cpuHistory as number[]); wNumbers(sink, model.memHistory as number[]); wNumbers(sink, model.procHistory as number[]); wBytes(sink, model.search.bytes); - wF64(sink, model.search.anchor); - wF64(sink, model.search.focus); - wF64(sink, model.search.compStart); - wF64(sink, model.search.compEnd); + wI64(sink, model.search.anchor); + wI64(sink, model.search.focus); + wI64(sink, model.search.compStart); + wI64(sink, model.search.compEnd); wEnum(sink, sortKeys, model.sortKey); wBool(sink, model.sortDescending); const pending = model.pendingKill; @@ -502,14 +527,14 @@ export function model_snapshot(): Uint8Array { wBool(sink, false); } else { wBool(sink, true); - wF64(sink, pending.pid); + wI64(sink, pending.pid); wBytes(sink, pending.name); } wF64(sink, model.tableScroll); wBytes(sink, model.note); wBool(sink, model.noteClearsOnSample); - wF64(sink, model.sampleGeneration); - wF64(sink, model.noteStampGeneration); + wI64(sink, model.sampleGeneration); + wI64(sink, model.noteStampGeneration); wF64(sink, model.chromeLeading); wF64(sink, model.headerHeight); return finish(sink); @@ -531,9 +556,9 @@ function bytesResult(value: Uint8Array): Uint8Array { return finish(sink); } -function numberResult(value: number): Uint8Array { +function intResult(value: number): Uint8Array { const sink = newSink(); - wF64(sink, value); + wI64(sink, value); return finish(sink); } @@ -548,7 +573,7 @@ function tableRowsResult(rows: TableRow[]): Uint8Array { wU32(sink, rows.length); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; - wF64(sink, row.pid); + wI64(sink, row.pid); wBytes(sink, row.pidText); wBytes(sink, row.name); wBytes(sink, row.cpuText); @@ -576,8 +601,8 @@ export function helper_call(helper: number, args: Uint8Array): Uint8Array { if (helper === 13) return bytesResult(h_sortDirectionIcon(committed)); if (helper === 14) return bytesResult(h_sortDirectionLabel(committed)); if (helper === 15) return tableRowsResult(h_visibleRows(committed) as TableRow[]); - if (helper === 16) return numberResult(h_matchCount(committed)); - if (helper === 17) return numberResult(h_shownCount(committed)); + if (helper === 16) return intResult(matchCount(committed)); + if (helper === 17) return intResult(shownCount(committed)); if (helper === 18) return bytesResult(h_emptyTitle(committed)); if (helper === 19) return bytesResult(h_emptyHint(committed)); if (helper === 20) return bytesResult(h_statusLine(committed)); diff --git a/tests/compiled-core/profiles/ai-chat.profile.json b/tests/compiled-core/profiles/ai-chat.profile.json index 00771dc8e..10053e103 100644 --- a/tests/compiled-core/profiles/ai-chat.profile.json +++ b/tests/compiled-core/profiles/ai-chat.profile.json @@ -36,7 +36,18 @@ "msg": "Msg", "init_export": "init", "update_export": "coreUpdate", - "subscriptions_export": "coreSubscriptions", - "integer_slots": [] + "integer_slots": [ + { "slot": "Model.nextId", "class": "i64" }, + { "slot": "Turn.id", "class": "i64" }, + { "slot": "ComposerDraft.anchor", "class": "i64" }, + { "slot": "ComposerDraft.focus", "class": "i64" }, + { "slot": "ComposerDraft.compStart", "class": "i64" }, + { "slot": "ComposerDraft.compEnd", "class": "i64" }, + { "slot": "TextSelection.anchor", "class": "i64" }, + { "slot": "TextSelection.focus", "class": "i64" }, + { "slot": "TextInputEvent_set_composition.cursor", "class": "i64" }, + { "slot": "TurnRow.id", "class": "i64" }, + { "slot": "Msg.chat_response.status", "class": "i64" } + ] } } diff --git a/tests/compiled-core/profiles/host-fixture.profile.json b/tests/compiled-core/profiles/host-fixture.profile.json index 26c04cb36..b3406fc5d 100644 --- a/tests/compiled-core/profiles/host-fixture.profile.json +++ b/tests/compiled-core/profiles/host-fixture.profile.json @@ -37,6 +37,22 @@ "init_export": "init", "update_export": "coreUpdate", "subscriptions_export": "coreSubscriptions", - "integer_slots": [] + "integer_slots": [ + { "slot": "Model.ticks", "class": "i64" }, + { "slot": "Model.failures", "class": "i64" }, + { "slot": "Model.saved", "class": "i64" }, + { "slot": "Model.lines", "class": "i64" }, + { "slot": "Model.audioEvents", "class": "i64" }, + { "slot": "Model.videoEvents", "class": "i64" }, + { "slot": "Model.imageResults", "class": "i64" }, + { "slot": "Model.nextCover", "class": "i64" }, + { "slot": "Model.topId", "class": "i64" }, + { "slot": "Model.wholeBytes", "class": "i64" }, + { "slot": "Model.topBytes", "class": "i64" }, + { "slot": "Model.chanEvents", "class": "i64" }, + { "slot": "Model.rejectSeq", "class": "i64" }, + { "slot": "Model.chanRejectAt", "class": "i64" }, + { "slot": "Model.imgRejectAt", "class": "i64" } + ] } } diff --git a/tests/compiled-core/profiles/markup.profile.json b/tests/compiled-core/profiles/markup.profile.json index 44e750ee2..c724428ed 100644 --- a/tests/compiled-core/profiles/markup.profile.json +++ b/tests/compiled-core/profiles/markup.profile.json @@ -39,7 +39,17 @@ "msg": "Msg", "init_export": "init", "update_export": "coreUpdate", - "subscriptions_export": "coreSubscriptions", - "integer_slots": [] + "integer_slots": [ + { "slot": "Model.nextId", "class": "i64" }, + { "slot": "Model.doneCount", "class": "i64" }, + { "slot": "Model.canvasWidth", "class": "i64" }, + { "slot": "Model.previewSurface", "class": "i64" }, + { "slot": "Task.id", "class": "i64" }, + { "slot": "TextSelection.anchor", "class": "i64" }, + { "slot": "TextSelection.focus", "class": "i64" }, + { "slot": "TextInputEvent_set_composition.cursor", "class": "i64" }, + { "slot": "Msg.toggle", "class": "i64" }, + { "slot": "Msg.canvas_resized", "class": "i64" } + ] } } diff --git a/tests/compiled-core/profiles/soundboard.profile.json b/tests/compiled-core/profiles/soundboard.profile.json index a25bbcafc..1f576ea73 100644 --- a/tests/compiled-core/profiles/soundboard.profile.json +++ b/tests/compiled-core/profiles/soundboard.profile.json @@ -39,6 +39,43 @@ "init_export": "init", "update_export": "coreUpdate", "subscriptions_export": "coreSubscriptions", - "integer_slots": [] + "integer_slots": [ + { "slot": "Model.openAlbum", "class": "i64" }, + { "slot": "Model.now", "class": "i64" }, + { "slot": "Model.elapsedMs", "class": "i64" }, + { "slot": "Model.nowDurationMs", "class": "i64" }, + { "slot": "Model.platformDurationMs", "class": "i64" }, + { "slot": "Model.queueDropped", "class": "i64" }, + { "slot": "Model.copiesRequested", "class": "i64" }, + { "slot": "QueueEntry.id", "class": "i64" }, + { "slot": "Msg.open_album", "class": "i64" }, + { "slot": "Msg.play_album", "class": "i64" }, + { "slot": "Msg.play_track", "class": "i64" }, + { "slot": "Msg.queue_track", "class": "i64" }, + { "slot": "Msg.copy_title", "class": "i64" }, + { "slot": "SearchDraft.anchor", "class": "i64" }, + { "slot": "SearchDraft.focus", "class": "i64" }, + { "slot": "SearchDraft.compStart", "class": "i64" }, + { "slot": "SearchDraft.compEnd", "class": "i64" }, + { "slot": "TextSelection.anchor", "class": "i64" }, + { "slot": "TextSelection.focus", "class": "i64" }, + { "slot": "TextInputEvent_set_composition.cursor", "class": "i64" }, + { "slot": "Msg_audio_event.positionMs", "class": "i64" }, + { "slot": "Msg_audio_event.durationMs", "class": "i64" }, + { "slot": "AlbumCell.id", "class": "i64" }, + { "slot": "AlbumCell.cover", "class": "i64" }, + { "slot": "TrackRow.id", "class": "i64" }, + { "slot": "TrackRow.number", "class": "i64" }, + { "slot": "helpers.gridColumns.return", "class": "i64" }, + { "slot": "helpers.gridShownColumns.return", "class": "i64" }, + { "slot": "helpers.visibleAlbumCount.return", "class": "i64" }, + { "slot": "helpers.albumCount.return", "class": "i64" }, + { "slot": "helpers.visibleTrackCount.return", "class": "i64" }, + { "slot": "helpers.trackCount.return", "class": "i64" }, + { "slot": "helpers.openAlbumId.return", "class": "i64" }, + { "slot": "helpers.openAlbumCover.return", "class": "i64" }, + { "slot": "helpers.nowPlayingCover.return", "class": "i64" }, + { "slot": "helpers.queueLen.return", "class": "i64" } + ] } } diff --git a/tests/compiled-core/profiles/system-monitor.profile.json b/tests/compiled-core/profiles/system-monitor.profile.json index 20d91eabe..8ff251d23 100644 --- a/tests/compiled-core/profiles/system-monitor.profile.json +++ b/tests/compiled-core/profiles/system-monitor.profile.json @@ -37,6 +37,42 @@ "init_export": "init", "update_export": "coreUpdate", "subscriptions_export": "coreSubscriptions", - "integer_slots": [] + "integer_slots": [ + { "slot": "Model.ticksSkipped", "class": "i64" }, + { "slot": "Model.samplesTaken", "class": "i64" }, + { "slot": "Model.sampledAtDayMs", "class": "i64" }, + { "slot": "Model.cores", "class": "i64" }, + { "slot": "Model.memTotalBytes", "class": "i64" }, + { "slot": "Model.cpuPercentTenths", "class": "i64" }, + { "slot": "Model.memUsedBytes", "class": "i64" }, + { "slot": "Model.processCount", "class": "i64" }, + { "slot": "Model.uptimeSeconds", "class": "i64" }, + { "slot": "Model.parseFailures", "class": "i64" }, + { "slot": "Model.sampleGeneration", "class": "i64" }, + { "slot": "Model.noteStampGeneration", "class": "i64" }, + { "slot": "ParsedProcess.pid", "class": "i64" }, + { "slot": "ParsedProcess.cpuTenths", "class": "i64" }, + { "slot": "ParsedProcess.memTenths", "class": "i64" }, + { "slot": "ParsedProcess.rssKb", "class": "i64" }, + { "slot": "SearchDraft.anchor", "class": "i64" }, + { "slot": "SearchDraft.focus", "class": "i64" }, + { "slot": "SearchDraft.compStart", "class": "i64" }, + { "slot": "SearchDraft.compEnd", "class": "i64" }, + { "slot": "PendingKill.pid", "class": "i64" }, + { "slot": "TextSelection.anchor", "class": "i64" }, + { "slot": "TextSelection.focus", "class": "i64" }, + { "slot": "TextInputEvent_set_composition.cursor", "class": "i64" }, + { "slot": "TableRow.pid", "class": "i64" }, + { "slot": "Msg.info_done.code", "class": "i64" }, + { "slot": "Msg.info2_done.info2Code", "class": "i64" }, + { "slot": "Msg.ps_done.psCode", "class": "i64" }, + { "slot": "Msg.mem_done.memCode", "class": "i64" }, + { "slot": "Msg.kill_done.killCode", "class": "i64" }, + { "slot": "Msg.copy_name", "class": "i64" }, + { "slot": "Msg.stamped", "class": "i64" }, + { "slot": "Msg.request_kill", "class": "i64" }, + { "slot": "helpers.matchCount.return", "class": "i64" }, + { "slot": "helpers.shownCount.return", "class": "i64" } + ] } } diff --git a/tests/compiled-core/wire.ts b/tests/compiled-core/wire.ts index 64f6cfdbc..c43374dc5 100644 --- a/tests/compiled-core/wire.ts +++ b/tests/compiled-core/wire.ts @@ -47,6 +47,20 @@ export function readI64(bytes: Uint8Array, at: number): number { return value; } +/// An i64 payload saturated into the f64-exact window: values at or +/// past ±2^53 clamp to ±(2^53 − 1). Selection offsets ride this reader +/// because the host's select-all synthesizes the maxInt "to the end" +/// sentinel, which every consumer snaps to the text's length. +export function readI64Saturating(bytes: Uint8Array, at: number): number { + const lo = readU32(bytes, at); + const hi = readU32(bytes, at + 4); + const hiSigned = hi >= 2147483648 ? hi - 4294967296 : hi; + const value = hiSigned * 4294967296 + lo; + if (value > 9007199254740991) return 9007199254740991; + if (value < -9007199254740991) return -9007199254740991; + return value; +} + export function readBool(bytes: Uint8Array, at: number): boolean { if (at >= bytes.length) { trap("a dispatch payload ended mid-value — the host and this core disagree about a type's layout"); @@ -206,14 +220,18 @@ export function decodeTextInputEvent(bytes: Uint8Array): TextInputEvent { return { kind: "move_caret", move: { direction: caretDirections[member]!, extend: extend } }; } case 7: { - // f64-classed like every corpus contract's numeric slot: the - // integer classes wait on prove-or-refuse discharge through the - // whole write graph (see the corpus profiles' empty - // integer_slots), and every corpus value is exact in f64. - const anchor = readF64(bytes, 1); - const focus = readF64(bytes, 9); + // Selection indices are i64-classed slots and ride the i64 + // encoding (saturating: select-all sends the maxInt "to the end" + // sentinel): bind the decoded values, range-guard them (an + // ordered comparison excludes NaN), and state wholeness with + // Math.trunc at the write. + const anchor = readI64Saturating(bytes, 1); + const focus = readI64Saturating(bytes, 9); assertConsumed(bytes, 17); - return { kind: "set_selection", selection: { anchor: anchor, focus: focus } }; + if (anchor >= -9007199254740991 && anchor <= 9007199254740991 && focus >= -9007199254740991 && focus <= 9007199254740991) { + return { kind: "set_selection", selection: { anchor: Math.trunc(anchor), focus: Math.trunc(focus) } }; + } + trap("a selection index is NaN or past ±(2^53 − 1) — the i64 slot has no honest value for it"); } case 8: { const len = readU32(bytes, 1); @@ -222,14 +240,15 @@ export function decodeTextInputEvent(bytes: Uint8Array): TextInputEvent { const present = readBool(bytes, at); at = at + 1; if (present) { - // The cursor rides f64: an optional's INNER integer class sits - // outside the profile's integer_slots vocabulary today, so the - // emitted contract classes this slot optional-f64 and the - // mirror encodes it that way (the value is a whole byte offset - // either way). - const cursor = readF64(bytes, at); + // The cursor is an i64-classed optional slot: range-guard the + // decoded value (an ordered comparison excludes NaN) and state + // wholeness with Math.trunc at the write. + const cursor = readI64(bytes, at); assertConsumed(bytes, at + 8); - return { kind: "set_composition", text: text, cursor: cursor }; + if (cursor >= -9007199254740991 && cursor <= 9007199254740991) { + return { kind: "set_composition", text: text, cursor: Math.trunc(cursor) }; + } + trap("a composition cursor is NaN or past ±(2^53 − 1) — an i64 slot has no honest value for it"); } assertConsumed(bytes, at); return { kind: "set_composition", text: text, cursor: null }; diff --git a/tests/sidecar/external_core_parity_tests.zig b/tests/sidecar/external_core_parity_tests.zig index 808318950..857d8be76 100644 --- a/tests/sidecar/external_core_parity_tests.zig +++ b/tests/sidecar/external_core_parity_tests.zig @@ -57,6 +57,13 @@ fn rawSubscriptions() []const u8 { return ptr[0..len]; } +fn rawFrameMsg(width: f64, height: f64, timestamp_ms: f64, interval_ms: f64) []const u8 { + var ptr: [*]const u8 = undefined; + var len: usize = 0; + abi.frame_msg(width, height, timestamp_ms, interval_ms, &ptr, &len); + return ptr[0..len]; +} + /// The transpiler lane's committed model, re-expressed in the mirror's /// sidecar-classed layout and canonically encoded — the reference bytes /// the archive's snapshot must equal. @@ -85,7 +92,7 @@ const script = [_]shim_core.Msg{ .add, .add, .{ .toggle = 2 }, - .{ .pick = 2 }, + .{ .pick = 2.5 }, .add, .cycle, .{ .banner_set = "parity" }, @@ -338,6 +345,11 @@ test "channel entries match the transpiler lane through the bytes envelope" { shim_model = next.shim; try testing.expect(ts_core.frameMsg(ts_model, .{ .width = 800, .height = 600, .timestampMs = 32, .intervalMs = 16 }) == null); try testing.expect(shim_core.frameMsg(shim_model, .{ .width = 800, .height = 600, .timestampMs = 32, .intervalMs = 16 }) == null); + // The raw archive entry receives f64 logical points before the + // generated mirror narrows its classed width. Compare after + // truncation so a fractional presentation at the committed + // width does not keep the channel alive. + try testing.expectEqualSlices(u8, &.{ 0, 0 }, rawFrameMsg(800.75, 600, 48, 16)); } // The key-fallback channel: a bare arm rides the header-only diff --git a/tests/ts-core/fixture.ts b/tests/ts-core/fixture.ts index bc42ea403..4c746c6af 100644 --- a/tests/ts-core/fixture.ts +++ b/tests/ts-core/fixture.ts @@ -74,6 +74,8 @@ export interface Model { // aliases on the f64 wire) must map to "unknown size" — there is no // one honest count to verify against. readonly topBytes: number; + // Holds 2^53 by design — past the i64 class's provable ±(2^53 − 1) + // window, so this slot stays f64-classed in every contract. readonly pastBytes: number; readonly chanState: ChannelState; readonly chanEvents: number; @@ -208,9 +210,9 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { case "loaded": return [{ ...model, status: msg.body }, Cmd.none]; case "failed": - return [{ ...model, failures: model.failures + 1, lastErr: msg.why }, Cmd.none]; + return [{ ...model, failures: (model.failures < 9007199254740991 ? model.failures + 1 : 9007199254740991), lastErr: msg.why }, Cmd.none]; case "tick": - return [{ ...model, ticks: model.ticks + 1, lastTickAt: msg.at }, Cmd.none]; + return [{ ...model, ticks: (model.ticks < 9007199254740991 ? model.ticks + 1 : 9007199254740991), lastTickAt: msg.at }, Cmd.none]; case "stamped": return [{ ...model, stampMs: msg.at }, Cmd.none]; case "save": @@ -220,7 +222,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { case "load": return [model, Cmd.readFile(asciiBytes(".zig-cache/tmp/ts-core-e2e/store.bin"), { key: "file", ok: "loaded", err: "failed" })]; case "wrote": - return [{ ...model, saved: model.saved + 1 }, Cmd.none]; + return [{ ...model, saved: (model.saved < 9007199254740991 ? model.saved + 1 : 9007199254740991) }, Cmd.none]; case "get": return [ model, @@ -250,7 +252,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { case "kill": return [model, Cmd.cancel("job")]; case "lined": - return [{ ...model, lines: model.lines + 1, lastLine: msg.text }, Cmd.none]; + return [{ ...model, lines: (model.lines < 9007199254740991 ? model.lines + 1 : 9007199254740991), lastLine: msg.text }, Cmd.none]; case "ended": return [{ ...model, exitCode: msg.code }, Cmd.none]; case "play": @@ -273,7 +275,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { durMs: msg.durationMs, playing: msg.playing, bands: msg.bands, - audioEvents: model.audioEvents + 1, + audioEvents: (model.audioEvents < 9007199254740991 ? model.audioEvents + 1 : 9007199254740991), }, Cmd.none]; case "play_clip": // The media-surface id a video widget would bind: the decoded @@ -293,7 +295,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { vPlaying: msg.playing, vW: msg.width, vH: msg.height, - videoEvents: model.videoEvents + 1, + videoEvents: (model.videoEvents < 9007199254740991 ? model.videoEvents + 1 : 9007199254740991), }, Cmd.none]; case "show_cover": // The runtime ImageId the views bind; the model only adopts it @@ -308,7 +310,7 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // more in-flight load, so the e2e suite can fill the bridge's // 16-entry image table and prove the 17th answers "rejected" // (never a crash) while the 16 live loads stay healthy. - return [{ ...model, nextCover: model.nextCover + 1 }, Cmd.imageLoad(model.nextCover, { path: asciiBytes("art/flood.png") }, { event: "image_done" })]; + return [{ ...model, nextCover: (model.nextCover < 9007199254740991 ? model.nextCover + 1 : 9007199254740991) }, Cmd.imageLoad(model.nextCover, { path: asciiBytes("art/flood.png") }, { event: "image_done" })]; case "load_top": // 2^53 - 1 reaching the bridge as a DYNAMIC value the emitter's // literal gate never sees: the last id every tier carries @@ -393,10 +395,10 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { // The echoed id IS the adopted id — the store-the-id-on-success // discipline reads it off the result instead of hardcoding it. if (msg.state === "loaded") - return [{ ...model, cover: msg.id, coverW: msg.width, coverH: msg.height, imageState: msg.state, imageStatus: msg.status, imageResults: model.imageResults + 1, lastImageId: msg.id }, Cmd.none]; + return [{ ...model, cover: msg.id, coverW: msg.width, coverH: msg.height, imageState: msg.state, imageStatus: msg.status, imageResults: (model.imageResults < 9007199254740991 ? model.imageResults + 1 : 9007199254740991), lastImageId: msg.id }, Cmd.none]; if (msg.state === "rejected") - return [{ ...model, imageState: msg.state, imageStatus: msg.status, imageResults: model.imageResults + 1, lastImageId: msg.id, rejectSeq: model.rejectSeq + 1, imgRejectAt: model.rejectSeq + 1 }, Cmd.none]; - return [{ ...model, imageState: msg.state, imageStatus: msg.status, imageResults: model.imageResults + 1, lastImageId: msg.id }, Cmd.none]; + return [{ ...model, imageState: msg.state, imageStatus: msg.status, imageResults: (model.imageResults < 9007199254740991 ? model.imageResults + 1 : 9007199254740991), lastImageId: msg.id, rejectSeq: (model.rejectSeq < 9007199254740991 ? model.rejectSeq + 1 : 9007199254740991), imgRejectAt: (model.rejectSeq < 9007199254740991 ? model.rejectSeq + 1 : 9007199254740991) }, Cmd.none]; + return [{ ...model, imageState: msg.state, imageStatus: msg.status, imageResults: (model.imageResults < 9007199254740991 ? model.imageResults + 1 : 9007199254740991), lastImageId: msg.id }, Cmd.none]; case "watch": return [model, Cmd.channelOpen(41, { event: "chan_evt" })]; case "mix_reject": @@ -417,8 +419,8 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { ])]; case "chan_evt": if (msg.state === "rejected") - return [{ ...model, chanState: msg.state, chanEvents: model.chanEvents + 1, rejectSeq: model.rejectSeq + 1, chanRejectAt: model.rejectSeq + 1 }, Cmd.none]; - return [{ ...model, chanState: msg.state, chanEvents: model.chanEvents + 1 }, Cmd.none]; + return [{ ...model, chanState: msg.state, chanEvents: (model.chanEvents < 9007199254740991 ? model.chanEvents + 1 : 9007199254740991), rejectSeq: (model.rejectSeq < 9007199254740991 ? model.rejectSeq + 1 : 9007199254740991), chanRejectAt: (model.rejectSeq < 9007199254740991 ? model.rejectSeq + 1 : 9007199254740991) }, Cmd.none]; + return [{ ...model, chanState: msg.state, chanEvents: (model.chanEvents < 9007199254740991 ? model.chanEvents + 1 : 9007199254740991) }, Cmd.none]; } } diff --git a/tests/ts-core/markup_fixture.ts b/tests/ts-core/markup_fixture.ts index d725aa09f..82e0115b7 100644 --- a/tests/ts-core/markup_fixture.ts +++ b/tests/ts-core/markup_fixture.ts @@ -67,7 +67,9 @@ export interface Model { export type Msg = | { readonly kind: "add" } - | { readonly kind: "toggle"; readonly id: number } + // The distinct field name keeps this i64 proof obligation separate + // from the f64-classed pick/hover arms after structural lowering. + | { readonly kind: "toggle"; readonly taskId: number } | { readonly kind: "pick"; readonly id: number } | { readonly kind: "cycle" } | { readonly kind: "clear" } @@ -86,7 +88,16 @@ export type Msg = /// frame that changes nothing dispatches nothing, so the channel starves /// when the app is idle). export function frameMsg(model: Model, frame: FrameEvent): Msg | null { - if (frame.width !== model.canvasWidth) return { kind: "canvas_resized", width: frame.width }; + // The width lands in an i64-classed slot: bind it, range-guard it (an + // ordered comparison excludes NaN), and state wholeness with + // Math.trunc at the write. + const width = frame.width; + if (width >= 0 && width <= 9007199254740991) { + const wholeWidth = Math.trunc(width); + if (wholeWidth !== model.canvasWidth) { + return { kind: "canvas_resized", width: wholeWidth }; + } + } return null; } @@ -174,17 +185,22 @@ export function update(model: Model, msg: Msg): [Model, Cmd] { const slot = model.nextId % 3; const title = slot === 0 ? asciiBytes("alpha") : slot === 1 ? asciiBytes("beta") : asciiBytes("gamma"); const added: Task = { id: model.nextId, title: title, done: false }; - return [{ ...model, tasks: [...model.tasks, added], nextId: model.nextId + 1 }, Cmd.none]; + // The bump saturates at the i64 class's provable ceiling. + const bumped = model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991; + return [{ ...model, tasks: [...model.tasks, added], nextId: bumped }, Cmd.none]; } case "toggle": { - const next = model.tasks.map((t) => (t.id === msg.id ? { ...t, done: !t.done } : t)); + const next = model.tasks.map((t) => (t.id === msg.taskId ? { ...t, done: !t.done } : t)); let done = 0; for (let i = 0; i < next.length; i++) { if (next[i].done) { done = done + 1; } } - return [{ ...model, tasks: next, doneCount: done }, Cmd.none]; + // Loop-accumulated counter: the guard settles range and Math.trunc + // states wholeness for the i64-classed slot. + const settled = done <= 9007199254740991 ? done : 9007199254740991; + return [{ ...model, tasks: next, doneCount: Math.trunc(settled) }, Cmd.none]; } case "pick": return [{ ...model, selected: msg.id }, Cmd.none];