Skip to content

feat(shape): z-normalized shape search — find any pattern across the history - #89

Merged
benyblack merged 6 commits into
mainfrom
feat/shape-search
Oct 1, 2026
Merged

benyblack merged 6 commits into
mainfrom
feat/shape-search

Conversation

@benyblack

@benyblack benyblack commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Roadmap v3's flagship feature, PR #2 of the recommended five: brush any region and find every occurrence of that shape across the whole loaded history — matrix-profile-class similarity search shipped inside a web chart.

The math — shapeSearch (exported from wickchart/core)

MASS-style, one O(n log n) FFT round-trip (hand-rolled iterative radix-2): sliding dot products against the z-normalized query + prefix sums for window stats → distances fall out as 2m(1−ρ) where ρ is the Pearson correlation of the z-normalized shapes (reported as score: 1 identical, −1 inverted, 0.8 strong). Both query and candidates are mean-removed and unit-variance, so matches are about form — the dip you're looking at matches at any price or amplitude. Greedy exclusion collapses overlapping matches; query length clamps 8–750; flat queries/windows are principled no-matches.

Measured: a 100k-bar history searches in ~30 ms — interactive at any size, so the roadmap's planned worker offload wasn't needed (ROADMAP row struck with the note). The unit suite pins this with a perf sanity.

The surface

findShape({from, to, maxMatches, minScore}) (no args → the brush selection) · clearShape() · shapeResult getter · wick:shape event · translucent query/match bands behind the series · setData() clears (indices are data-bound).

Verification — the flagship lands on tested ground

  • Differential unit tests vs an independent brute-force O(n·m) reference (different algebra entirely): starts, scores, distances.
  • Golden vectors vs a numpy brute-force reference — starts exact, scores within tolerance; the coverage guard now counts 22/22 math kernels.
  • e2e: paint/clear specs against a real canvas.
  • npm test — 704/704 · e2e — 80 passed / 4 skipped.
  • Budget: main entry 68→71 KB gz (+3.1: ~1.9 math + ~1.2 surface), raised in its own commit with the why, per the house rule.

A story worth the PR body

Mid-development the golden gate caught a SuperTrend regression — a botched edit of the flip-gap line during this feature's development — before it could ship. The correctness harness built in PR #88 paid for itself two PRs later.

Docs: README section + method/event rows, docs.html section with a live playground (TOC updated), demo toolbar Find shape toggle, ROADMAP row struck.

…history

Roadmap v3's flagship: brush any region (or pass times, or press Find
shape on the visible window) and findShape() ranks every occurrence of
that shape across the whole loaded history.

The math (shapeSearch in core.js, exported) is MASS-style: one O(n log n)
FFT round-trip computes all sliding dot products against the
z-normalized query, prefix sums give candidate-window stats, and
distances fall out as 2m(1-rho) where rho is the Pearson correlation of
the z-normalized shapes (reported as 'score' — 1 identical, -1
inverted). Windows overlapping the query or an accepted match are
excluded greedily, so near-duplicate neighbours collapse. Query length
clamps to 8..750; flat queries and flat windows are principled no-matches.

Measured: 100k-bar history searched in ~30 ms — interactive at any size,
so the roadmap's worker offload was not needed (noted in ROADMAP.md).
The hand-rolled iterative radix-2 FFT is the only new machinery.

Chart surface: findShape()/clearShape()/shapeResult, the wick:shape
event with the full payload, query/match translucent bands behind the
series, and setData() clearing (indices are data-bound).

Covered three ways: differential unit tests against an independent
brute-force O(n·m) reference, golden vectors vs a numpy brute-force
reference (follows in its own commit), and e2e paint/clear specs. The
golden gate caught a SuperTrend regression mid-development — a botched
edit of the flip-gap line — before it could ship; the method works.
shapeSearch's FFT + z-norm math is ~1.9 KB in core; the chart surface
(findShape/clearShape/shapeResult, wick:shape payload, band rendering)
adds ~1.2 KB. Landed at 70.3 KB.
…ference

A numpy O(n·m) reference (direct per-window z-normalization and dot
products — no FFT anywhere) pins the FFT path's match list: starts
exact, scores/distances within tolerance. The coverage guard now knows
shapeSearch is a math kernel (not calc*-named), 22/22 covered.
…ap strikes the row

docs.html gains a live findShape(visible range) playground; the demo
toolbar gets a Find shape toggle that searches the visible window's
shape and toasts the match count/duration/best score; README documents
the method, event and section; ROADMAP strikes the shipped row with the
sync-not-worker note.
CI's wider Linux fallback fonts laid the labelled button out past 412px
(426px, zoomed-out page). Moving it into cluster-view restores the
Market cluster's exact pre-feature width — strictly narrower than the
layout that already passed on any font — and the button gains the
established icon-only + screen-reader-label + aria-pressed toggle
pattern.
@benyblack

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-01T14:45:08.711034Z 8e19a49 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e19a49853

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/wick-chart.js
}
// selection indices are data-bound; a replacement invalidates them
if (this._brushSel || this._brushDrag) this.clearBrush();
if (this._shape) this._shape = null; // indices are data-bound too

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate shape state on every data reset

When setData([]) is used, the early branch calls clearData() before reaching this new reset, so the old index-based _shape remains stored. If streaming subsequently repopulates the chart through update(), shapeResult can report unrelated bars or throw when a stored match index exceeds the new dataset; historical inserts and _maybeLoadMore() prepends similarly shift these indices without rebasing them. Clear or rebase shape state on every data path that empties or shifts the primary series.

Useful? React with 👍 / 👎.

Comment thread src/wick-chart.js
const matches = shapeSearch(
d.map((b) => b.close),
i0,
i1 - i0 + 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the reported query aligned with the 750-bar clamp

For a requested range longer than 750 bars, shapeSearch() silently searches only the first 750 bars, but this wrapper retains the original i1 in the result and query band. Consequently the payload misidentifies the searched shape, and a match starting at bar 750 can even be returned despite overlapping the reported query range. Clamp or reject the chart-level range before both searching and storing it.

Useful? React with 👍 / 👎.

Comment thread src/core.js
Comment on lines +1065 to +1066
const mean = (csum[i + m] - csum[i]) / m;
const sig = Math.sqrt(Math.max(0, (csq[i + m] - csq[i]) / m - mean * mean));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stabilize variance against large price offsets

When prices have a large baseline relative to their movement, computing variance as E[x²] - E[x]² suffers catastrophic cancellation. For example, adding 1e9 to an otherwise identical repeated pattern can make this search return no matches, even though z-normalized similarity must be invariant to that offset. Use a centered or otherwise numerically stable rolling variance calculation so high-valued instruments are not treated as flat or assigned corrupted scores.

Useful? React with 👍 / 👎.

…rting, offset-stable variance

All three Codex findings, all legitimate:

1. setData([]) early-returns through clearData(), so the shape reset
   added to setData never ran on that path — stale index-bound state
   survived an empty-then-repopulated chart. clearData() now clears it.
   Backfill prepends (loadMore) shift every index by 'added'; the shape
   result rebases instead of dropping, so bands survive loading more
   history in front of them.

2. A query range longer than the 750-bar clamp searched only the first
   750 bars while reporting the full range — the payload misidentified
   the shape, and a returned match could overlap the *reported* query.
   findShape now clamps i1 to i0 + SHAPE_MAX_WINDOW - 1 (the cap is
   exported from core) before searching, storing, or reporting.

3. Window variance came from raw moments (E[x²] − E[x]²), which cancel
   catastrophically when the price baseline dwarfs the movement — a
   repeated pattern on a 1e9-baseline series returned zero matches.
   The series is now shifted by its first value once, so every
   statistic runs at movement scale; z-normalized similarity was
   already offset-invariant in exact arithmetic, and now is in float64.

Each carries a regression test (offset-invariance at 1e9, clamp
reporting + overlap, clearData/rebase source contracts).
@benyblack

Copy link
Copy Markdown
Owner Author

All three findings addressed in 1b22dc3 — each was legitimate, thank you:

1. Data-reset invalidation — setData([]) early-returns through clearData(), so the reset added to setData never ran on that path; clearData() now clears the shape state. Backfill prepends (loadMore) shift every index by added — the shape result rebases rather than drops, so bands survive loading more history in front of them.

2. Honest clamp reporting — the cap is now exported (SHAPE_MAX_WINDOW) and findShape clamps i1 = i0 + 749 before searching, storing, or reporting, so the query band, the payload, and the exclusion zone all describe exactly what was searched. A regression test asserts no returned match overlaps the reported query.

3. Offset-stable variance — the series is shifted by its first value once, so the raw-moment window statistics run at movement scale instead of price scale. The 1e9-baseline scenario from the review is now a regression test: the repeated pattern finds all its matches at score ≈ 1 (it previously returned none).

npm test — 706/706 · e2e — 80/4-skipped · types build clean · main entry 70.79 KB gz (within the 71 ceiling).

@benyblack
benyblack merged commit 092b07c into main Oct 1, 2026
2 checks passed
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