Skip to content

test(highway): the R3c perf gate — measure the render loop before carving it - #910

Merged
byrongamatos merged 1 commit into
mainfrom
r3c/highway-baseline
Jul 12, 2026
Merged

byrongamatos merged 1 commit into
mainfrom
r3c/highway-baseline

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

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).

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 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 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, 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'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/highway path, so every real caller passes encodeURIComponent(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

drawMs result
10× regression injected into the draw path 22.0ms fails (budget 12ms)
clean build 2.1ms ✅ passes

…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

    • Added a performance snapshot API for monitoring rendering cost, frame timing, scaling, and adaptive render thresholds.
  • Tests

    • Added browser coverage to verify active rendering remains within its performance budget.
    • Added checks to prevent false passes caused by idle playback or stale measurements.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f36a31e9-4cce-4c71-ad9c-39617325a658

📥 Commits

Reviewing files that changed from the base of the PR and between dbbb1bd and 1783d58.

📒 Files selected for processing (2)
  • static/highway.js
  • tests/browser/highway-perf-baseline.spec.ts

📝 Walkthrough

Walkthrough

The 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.

Changes

Highway performance measurement

Layer / File(s) Summary
Expose highway performance snapshot
static/highway.js
Adds getPerf() with smoothed draw and frame timing, render-scale state, effective scale, and adaptive budget thresholds.
Validate active render budget
tests/browser/highway-perf-baseline.spec.ts
Loads a real chart, starts playback, samples performance during clock advancement, and asserts valid draw metrics below the upper budget.

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
Loading

Possibly related PRs

  • got-feedBack/feedBack#849: Moves highway render-time and adaptive-scaling state into hwState, which is read by the new performance API.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly references the highway performance gate and render-loop measurement added in this PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch r3c/highway-baseline

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

tests/browser/highway-perf-baseline.spec.ts

Parsing error: Unexpected token as


Comment @coderabbitai help to get the list of available commands.

…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
static/highway.js (1)

3149-3159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

getPerf() largely duplicates the pre-existing getPerfStats().

Both return drawMs, autoScale, renderScale, and effectiveScale; getPerf() just adds frameMs and 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 having getPerfStats() delegate to getPerf() (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

📥 Commits

Reviewing files that changed from the base of the PR and between db3ca34 and dbbb1bd.

📒 Files selected for processing (3)
  • static/highway.js
  • static/tailwind.min.css
  • tests/browser/highway-perf-baseline.spec.ts

Comment thread static/highway.js
Comment on lines +3149 to +3159
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,
};
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@byrongamatos
byrongamatos merged commit 23ecddc into main Jul 12, 2026
4 checks passed
@byrongamatos
byrongamatos deleted the r3c/highway-baseline branch July 12, 2026 09:36
byrongamatos added a commit that referenced this pull request Jul 12, 2026
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>
byrongamatos added a commit that referenced this pull request Jul 12, 2026
…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>
byrongamatos added a commit that referenced this pull request Jul 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant