Skip to content

perf: skip building the candle-mode line overlay when it isn't drawn - #1

Merged
fuller merged 4 commits into
mainfrom
perf/candle-line-overlay-gate
Jul 28, 2026
Merged

perf: skip building the candle-mode line overlay when it isn't drawn#1
fuller merged 4 commits into
mainfrom
perf/candle-line-overlay-gate

Conversation

@fuller

@fuller fuller commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Skip dead work in candle mode. In steady candle mode the engine built the line overlay's point array every frame (one object per visible candle, or per visible tick plus a closeRefs array on the density-blend branch) — which drawCandleFrame then discarded, because the overlay is only drawn during a line-mode or reveal transition. Now built only when it will be used.
  • Consolidate the presence gate. The drawer's "is the overlay visible" calculation and the engine's "should I build its points" check were separate inline expressions sharing a hardcoded 0.01. Both now live in draw/lineOverlay.ts, with a test asserting the invariant that keeps them safe.
  • PLAN_MAINT.md — a maintainability survey of src/ 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:

arm runs mean
before 65.8 / 65.7 / 64.6 65.4%
after 64.2 / 67.6 / 68.9 66.9%
after (drift control) 63.2 / 65.3 / 65.2 64.6%

The two after arms bracket before, 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 in PLAN_MAINT.md item 4 to be redone once drawLine takes an options object.

Test Coverage

engineStep has no tests (1818 lines — the gap documented in PLAN_MAINT.md item 6), so rather than ship untested branch logic, the gate was extracted as a pure predicate and tested directly.

NEW CODE PATHS
==============
[+] src/draw/lineOverlay.ts
    ├── lineOverlayPresence()      [***] 7 tests — thresholds, cubed vs linear reveal,
    │                                    monotonicity, range bounds
    └── shouldBuildLineOverlay()   [***] 5 tests — incl. the invariant grid

[+] src/engine/step.ts
    └── wantLineVisible gate       [***] covered via shouldBuildLineOverlay + on-device

The load-bearing test asserts over a dense grid of both inputs that shouldBuildLineOverlay is 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 lineOverlayPresence is 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 literal 0.01 comparisons left on the magic number after extracting LINE_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/refIdx are fully scoped inside the guard); fullLineMode still 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 SharedValue closure that passed every test, then failed on frame 2) — every path was checked on the iOS simulator, not just in jest:

  • Steady candle mode (gate false) — renders correctly, well past frame 2
  • Line-mode-within-candles (gate true) — line, fill, badge, dashed reference line, live dot all present
  • Line tab — unaffected

The last check specifically proves LINE_OVERLAY_MIN_PRESENCE resolves across the module boundary; if it didn't, lp > undefined would 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:

  • engineStep is a ~1700-line function with three inline pipelines; 132 lines (48% of the smaller pipeline) are duplicated, largest identical run 53 lines
  • The layout literal is constructed byte-identically 3×
  • A real leak: EngineState has eight per-series Maps; four are pruned by copy-pasted loops, and seriesAlpha has the same lifecycle but was never added
  • 11 functions take ≥9 positional parameters (worst: 19)
  • Coverage is inverted — the three largest files have zero tests

Version

No bump. Nothing user-facing shipped; entries sit under ## [Unreleased]. This repo has no VERSION file — it versions via package.json (currently 0.2.0, released 2026-07-27).

Test plan

  • yarn typecheck — exit 0
  • yarn lint — exit 0
  • yarn test — 160 tests, 9 suites, 0 failures
  • Each commit independently valid (bisectable — verified commit 1 has no forward reference)
  • iOS simulator: steady candle, line-within-candle, Line tab

🤖 Generated with Claude Code

fuller and others added 4 commits July 28, 2026 18:55
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
fuller merged commit 9d1e497 into main Jul 28, 2026
5 checks passed
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>
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