Skip to content

Commit 47953b4

Browse files
committed
add some skills for agent usage
1 parent fb4838a commit 47953b4

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
name: bench-records
3+
description: Archive benchmark runs into benchmark/grapheme/_records and regenerate the HTML report. Use when adding or refreshing a perf record for Node.js, Bun, Chrome, Firefox, or Safari, or when asked to update the benchmark report.
4+
---
5+
6+
# Benchmark records
7+
8+
`benchmark/grapheme/_records/` archives verbatim mitata outputs; `report.mjs` parses them into `report.generated.html` (committed alongside the records).
9+
10+
## File naming
11+
12+
`YYYYMMDD-{cpu}-{os}-{runtime}_{version}.txt`, all lowercase snake_case, e.g.
13+
`20260707-apple_m4_pro-macos_26.5-nodejs_26.4.0.txt`.
14+
One file per (date, platform, runtime); a same-day re-run replaces the file.
15+
16+
## Capturing
17+
18+
A record must be the verbatim, complete mitata output: the `clk:`/`cpu:`/`runtime:` header, every benchmark group, and every `summary` block. Never trim, reformat, or reconstruct — the report parser and humans both read it raw, and the case names contain invisible Unicode that is easy to corrupt.
19+
20+
- **Node**: `yarn perf:grapheme`, capture stdout (non-TTY output is already plain text).
21+
- **Bun**: `bun --bun run perf:grapheme` (`--bun` is required, otherwise Bun shells out to Node).
22+
- **Chrome (automatable)**: start `yarn perf:grapheme:browser` in the background (vite dev server). With the chrome-devtools MCP: navigate to the served URL, click the "Execute benchmark" button, wait until the button is re-enabled (the worker posts `done`), then read the `#output` element's `innerText` via `evaluate_script`. The MCP can only write files inside the workspace — save to a repo-relative temp path first, then move into `_records/`.
23+
- **Safari / Firefox (manual)**: the user runs the same vite page themselves and pastes the output. Write the pasted text to the record file exactly as given; when editing an existing record, watch for accidentally duplicating a summary block (it happened once).
24+
25+
## After writing a record
26+
27+
1. Sanity-parse it: expect 6 benchmark groups, and every competitor present in each summary block.
28+
2. Regenerate the report: `node benchmark/grapheme/_records/report.mjs` — prints record/platform counts and rewrites `report.generated.html`.
29+
3. Tell the user the per-competitor speedup ranges from the new record (e.g. "Intl.Segmenter 1.9–2.5x").
30+
4. The user commits records themselves — never run `git commit`.
31+
32+
## Measurement-quality flags
33+
34+
- **Safari coarse-timer clamping**: `0.00 ps` minimums or `1.00 ms` maximums mean the reduced-precision timer quantized the samples; affected rows (often the short ASCII case) can read ~2x off. Archive as provided, but flag it — a re-run with the tab focused usually fixes it.
35+
- Browser runs are sensitive to background-tab throttling; ask for a focused, foreground run when numbers look inconsistent with the platform's other rows.
36+
- README performance claims are qualitative ranges; a single noisy record rarely requires editing README.md.

.claude/skills/benchmark/SKILL.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
name: benchmark
3+
description: Measure runtime perf, bundle size, and memory impact of unicode-segmenter changes. Use when evaluating any src/ change, running yarn perf / bundle-stats / memory-stats, comparing against a baseline revision, or when benchmark numbers look noisy or contradictory.
4+
---
5+
6+
# Benchmarking unicode-segmenter
7+
8+
This library's priorities, in order: bundle size, runtime perf, memory footprint. Measure all three before claiming a change is a win — they regularly trade against each other.
9+
10+
## Commands
11+
12+
| Axis | Command | Notes |
13+
|---|---|---|
14+
| Runtime perf (Node) | `yarn perf:grapheme` (also `:general`, `:emoji`) | mitata; script passes `--expose-gc` |
15+
| Runtime perf (Bun) | `bun --bun run perf:grapheme` | `--bun` forces the Bun runtime instead of Node |
16+
| Runtime perf (browser) | `yarn perf:grapheme:browser` | vite page; capture procedure in the `bench-records` skill |
17+
| Runtime perf (Hermes / QuickJS) | `yarn perf:grapheme:hermes`, `yarn perf:grapheme:quickjs` | Metro-based; see Metro warning |
18+
| Bundle size | `yarn bundle-stats:grapheme` | esbuild; minified + gzip + brotli per library |
19+
| Hermes bytecode | `yarn bundle-stats:grapheme:hermes` | React Native proxy metric |
20+
| Memory | `yarn memory-stats:grapheme` | retained heap per library; forked child per lib, median of 5 samples × 10 uses |
21+
22+
Benchmarks import `src/*.js` directly — no build step needed, current checkout state is what gets measured.
23+
24+
## Interpreting results
25+
26+
- **Compare ratios within a run, not absolute ns across runs.** Apple Silicon clock scaling (3.7–4.5 GHz observed on M4 Pro) swings absolute numbers ±20% between runs. Each perf run includes the competitors, so the `summary` "x faster than" lines are the stable signal.
27+
- Take best-of-3 when absolute numbers matter; close heavy apps first.
28+
- Do not add mitata's `.gc('inner')` when comparing — it attributes per-iteration GC cost to the benchmark and distorts small cases.
29+
- For bundle size, gzip/brotli are what users pay. Raw or minified chars can move opposite to compressed size (the base36-VLQ data encoding beats base64 after gzip despite more raw characters).
30+
- Per-case reporting: the six shared cases (ASCII, emoji/ZWJ, Hindi/InCB, Zalgo, tweet, code) rarely move in one direction. Report each; flag any case regressing >10%.
31+
32+
## A/B against a baseline revision
33+
34+
Never move test inputs by copy-paste: they contain invisible codepoints (ZWJ, variation selectors) and were mangled exactly that way once. Always import `benchmark/grapheme/_testcases.js`.
35+
36+
1. Materialize the baseline's module closure with read-only git into the scratchpad:
37+
`git show origin/main:src/grapheme.js > <scratch>/baseline/grapheme.js` — repeat for `core.js`, `_grapheme_data.js`, and whatever else that revision imports.
38+
2. Write one scratch mitata script importing BOTH implementations plus `_testcases.js`, registering old/new for each case in the same process.
39+
3. Run at least twice with declaration order swapped to detect order bias.
40+
41+
## Size-sensitive coding notes (learned the hard way)
42+
43+
- esbuild inlines `const`-bound numeric literals into every use site but preserves `let` bindings. Shared numeric bounds in `src/grapheme.js` (`BMP_MAX`, `T1_MIN`, `T2_MIN`) are deliberately `let` — keep them that way.
44+
- Generator syntax is the deliberate choice over a class-based iterator: a class was 13–34% faster on V8 but cost ~+1.6 KiB Hermes bytecode after Babel class lowering (Hermes can't parse class syntax natively). Don't convert to a class without re-measuring V8, JSC, Hermes, and all three size metrics.
45+
- `codePointAt()` beat manual `charCodeAt` surrogate pairing on every measured engine (17–20% faster on Hermes) and is smaller. Don't "optimize" it back.
46+
47+
## Metro warning (Hermes scripts)
48+
49+
Metro spawns a worker farm per invocation. When calling Metro programmatically (custom measurement pipelines, loops), always pass `maxWorkers: 1` in the `Metro.loadConfig` overrides — a runaway farm once spawned enough node processes to freeze the machine. One-off runs of the repo's own scripts are fine.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
name: codspeed-debug
3+
description: Investigate CodSpeed CI performance reports on PRs — regressions or improbable improvements that don't reproduce locally. Use when the CodSpeed check flags a delta and you need to replicate or explain it.
4+
---
5+
6+
# Debugging CodSpeed results
7+
8+
CI (`.github/workflows/codspeed.yml`) runs `node benchmark/grapheme/codspeed.js` (tinybench + `@codspeed/tinybench-plugin`) under `CodSpeedHQ/action` in **simulation mode**: a valgrind-style instrumented VM counting simulated CPU work, not wall clock. Its numbers routinely disagree with mitata.
9+
10+
## How the instrumented measurement differs from mitata
11+
12+
- V8 runs with stability flags (e.g. `--predictable --hash-seed=1`) and the plugin measures roughly: a few warmup calls → forced `gc()`**one** measured call.
13+
- Consequence 1: the measured call sees whatever tier the code reached during warmup. The repo defends with a 2000-iteration `beforeAll` warmup in `codspeed.js` — do not remove it.
14+
- Consequence 2 (the big trap): **forced GC clears weak references embedded in optimized code** (deopt reason: `embedded weak objects cleared` — e.g. hidden-class maps of objects with no live instances), so optimized code deopts right before the measured call, producing phantom regressions up to ~60%.
15+
- Defense in `src/grapheme.js`: the pin block stashing a live generator and its first result on `PAIR._keep`, which keeps the segment-object hidden classes strongly reachable across GC. Do not remove it.
16+
- A module-scope `const _keep = ...` does NOT work — unreferenced module bindings don't survive module evaluation; the pin must hang off an object that stays reachable (`PAIR`).
17+
18+
## Local replication recipe
19+
20+
Write a scratch script that mimics the protocol per case: N warmup calls, `globalThis.gc()`, then time exactly one call. Run with:
21+
22+
```
23+
node --expose-gc --predictable --hash-seed=1 scratch.mjs
24+
```
25+
26+
Diagnose tiering with `--trace-deopt`, `--trace-opt`, and `--trace-baseline`; a deopt logged between the `gc()` and the measured call is the smoking gun.
27+
28+
## Other V8 behaviors that have bitten this repo
29+
30+
- Sparkplug (baseline) compiles in batches with a ~4 KiB source-length threshold: one huge function can be compiled alone or later than the same logic split into smaller functions, so pure source-size changes can shift tier-up timing and show up as CodSpeed deltas unrelated to algorithmic cost.
31+
- Deltas under ~5% in simulation mode are noise. Before reacting to anything, open the flame diff on codspeed.io and check whether the changed frames are in the segmenter or in the harness/GC.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
name: unicode-data
3+
description: Regenerate the generated Unicode data modules (src/_grapheme_data.js, _general_data.js, _emoji_data.js, test/_unicode_testdata.js) and verify correctness. Use when bumping the Unicode version, editing scripts/unicode.js or scripts/lib/encoding.js, or changing grapheme break rules or the pair table.
4+
---
5+
6+
# Unicode data pipeline
7+
8+
`node scripts/unicode.js` downloads UCD files for the pinned `UNICODE_VERSION` (a const near the top of the script) into `scripts/unicode_data/` (cached — delete a file to re-fetch) and regenerates:
9+
10+
- `src/_grapheme_data.js``grapheme_data` (encoded ranges), `grapheme_cats` (one base36 digit per range), `grapheme_pairs` (256-digit 16×16 break-decision table), `GraphemeCategory` enum
11+
- `src/_general_data.js`, `src/_emoji_data.js` — flat membership tables
12+
- `test/_unicode_testdata.js` — official GraphemeBreakTest cases consumed by `yarn test`
13+
14+
Never hand-edit generated files; fix the generator and re-run.
15+
16+
## Encoding invariants (breaking any of these corrupts data silently)
17+
18+
- Ranges are delta + base36-VLQ encoded: 4 payload bits + 1 continuation bit per character, alphabet `[0-9a-v]`, decoded by native `parseInt(ch, 36)` in `src/core.js` (`decodeUnicodeData`). Encoder lives in `scripts/lib/encoding.js`. base36 was chosen over base64 deliberately: no codec needed in the bundle, and it compresses better under gzip/brotli.
19+
- Ranges must be sorted and non-overlapping (delta gaps are non-negative by construction).
20+
- Flat tables pack `end << 5 | category` into Uint32 (`findUnicodeRangeCategory` unpacks with `>>> 5` / `& 31`). Category max is 31; the largest real value is 23 (word break). A future category beyond 31 requires a format change in both `core.js` and the generator.
21+
- Grapheme category 15 (`InCB_Consonant`) is internal-only — it exists so InCB=Consonant data folds into the main grapheme table instead of a separate module. It must never leak through the public API: `graphemeSegments` masks it to 0 in `_catBegin`/`_catEnd`.
22+
23+
## Pair table semantics
24+
25+
`grapheme_pairs[prevCat * 16 + curCat]` values: `0` = break, `1` = no break, `2` = GB12/13 (Regional Indicator parity), `3` = GB11 (ExtPic + ZWJ sequence), `4` = GB9c (InCB linker). The stateful cases (2–4) are resolved by the packed sequence state in `src/grapheme.js` (`nextState` + the gate mask). Rule changes belong in `buildGraphemePairTable()` in `scripts/unicode.js`, not in hand edits to the emitted string.
26+
27+
## Inlined fast paths
28+
29+
`src/grapheme.js` hardcodes hot regions (Latin, CJK, Hangul LV/LVT, PUA, variation selectors, E0000 tags, …) as computed checks plus three Uint8Array windows (T0: 0x0000–0x2FFF, T1: 0xA000–0xABFF, T2: 0x1F000–0x1FAFF); only the remainder reaches the binary-search tail. `scripts/unicode.js` asserts every inlined assumption against the raw UCD tables at codegen time, plus a tail-size cap (≤320 ranges). **If regeneration throws an assertion, a new Unicode version changed a region that grapheme.js inlines — update the inline logic in `src/grapheme.js` to match reality; never weaken the assertion.**
30+
31+
## Verification checklist after any regen or rule change
32+
33+
1. `yarn test` — includes the official UCD GraphemeBreakTest suite, fast-check property tests against `Intl.Segmenter` (100k runs each), and the real-world corpus suite (`test/corpora.js` over committed `test/_corpora/` fixtures — natural texts caught bugs both of the others missed; see issue #124).
34+
2. For rule changes, deep-fuzz in the scratchpad: fast-check comparing `graphemeSegments` against `Intl.Segmenter` at ≥1M runs over full-Unicode strings. Divergences are not automatically bugs — the host ICU may lag or lead the pinned `UNICODE_VERSION`; adjudicate against the UAX #29 rules for the pinned version before "fixing" anything.
35+
3. `yarn bundle-stats:grapheme` — data changes move bundle size; report the delta.
36+
4. Add a changeset (`yarn changeset`) for anything user-visible; Unicode version bumps are user-visible.
37+
38+
On a Unicode version bump, also update `UNICODE_VERSION_STRING` in `scripts/corpora.js` (it pins the emoji-test.txt URL) and re-run `node scripts/corpora.js` to refresh the emoji corpus fixture. Downloads cache under `scripts/corpora_data/` (gitignored); the fixtures in `test/_corpora/` are committed.

0 commit comments

Comments
 (0)