test(highway): the R3c perf gate — measure the render loop before carving it - #910
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe highway API now exposes render-loop timing, scaling, and budget metrics. A Playwright test starts active chart playback, samples draw cost, and verifies it remains below the configured upper render budget. ChangesHighway performance measurement
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Playwright
participant LibraryAPI
participant Highway
participant ChartClock
Playwright->>LibraryAPI: load first real song
Playwright->>Highway: pin render scale and start playback
Highway->>ChartClock: advance chart clock
Playwright->>Highway: sample getPerf()
Highway-->>Playwright: return drawMs and drawBudgetHiMs
Playwright->>Playwright: assert drawMs is finite and below budget
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
tests/browser/highway-perf-baseline.spec.tsParsing error: Unexpected token as Comment |
…ving it (R3c) highway.getPerf() (additive) + tests/browser/highway-perf-baseline.spec.ts. No behaviour change. This lands BEFORE highway.js is touched, because a perf-gated refactor without a perf gate is just a refactor. ━━━ FRAME RATE IS THE WRONG THING TO MEASURE ━━━ The highway AUTO-SCALES. When the smoothed draw cost passes _DRAW_BUDGET_HI_MS (12ms) it LOWERS THE RENDER RESOLUTION to protect the frame rate (#654). Exactly right for players — and it means a real perf regression does NOT show up as dropped frames. It shows up as a BLURRIER PICTURE at a perfectly healthy 60fps. Benchmark fps and you measure the feedback loop, not the renderer, and conclude nothing changed while the image quietly degrades. So the gate pins the scale (setRenderScale(1) + setMinRenderScale(1), which clamps autoScale to [1,1]) and measures drawMs — the renderer's own cost. None of that was reachable before: neither drawMs nor the effective scale escaped the closure. Hence getPerf(). The threshold is the app's OWN: _DRAW_BUDGET_HI_MS is the cost at which the highway itself starts sacrificing resolution in production. Exceeding it is not an arbitrary benchmark line — it is the renderer failing its own budget. Current cost ~2.2ms, so ~5x headroom: far more than headless-CI variance, far less than any regression worth shipping. ━━━ I WROTE THIS GATE WRONG THREE TIMES. EACH TIME IT PASSED. ━━━ 1. VACUOUS ASSERTION. First cut asserted "the auto-scaler wasn't forced to intervene", i.e. effectiveScale == 1. I injected a 10x regression (drawMs 2.4 -> 22.4ms, nearly DOUBLE the budget) and it PASSED. Of course it did: setMinRenderScale(1) sets the scaler's FLOOR to 1, so effectiveScale CANNOT drop below it. The very pinning that stops the scaler hiding a regression also stops it ever reporting one. A guard that cannot fail. 2. MEASURING AN IDLE RENDERER (Codex [P2]). playSong() takes ~3-4s to actually start — it is fetching and decoding stems. My "if not playing after 2s, togglePlay()" fired BEFORE autoplay, started playback, and then the app's own autoplay toggled it straight back to PAUSED. The renderer idled through the entire measurement. Now it WAITS for playback rather than racing it, and asserts the chart clock advanced DURING the sampling window — not merely at some point beforehand, which the first fix would have accepted. 3. UNENCODED FILENAME (Codex [P2]). playSong() decodes its argument before building the /ws/highway path, so every real caller passes encodeURIComponent(filename) (app.js:2879, 4137). Raw, a name containing # ? % or / yields an invalid WebSocket URL, the song never loads — and on those libraries the gate would have silently measured an idle renderer instead of failing. Every one of those bugs made the gate PASS. That is the whole hazard of a perf test: it fails safe in the wrong direction. BITE-TESTED, and this is the only reason I trust it: a 10x regression injected into the draw path FAILS the gate under live playback (22.0ms vs the 12ms budget) and the clean build passes at ~2.1ms with the chart clock advancing 5.6s across the sample. node 1045, pytest 2412, ESLint 0, Codex 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dbbb1bd to
1783d58
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
static/highway.js (1)
3149-3159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
getPerf()largely duplicates the pre-existinggetPerfStats().Both return
drawMs,autoScale,renderScale, andeffectiveScale;getPerf()just addsframeMsand the two budget constants. Two near-identical read-only snapshot methods on the same object invite drift (a future perf tweak could update one and forget the other). Consider havinggetPerfStats()delegate togetPerf()(or vice versa) to keep a single source of truth.♻️ Example consolidation
getPerfStats() { - return { - drawMs: hwState._drawMsEMA, - autoScale: hwState._autoScale, - renderScale: hwState._renderScale, - effectiveScale: _effectiveRenderScale(), - }; + const p = api.getPerf(); + return { drawMs: p.drawMs, autoScale: p.autoScale, renderScale: p.renderScale, effectiveScale: p.effectiveScale }; },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@static/highway.js` around lines 3149 - 3159, Consolidate the duplicate performance snapshot logic in getPerf() and getPerfStats() by making one method delegate to the other, preserving the existing shared fields and additional frame/budget fields. Keep a single source of truth so future performance metrics cannot diverge between the two methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@static/highway.js`:
- Around line 3149-3159: Update _updatePerfHud so frame-timing bookkeeping runs
regardless of hwState._hudOn: calculate and smooth _frameMsEMA from
_lastFramePerf, then record the current timestamp before the HUD visibility
early return. Keep the existing HUD element creation/removal logic gated by
_hudOn, ensuring getPerf().frameMs remains live when the debug HUD is disabled.
---
Nitpick comments:
In `@static/highway.js`:
- Around line 3149-3159: Consolidate the duplicate performance snapshot logic in
getPerf() and getPerfStats() by making one method delegate to the other,
preserving the existing shared fields and additional frame/budget fields. Keep a
single source of truth so future performance metrics cannot diverge between the
two methods.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 57e539e3-4b6c-4f83-85b3-595dd32b15ec
📒 Files selected for processing (3)
static/highway.jsstatic/tailwind.min.csstests/browser/highway-perf-baseline.spec.ts
| getPerf() { | ||
| return { | ||
| drawMs: hwState._drawMsEMA, // smoothed cost of _renderer.draw() | ||
| frameMs: hwState._frameMsEMA, // smoothed frame interval | ||
| renderScale: hwState._renderScale, // the user's Quality setting | ||
| autoScale: hwState._autoScale, // what the load-adaptive loop chose | ||
| effectiveScale: _effectiveRenderScale(), | ||
| drawBudgetHiMs: _DRAW_BUDGET_HI_MS, | ||
| drawBudgetLoMs: _DRAW_BUDGET_LO_MS, | ||
| }; | ||
| }, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
getPerf().frameMs returns a stale/zero value unless the debug HUD flag is enabled.
frameMs is documented as "smoothed frame interval" but hwState._frameMsEMA is only ever mutated inside _updatePerfHud() (lines ~1379-1414), and only when hwState._hudOn is true — which itself only flips true when localStorage.getItem('highwayPerfHud') === '1'. For any caller of the new public API that hasn't manually set that debug flag, getPerf().frameMs will silently report 0 forever, since _lastFramePerf/_frameMsEMA are never touched otherwise. This isn't hit by the accompanying test (it only logs frameMs, never asserts on it), but it makes a documented field of the new public contract non-functional by default.
Root cause: the frame-timing bookkeeping is nested inside the same if (!hwState._hudOn) return; guard that also gates the DOM/HUD element lifecycle — those are two unrelated concerns sharing one early-return.
🐛 Proposed fix — decouple frame-timing EMA from the HUD-visibility gate (in `_updatePerfHud`, ~line 1379)
function _updatePerfHud() {
if (typeof document === 'undefined' || !document.body) return;
const nowP = performance.now();
if (nowP - hwState._hudFlagAt > 500) {
hwState._hudFlagAt = nowP;
try { hwState._hudOn = localStorage.getItem('highwayPerfHud') === '1'; } catch (_) { hwState._hudOn = false; }
}
// Always update the EMA, independent of whether the HUD is displayed —
// getPerf().frameMs must be live for any caller, not just when the debug
// overlay is on.
if (hwState._lastFramePerf) {
const d = nowP - hwState._lastFramePerf;
hwState._frameMsEMA = hwState._frameMsEMA === 0 ? d : hwState._frameMsEMA * 0.9 + d * 0.1;
}
hwState._lastFramePerf = nowP;
if (!hwState._hudOn) {
if (hwState._perfHud) { hwState._perfHud.remove(); hwState._perfHud = null; }
return;
}
if (!hwState._perfHud) {
// ... unchanged HUD element creation🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@static/highway.js` around lines 3149 - 3159, Update _updatePerfHud so
frame-timing bookkeeping runs regardless of hwState._hudOn: calculate and smooth
_frameMsEMA from _lastFramePerf, then record the current timestamp before the
HUD visibility early return. Keep the existing HUD element creation/removal
logic gated by _hudOn, ensuring getPerf().frameMs remains live when the debug
HUD is disabled.
Two lines. index.html: defer -> type="module". highway.js: one explicit assignment.
highway.js can now `import`, which is the whole point — the carve can begin.
━━━ THE ONE THING THE FLIP ACTUALLY BREAKS: window.createHighway ━━━
A top-level `function createHighway()` in a CLASSIC script IMPLICITLY becomes
window.createHighway. In a module it does not — module declarations are module-scoped, and
the name vanishes from the global object the instant the tag grows type="module".
The constitution names window.createHighway as PUBLIC EXTENSION CONTRACT (alongside
window.playSong / showScreen / feedBack). NOTHING IN-TREE CALLS IT. That is exactly why this
would have shipped: the only consumers are third-party plugins rendering their own highway
panel, and I cannot grep those. Green CI, green tests, and a broken plugin API.
Verified by removing the assignment and reloading:
flip WITHOUT an explicit assignment: window.createHighway === undefined <-- gone
flip WITH it: window.createHighway === function
So it is assigned explicitly now — same object, same behaviour, no longer an accident of how
the file happens to be loaded.
━━━ AND A CORRECTION TO #912 ━━━
#912 (merged) rewrote 73 bare `highway.x` -> `window.highway.x` on the stated grounds that
the flip would turn every one of them into a ReferenceError. HAVING NOW ACTUALLY FLIPPED IT,
THAT WAS WRONG. highway.js already did `window.highway = highway`, which puts the name on the
GLOBAL OBJECT — and bare-identifier resolution falls back to the global object whether or not
a lexical global binding exists. Measured on both builds: bare `highway` resolves either way.
#912 is defensible as hygiene and it does not hurt, but it was not a precondition and it fixed
no latent bug. A correction is posted on the PR so its commit message does not mislead. The
real hazard was the factory, not the instance — same class of breakage, wrong name.
ORDERING is unchanged: classic-defer and non-async type="module" share ONE post-parse
execution queue, in document order, so highway.js keeps its position at index.html:1244.
VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors — window.highway,
window.createHighway, the full API surface, a real song playing, the chart clock advancing,
and the seek->setTime sync. THE PERF GATE PASSES at 1.85ms against its 12ms budget (module
evaluation costs nothing at render time), which is exactly what #910 was built to tell me.
node 1045, pytest 2416, ESLint 0, Codex 0.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ts.js (R3c) (#914) 29 constants, 190 lines. highway.js 4,267 -> 4,158. The first real slice, and the one that every later one imports. ━━━ WHY ONLY THE CONSTANTS MAY LIVE AT MODULE SCOPE ━━━ createHighway() is a FACTORY, not a singleton. The constitution publishes window.createHighway precisely so a plugin can build a SECOND highway for its own panel, and highway.js already says so at the top of the closure: // R3c: per-instance mutable state in one object, so extracted renderer/ws // modules can close over it as a factory arg without cross-panel sharing. So hwState — all 79 mutable properties — must NEVER become a module-level singleton: two highways would silently share it, and one panel would drive the other's clock, scale and colour tables. Extracted functions will take it as an ARGUMENT. That is the OPPOSITE of the app.js carve, where a single state container (player-state.js, library-state.js) was exactly right, because there is exactly one app. Same epic, same language, opposite answer — because one is a singleton and the other is a factory. These 29 are pure literals: numbers, strings and colour tables, never reassigned, never mutated. Sharing them across instances is not merely safe, it is what you want — one copy of the shimmer LUT bounds and the string palettes rather than one per panel. Anything with a runtime dependency (document, window, performance, localStorage) stays in the factory; checked, and none of these has one. ESLint now knows static/highway.js is a module. It could not have known before this commit: the flip (#913) changed the SCRIPT TAG, but the file had no import/export yet, so it still parsed as a script and lint stayed green. The first `import` is what makes the config wrong. TESTS. Four source-shape harnesses asserted `const _AUTO_SCALE_MIN = …` etc. lived in highway.js. They now read highway.js AND every static/js/highway-*.js — deliberately, rather than being re-pinned at whichever file currently holds a constant. Re-pinning just breaks again on the next carve, and a source-shape assertion that silently stops finding its target is indistinguishable from one that passes. Bite-tested: renaming two constants away fails them. VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. AND THE PERF GATE PASSES AT 1.97ms against its 12ms budget — which is the point of having built it (#910) first: these constants moved from closure scope to module scope, and V8 does not treat those identically. It does here. Now I know rather than hope. node 1045, pytest 2416, ESLint 0, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ometry.js (R3c) (#915) 6 functions, 53 lines. highway.js 4,158 -> 4,105. NOT ONE CALL SITE CHANGES. project, roundRect, bnvNormalizedPoints, teachingFingerLabel, teachingDegreeLabel, chordHarmonyLabels — the shared primitives every drawing function leans on. ━━━ PURITY IS THE WHOLE POINT OF THIS SLICE ━━━ Every one of these is a pure function of its arguments. None touches hwState. None closes over the canvas context — roundRect() already took `ctx` explicitly, and the rest need nothing but numbers. project() reads only the module-level constants from #914. That matters because createHighway() is a FACTORY: a plugin can build a second highway for its own panel, so anything holding per-instance state must be PASSED hwState rather than importing it, or two panels silently share one clock and palette. These six hold no state at all, so they move VERBATIM — the module boundary is invisible to every caller. The asserts are mechanical and in the extractor: it REFUSES to move a function whose body mentions hwState, or that references `ctx` without taking it as a parameter. Purity is checked, not assumed. ━━━ WHAT IS DELIBERATELY LEFT BEHIND ━━━ The four primitives that DO need hwState — fretX, fillTextReadable, _noteState, _paintGemGlow — stay in the factory for now. They need an explicit hwState parameter threaded through 53 call sites, which is a real behavioural change and belongs in its own commit rather than smuggled in beside a provably-identical move. Separating the provable from the risky is the whole discipline of this epic. TESTS. Three harnesses brace-match these functions out of the source and run them in a sandbox; they now read static/js/highway-geometry.js. `export function x` still contains `function x`, so the extractor needed no change — only the path. VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. PERF GATE PASSES AT 1.91ms against its 12ms budget — and this is the one that could plausibly have cost something: project() runs for every visible note on every frame and is now a CROSS-MODULE call. It costs nothing measurable. That is the answer #910 was built to give. node 1045, pytest 2416, ESLint 0, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
highway.getPerf()(additive) +tests/browser/highway-perf-baseline.spec.ts. No behaviour change.This lands before
highway.jsis touched, because a perf-gated refactor without a perf gate is just a refactor.Frame rate is the wrong thing to measure
The highway auto-scales. When the smoothed draw cost passes
_DRAW_BUDGET_HI_MS(12ms) it lowers the render resolution to protect the frame rate (#654).That's exactly right for players. It also means a real performance regression does not show up as dropped frames — it shows up as a blurrier picture at a perfectly healthy 60fps.
Benchmark fps and you measure the feedback loop, not the renderer, and conclude nothing changed while the image quietly degrades.
So the gate pins the scale (
setRenderScale(1)+setMinRenderScale(1), clamping autoScale to[1,1]) and measuresdrawMs— the renderer's own cost. None of that was reachable before: neitherdrawMsnor the effective scale escaped the closure. HencegetPerf().The threshold is the app's own:
_DRAW_BUDGET_HI_MSis the cost at which the highway itself starts sacrificing resolution in production. Exceeding it isn't an arbitrary benchmark line — it's the renderer failing its own budget. Current cost ~2.2ms, so ~5× headroom: far more than headless-CI variance, far less than any regression worth shipping.I wrote this gate wrong three times. Each time it passed.
1. A vacuous assertion. My first cut asserted "the auto-scaler wasn't forced to intervene" — i.e.
effectiveScale == 1. I injected a 10× regression (drawMs 2.4 → 22.4ms, nearly double the budget) and it passed.Of course it did.
setMinRenderScale(1)sets the scaler's floor to 1, soeffectiveScalecannot drop below it. The very pinning that stops the scaler hiding a regression also stops it ever reporting one. A guard that cannot fail.2. Measuring an idle renderer (Codex [P2]).
playSong()takes ~3–4s to actually start — it's fetching and decoding stems. My "if not playing after 2s, togglePlay()" fired before autoplay, started playback, and then the app's own autoplay toggled it straight back to paused. The renderer idled through the entire measurement.It now waits for playback rather than racing it, and asserts the chart clock advanced during the sampling window — not merely at some point beforehand, which my first fix would have accepted.
3. An unencoded filename (Codex [P2]).
playSong()decodes its argument before building the/ws/highwaypath, so every real caller passesencodeURIComponent(filename)(app.js:2879, 4137). Raw, a name containing#,?,%or/yields an invalid WebSocket URL and the song never loads — and on those libraries the gate would have silently measured an idle renderer instead of failing.Every one of those bugs made the gate pass. That is the whole hazard of a perf test: it fails safe in the wrong direction.
Bite-tested — the only reason I trust it
…both under live playback, with the chart clock advancing 5.6s across the sample.
node 1045 · pytest 2412 · ESLint 0 · Codex 0.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests