Skip to content

plan: keep monotonic busy_tick so the comet does not teleport #674

Description

@btipling

Review notes (2026-08-18)

Reviewed issue #674 (plan for source #673) for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.

Issue Severity Resolution
Goal 3 (spinner 8-cycle across 255→256) had no unit row Minor Locked busySpinnerCells(256) == cells(0) and one-step 255→256 in test-rich
Baseline “starts at 1 while Busy” skipped the existing 100 ms phase-0 push Minor Host tick starts 0; busy-start + reduced-motion push 0; interval does tick += 1 then setBusyTick(tick) (first animated value is 1). Do not change that 100 ms solid flash
u8 truncate on the comet path would reintroduce the hitch Minor text_wave / headPosition must see the full u32. Spinner may % 8 only

No Blockers. No existing-cap change (the 1..255 fold is a lossy map, not a transport budget).

Status: HANDOFF-READY
Reviewed: 2026-08-18 (correctness / performance / architecture / testing / cloud ops N/A / living docs / layers / UI / caps)

Plan header

Field Value
Status HANDOFF-READY
Date 2026-08-18
Type single
Parent N/A
Source issue #673 — harness: smooth the busy_tick 255→1 wrap teleport in text_wave comet
Branch plan/busy-tick-comet-wrap
Layers harness (Wasm)
Reusability impact none
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/harness-limits.md (Busy spinner wrap sentence)

Summary

The comet head in text_wave.zig teleports ~7 glyphs every 25.6 s because inv_set_busy_tick throws away the host's monotonic u32 and stores (phase-1)%255+1. The host already sends 1, 2, … 256, 257. Keep that counter. No protocol bump, no host change.

Goals

# Goal Success signal
1 Adjacent host ticks advance the comet by one SPEED step (~⅔ glyph), including across the old 255→256 boundary headPosition(256, 18) − headPosition(255, 18) ≈ 0.667; the #671 pin that 255 vs 1 jumps > 5 no longer describes paint
2 Phase 0 still means idle / reduced motion / old host — solid ramp[0], no mid-turn flash Host setBusyTick(0) and reduced-motion (no ticks) still paint a static accent line
3 Spinner stays a smooth 8-step clockwise loop across the same boundary busySpinnerCells(256) equals cells(0); 255 % 8 == 7 then 256 % 8 == 0 — no skipped cell
4 Wire unchanged Protocol stays v17; inv_set_busy_tick(u32) already

Non-goals / out of scope

  • Widening or changing the export (inv_set_busy_tick is already u32)
  • Changing HARNESS_BUSY_TICK_HZ (stays 10)
  • Host ticker rewrite / wrap remap on the DOM side
  • Changing the existing ~100 ms phase-0 solid flash at busy start
  • Occupancy / SPEED / STEPS retune (shipped in harness: faster, narrower Waiting for model… shimmer (plan #669) #671)
  • Accepting the hitch
  • Forbidden wiring: protocol bump for a value the wire already carries · dual DOM chat · secrets in Wasm · laptop-only ops · @truncate(u8, phase) (or equivalent) on the text_wave / headPosition path

Architectural decisions

Decision Options considered Choice Why
Where to fix A) Wider wire (u16/u32 export + protocol bump) · B) Wasm wrap-counter on the folded u8 · C) Stop folding — store the host u32 · D) Accept hitch C Host HarnessHost already increments a monotonic tick and calls setBusyTick(tick). The 255→1 jump is invented in inv_set_busy_tick. Restoring the bits is one assignment, no bump, no wrap-detect heuristics
0 sentinel A) Keep 0 = idle/reduced-motion · B) Use a side flag A text_wave phase-0 fast-path and old-host (busy_tick stays 0) already depend on it. Host sends 0 on Ready/Stop/error and at busy-start; busy interval ticks start at 1
Phase type through paint A) Keep u8 at paint, accumulate elsewhere · B) u32 from bridge → paintBusyRow / text_wave / rect_spinner B One type. Spinner still % 8. Avoids a second folded copy that can drift. Never truncate to u8 before headPosition
Multiply width A) u32 phase * SPEED · B) u64 B SPEED=2 overflows u32 at phase > 2^31 (~6.8 y at 10 Hz). Cheap; matches the existing “don't wrap the multiply” lesson from #671

Layer placement

Concern Layer Path(s) Rationale
Keep host monotonic tick harness native/harness/src/bridge.zig inv_set_busy_tick / busyTick() Export already u32; drop the % 255 fold; 0 still stored as 0
Comet math harness text_wave.zig headPosition / Options.phase Accept u32; % (N*STEPS) is the only wrap the comet needs
Busy row + status spinner harness busy_row.zig, rect_spinner.zig, busy_spinner.zig Take u32 phase; spinner LUT still mods 8
Host ticker DOM app/harness/HarnessHost.tsx No change — already monotonic
Docs docs docs/harness-limits.md Busy spinner row Drop “wraps 255→1” as the live behavior

Current baseline (live code)

main @ ea1ff52 (PR #671 merged). Protocol v17.

Claim Path / symbol Notes
Host ticker app/harness/HarnessHost.tsx ~752–781 tick local starts at 0. Busy-start and reduced-motion push setBusyTick(0). Interval: tick += 1 then setBusyTick(tick) → 1, 2, … 256. First ~100 ms of a turn is solid (phase 0) — existing, out of scope
Export already takes u32 bridge.zig:733 inv_set_busy_tick(phase: u32) Folds to u8: (phase-1)%255+1 — this is the bug
Stored width bridge.zig:113 var busy_tick: u8 Must widen
Reader bridge.zig:368 pub fn busyTick() u8 Callers: ui.zig:354 busy row, ui.zig:587 status spinner. No JS getter export
Comet head text_wave.zig headPosition(phase: u8, N) raw = phase * SPEED (already u32 mul); raw % (N*STEPS)
Teleport pin text_wave.test.zig 255→1 wrap teleport head(255,18)=8.0, head(1,18)≈0.667, delta>5 — documents the folded inputs, not the host sequence
Phase 0 fast-path text_wave.paint phase==0 → one addText at ramp[0]
Spinner LUT busy_spinner.busySpinnerCells(phase: u8) phase % 8. Existing test: 255 == 7; add 256 == 0
Layout tests busy_row_layout.test.zig paintBusyRow(0, 0) / (7, 60) Comptime ints coerce to u32
Protocol PROTOCOL_VERSION = 17 No new export. Do not bump
AGENTS / feature-divide wrap claim none (only harness-limits.md) AGENTS N/A is correct
#666 queue PR still open Orthogonal; no touch

Math of the folded hitch (N=18, STEPS=3, SPEED=2, period=54):

Host tick Stored today raw head
255 255 510 8.000
256 1 2 0.667
256 (this plan) 256 512 8.667

512 % 54 = 26, 26/3 ≈ 8.667. Step from 8.000 is 2/3 — one SPEED increment.

Design

inv_set_busy_tick

var busy_tick: u32 = 0;

pub fn busyTick() u32 {
    return busy_tick;
}

export fn inv_set_busy_tick(phase: u32) void {
    // 0 stays the idle / reduced-motion / old-host / busy-start sentinel.
    // Every positive host tick is kept as-is — do not fold to 1..255.
    busy_tick = phase;
    refresh();
}

Host already Math.max(0, Math.floor(phase)) | 0, so negatives never arrive. No second remap.

inv_init / clear paths that set busy_tick = 0 stay.

text_wave

  • Options.phase: u32
  • headPosition(phase: u32, N: usize) f32 — multiply in u64:
    raw = @as(u64, phase) * @as(u64, SPEED); return (raw % period) / STEPS
  • Phase 0 fast-path unchanged
  • Do not @truncate / @intCast to u8 before this function

Paint plumbing

paintBusyRow(phase: u32, …), rect_spinner.Options.phase: u32, busySpinnerCells(phase: u32)% 8 as today. ui.zig already passes bridge.busyTick() through.

Tests (replace the #671 pin)

The old test pins the folded pair (255, 1). Paint will no longer see that pair. Replace it:

Case Expect
Continuity at the old fold headPosition(256, 18) − headPosition(255, 18) ≈ 2/3
One SPEED step in the interior headPosition(8, 18) − headPosition(7, 18) ≈ 2/3
Ring wrap of the comet (not the tick) still works headPosition at raw % 54 == 00.0 (e.g. phase 27 on N=18)
Phase 0 still 0 headPosition(0, 18) == 0 (fast-path domain; paint does not wave)
u64 mul headPosition(1 << 31, 18) is not 0 (u32 phase*2 would overflow to 0); expect ≈ 7.333 (2^32 % 54 = 22, /3)
Spinner across the old fold busySpinnerCells(256) equals cells(0); cells(255) equals cells(7) (already pinned)

Keep the occupancy / trail / UTF-8 tests as-is.

Do not keep delta(255, 1) > 5 as a live paint contract — that pair is not on the host sequence.

Docs

In docs/harness-limits.md Busy spinner row, replace the live claim that the bridge “maps busy ticks to 1..255 (wrapping 255→1)” with: host monotonic u32 is stored as-is; 0 is reserved for idle / reduced motion / busy-start; the comet wraps only on N*STEPS (visible loop ~2.7 s), not on a 25.6 s u8 fold. Timeless — no issue numbers.

Cloud ops path

N/A — no Production mutate. Zig on the self-hosted build-harness runner (existing artifact → Vercel).

Living docs plan

Surface Change Notes
docs/harness-limits.md Busy spinner wrap sentence (see Design) Timeless; no issue/phase theater
AGENTS.md N/A — protocol stays v17; no 255-wrap claim to update
README.md N/A — visitor entry unchanged
SECURITY.md N/A — no secrets / trust-boundary change
.env.example N/A — no env
docs/feature-divide.md N/A — spinner ownership row has no 255-wrap claim

Implementation order

  1. text_wave.zigphase: u32, u64 multiply; rewrite the wrap test
  2. text_wave.test.zig — continuity + large-phase cases
  3. bridge.zigbusy_tick: u32, drop % 255 fold, busyTick() u32
  4. busy_row.zig / rect_spinner.zig / busy_spinner.zig — phase u32
  5. busy_spinner.test.zig256 == 0 (keep 255 == 7)
  6. docs/harness-limits.md — wrap sentence
  7. Confirm build.zig test-rich still registers text_wave + busy_spinner (no new module)

Testing

# Case Layer Type Command / method
1 headPosition(256, 18) − headPosition(255, 18) ≈ 2/3 harness unit zig build test-rich
2 Interior step 7 → 8 on N=18 is also ≈ 2/3 harness unit zig build test-rich
3 Comet loop: phase 27 on N=18 → head 0 (existing) harness unit zig build test-rich
4 Phase 0 → head 0 (fast-path domain) harness unit zig build test-rich
5 headPosition(1<<31, 18) ≈ 7.333 (u64 mul; not u32-wrap 0) harness unit zig build test-rich
6 Occupancy / trail / UTF-8 tests still green harness unit zig build test-rich
7 busySpinnerCells(256) == cells(0); cells(255) == cells(7) harness unit zig build test-rich
8 Host setBusyTick still floors / clamps / accepts 0 (existing TS) DOM unit npm test
9 Operator: a turn held past ~26 s — comet does not jump Wasm operator Preview
10 Operator: reduced motion / Stop — static accent, no leftover wave Wasm operator Preview
11 npx tsc --noEmit + build-harness both CI agent / GHA

Minimum locked for DoD: 1–8, 11, plus operator 9–10.

Caps table

Cap / ceiling Value Rationale Code location
HARNESS_BUSY_TICK_HZ 10 (unchanged) Existing host cadence. This plan does not raise or lower it HarnessHost.tsx
Wire inv_set_busy_tick u32 (already) Host tick is already this width. Not a change bridge.zig export
Idle sentinel 0 (unchanged) Reduced motion / old host / Stop / busy-start text_wave phase-0 fast-path

No existing cap is raised or lowered. The 1..255 fold is an internal lossy map, not a transport budget — it is removed, not replaced with a new named cap. The comet’s only wrap is N * STEPS (period 54 at N=18), already locked.

Definition of done

  • Adjacent host ticks (including 255→256) move the comet by one SPEED step; no ≥5-glyph jump
  • Phase 0 still solid accent (idle / reduced motion / old host / busy-start)
  • Spinner 8-cycle is continuous across the same boundary (256 ≡ 0)
  • Protocol remains v17 — no export / REQUIRED_FNS change
  • text_wave + busy_spinner tests green (zig build test-rich); #671 teleport pin replaced by the continuity cases
  • npm test + npx tsc --noEmit green
  • build-harness green
  • docs/harness-limits.md Busy spinner row describes monotonic u32 + 0 sentinel (no “wraps 255→1” as live behavior)
  • Cloud ops: N/A
  • No dual-chat; no existing-cap change; no u8 truncate on the comet path

Risks & mitigations

Risk Mitigation
Something else assumed busyTick() is u8 Zig will fail the type at compile; grep busyTick / .phase after the widen
Layout tests pass a u8 literal into paintBusyRow Coerces to u32 in Zig — no change needed
Host mock tests stored the raw argument and never saw the fold They stay green; the fold lived only in Wasm
Implementer @truncates to u8 “to match Options” Forbidden above; comet tests 256 vs 255 fail if they do
Very long turn (u32 tick ~13 y) Ignore; u64 mul covers the SPEED=2 doubling

Open questions

None — in-scope choices are locked.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions