perf: skip building the candle-mode line overlay when it isn't drawn - #1
Merged
Conversation
The draw layer's "is the line overlay visible this frame" calculation and the engine's companion "should I build its points" check were separate inline expressions sharing a hardcoded 0.01 threshold — coupling that rots silently, since a change to one without the other makes the builder skip frames the drawer still wants and the overlay vanishes mid-transition with no crash and no failing test. Both now live in draw/lineOverlay.ts, kept free of Skia imports so jest can exercise them directly (same reasoning as draw/pathCache.ts). The extracted lineOverlayPresence is bit-identical to the previous inline expression across 1,002,001 sampled input pairs. lineOverlay.test.ts asserts the load-bearing invariant over a dense grid of both inputs: the builder must return true wherever the drawer would draw. Verified the test actually fails when the gate stops being broader.
In steady candle mode — the common case, held for as long as the chart is on screen — drawCandleFrame discards the line overlay's point array, because the overlay is only drawn during a line-mode or reveal transition. The engine built it anyway, every frame at 60fps: one object per visible candle, or one per visible tick plus a closeRefs array on the density-blend branch. Gated on shouldBuildLineOverlay (draw/lineOverlay.ts), which is deliberately broader than the drawer's own test, so it can only ever skip frames the drawer was going to ignore. lineSmoothValue stays unconditional in both branches — it's a scalar, and leaving it there keeps the value's derivation byte-for-byte unchanged. Measured no CPU delta on a Debug simulator build (before 65.4%, after 66.9%, after-recheck 64.6% — the after arms bracket the before arm). This removes provably dead work rather than making live work faster; the win is not visible at that measurement floor. Verified on the iOS sim in steady candle mode, line-mode-within-candles, and the Line tab.
PLAN_MAINT.md is a survey of all of src/ (47 files, ~15.5k lines), written because the recent perf batches optimized aggressively within existing structures without revisiting the structures themselves. Seven ranked items with verified line numbers, an execution order, and standing rules for whoever picks it up. Follows the PLAN.md / PLAN_PERF.md house style. Notable: item 3 is a real (small) leak found by the survey — EngineState has eight per-series Maps, four are pruned by hand-written copy-pasted loops, and seriesAlpha has the same lifecycle but was never added, so it grows unboundedly as series ids churn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN_PERF.md set the precedent of pointing the plan docs at each other so a resuming agent finds the right one. PLAN_MAINT.md is the third and was orphaned — PLAN.md's deferred section now names it, and PLAN_PERF.md points forward to it, including the note that its item 4 already blocked a perf change.
fuller
added a commit
that referenced
this pull request
Aug 1, 2026
PLAN_MAINT #6 — the prerequisite for the pipeline extraction (#1). Tests 218 -> 319 across the branch. Written to catch mutations, not for coverage percentage. Each significant test pins a specific one-line regression, e.g.: - hover at x=0: `hoverPixelX === null` weakened to `!hoverPixelX` would freeze the crosshair mid-drag at the left edge. - Three "deliberate non-condition" tests in quiescence: adding !cfg.showPulse, !cfg.showMomentum or !cfg.orderbookData. Pulse defaults true, so that one would silently disable quiescence for nearly every chart. - Log-space window transition: 10s->40s halfway is 20 (geometric mean), asserted `not` 25 — pins logLerp against a linear rewrite. - 60Hz vs 120Hz convergence, paired with a test proving an uncorrected lerp WOULD diverge, so removing the dt term is caught rather than masked. - 300-frame convergence asserting toBe(40) bit-exactly, paired with a proof that an unsnapped exponential never gets there — a residual epsilon invalidates the range and grid caches forever. - candleAtX: an exhaustive 1000-candle sweep over every bucket centre, which catches binary-search off-by-ones that spot checks miss. Also covers computeAdaptiveSpeed and computeCandleRange, which PLAN_MAINT omitted. One finding, source deliberately NOT changed: updateCandleRange is not exactly frame-rate independent. Its lerp is dt-corrected but the adaptive speed is recomputed from the current gap every frame, so 120Hz re-evaluates the schedule twice as often and lands slightly ahead (<1% of travel). engine/helpers.ts's updateRange avoids this by computing its speed once and passing it in. The test asserts the actual bounded drift rather than an aspirational exact match, and still catches removal of dt. Worth deciding later whether the candle pipeline should hoist its speed computation to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fuller
added a commit
that referenced
this pull request
Aug 3, 2026
The safety net for PLAN_MAINT #1 — extracting three inline pipelines from a ~1,900-line function that share one EngineState. Existing coverage was 17 regression tests pinning bugs fixed today; this pins the surface. Asserts EngineState field values, which draw functions ran, how many times, and in what ORDER (via jest invocationCallOrder merged across nine mocks). Deliberately never asserts argument tuples — a concurrent refactor is reshaping those signatures, and tuple assertions would break for reasons unrelated to correctness. Covers: golden 5-frame sequences per pipeline (~16-22 EngineState fields each, explicit toEqual rather than snapshot files); the full six-direction mode transition matrix plus a line->multi->line round trip; and the ordering invariants that are load-bearing but currently only documented in comments — the scroll-layer gate running after updateHoverState and before drawFrame, grid-before-draw in all three pipelines, and pausedDt at all three updateGridLayer sites. 12 mutations applied, run, reverted — 12 caught. One (flipping the line pipeline's window buffer) escaped the first draft because the buffer lands on no EngineState field; a group reading layout.rightEdge off the call closed it. Independently re-verified here: mutating one pausedDt to dt fails exactly the grid-dt test. THREE FINDINGS FOR THE REFACTOR, all pinned as-is on purpose so the extraction changes them deliberately rather than by accident: - `rangeInited` is ONE flag shared by all three pipelines, and it is a real stale-state leak. After line->candle, updateCandleRange sees it already true and LERPS the Y range away from the line's range instead of snapping to the candle's. Symmetric for candle->multi. If the extraction gives each pipeline its own range state, two tests here will fail — that failure is the signal. - Cleanup is asymmetric: the five per-series maps are pruned on leaving multi mode, but nothing prunes candle state or line state on exit. - drawMultiFrame gets raw dt while drawFrame/drawCandleFrame get pausedDt. An extraction that unifies the three call sites silently changes multi-series pause behaviour. Also documents what resisted characterization: the scroll-layer gate is unreachable under a mocked draw layer (lineCacheHits needs a prefix only the real drawFrame builds), so the mock had to write the cache slot — without that every scroll-layer test would have been vacuously green. A trap for anyone extending this file. Tests 395 -> 437. No source file touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
closeRefsarray on the density-blend branch) — whichdrawCandleFramethen discarded, because the overlay is only drawn during a line-mode or reveal transition. Now built only when it will be used.0.01. Both now live indraw/lineOverlay.ts, with a test asserting the invariant that keeps them safe.PLAN_MAINT.md— a maintainability survey ofsrc/for handoff (see below).Honest note on the perf claim
This measured no CPU delta. Three arms on the Candles tab, iPhone 17 Pro sim, Debug, 3×20s each:
The two
afterarms bracketbefore, and within-arm spread exceeds the between-arm difference. This PR removes provably dead work rather than making live work faster; the effect is below the measurement floor on a Debug simulator build under host load. It is not a speedup claim.A second change from the same session — pooled decimation scratch — was written, tested green, then deliberately dropped: it required a 17th positional parameter on
drawLine, and paying that for an unmeasurable win was the wrong trade. It's queued inPLAN_MAINT.mditem 4 to be redone oncedrawLinetakes an options object.Test Coverage
engineStephas no tests (1818 lines — the gap documented inPLAN_MAINT.mditem 6), so rather than ship untested branch logic, the gate was extracted as a pure predicate and tested directly.The load-bearing test asserts over a dense grid of both inputs that
shouldBuildLineOverlayis true wherever the drawer would draw — i.e. the builder can only skip frames the drawer would ignore. Mutation-checked: narrowing the gate to mirror the drawer's threshold exactly makes 3 tests fail with the offending input pair in the diff.Tests: 148 → 160 (+12 new), 8 → 9 suites.
Equivalence evidence
The extracted
lineOverlayPresenceis bit-identical to the previous inline expression across 1,002,001 sampled(lineModeProg, chartReveal)pairs — 0 differing, max delta 0.Pre-Landing Review
2 issues, both auto-fixed:
draw/index.ts:756,801,846— three literal0.01comparisons left on the magic number after extractingLINE_OVERLAY_MIN_PRESENCE, recreating the drift risk the extraction removes → now use the constant.CHANGELOG.md— entry described only the perf gate, not the extraction → added.Checked and clear: no skipped side effects across the new branches (
closeRefs/refIdxare fully scoped inside the guard);fullLineModestill read; the reveal-padding block is unreachable when the gate is false.Design Review
No frontend files changed — skipped.
Device verification
Because this adds a new module whose worklets are called from the frame worklet — a shape this repo has been bitten by before (a
SharedValueclosure that passed every test, then failed on frame 2) — every path was checked on the iOS simulator, not just in jest:The last check specifically proves
LINE_OVERLAY_MIN_PRESENCEresolves across the module boundary; if it didn't,lp > undefinedwould be false and the overlay would be absent entirely.PLAN_MAINT.md
Survey of all 47 files in
src/. Seven ranked items with verified line numbers. Highlights:engineStepis a ~1700-line function with three inline pipelines; 132 lines (48% of the smaller pipeline) are duplicated, largest identical run 53 lineslayoutliteral is constructed byte-identically 3×EngineStatehas eight per-series Maps; four are pruned by copy-pasted loops, andseriesAlphahas the same lifecycle but was never addedVersion
No bump. Nothing user-facing shipped; entries sit under
## [Unreleased]. This repo has noVERSIONfile — it versions viapackage.json(currently 0.2.0, released 2026-07-27).Test plan
yarn typecheck— exit 0yarn lint— exit 0yarn test— 160 tests, 9 suites, 0 failures🤖 Generated with Claude Code