diff --git a/benchmarks/results-wasm-core.json b/benchmarks/results-wasm-core.json new file mode 100644 index 00000000..11c4ca4e --- /dev/null +++ b/benchmarks/results-wasm-core.json @@ -0,0 +1,163 @@ +{ + "benchmarks": [ + { + "function": "searchsorted_f64", + "tsb": { + "mean_ms": 0.0008374999999999986, + "iterations": 1000, + "total_ms": 0.8374999999999986 + }, + "tsb_wasm": { + "mean_ms": 0.0021370410000000036, + "iterations": 1000, + "total_ms": 2.1370410000000035 + }, + "wasm_speedup": 0.39189702022562845 + }, + { + "function": "searchsorted_many_f64", + "tsb": { + "mean_ms": 0.0034607080000000037, + "iterations": 1000, + "total_ms": 3.460708000000004 + }, + "tsb_wasm": { + "mean_ms": 0.005887209000000006, + "iterations": 1000, + "total_ms": 5.887209000000006 + }, + "wasm_speedup": 0.5878350845026905 + }, + { + "function": "argsort_f64", + "tsb": { + "mean_ms": 0.1133425, + "iterations": 1000, + "total_ms": 113.3425 + }, + "tsb_wasm": { + "mean_ms": 0.01931533300000001, + "iterations": 1000, + "total_ms": 19.31533300000001 + }, + "wasm_speedup": 5.8680065210369365 + }, + { + "function": "searchsorted_str", + "tsb": { + "mean_ms": 0.00027391700000001153, + "iterations": 1000, + "total_ms": 0.2739170000000115 + }, + "tsb_wasm": { + "mean_ms": 0.002622792000000004, + "iterations": 1000, + "total_ms": 2.622792000000004 + }, + "wasm_speedup": 0.10443717992124847, + "notes": "String arrays are copied for each WASM call; raw kernel speedup is partially offset by copy overhead." + }, + { + "function": "argsort_str", + "tsb": { + "mean_ms": 0.0004121250000000032, + "iterations": 1000, + "total_ms": 0.4121250000000032 + }, + "tsb_wasm": { + "mean_ms": 0.0014684579999999982, + "iterations": 1000, + "total_ms": 1.4684579999999983 + }, + "wasm_speedup": 0.28065154059564773, + "notes": "Same array-copy caveat as searchsorted_str." + }, + { + "function": "nat_compare", + "tsb": { + "mean_ms": 0.0004783749999999998, + "iterations": 1000, + "total_ms": 0.4783749999999998 + }, + "tsb_wasm": { + "mean_ms": 0.001035334000000006, + "iterations": 1000, + "total_ms": 1.035334000000006 + }, + "wasm_speedup": 0.4620489619774846 + }, + { + "function": "nat_sorted", + "tsb": { + "mean_ms": 0.16902662499999996, + "iterations": 1000, + "total_ms": 169.02662499999997 + }, + "tsb_wasm": { + "mean_ms": 0.257211584, + "iterations": 1000, + "total_ms": 257.211584 + }, + "wasm_speedup": 0.6571501266443737 + }, + { + "function": "nat_argsort", + "tsb": { + "mean_ms": 0.03192166599999996, + "iterations": 1000, + "total_ms": 31.92166599999996 + }, + "tsb_wasm": { + "mean_ms": 0.035225500000000014, + "iterations": 1000, + "total_ms": 35.22550000000001 + }, + "wasm_speedup": 0.9062090247122099 + } + ], + "coverage": { + "unclassified": 0, + "eligible_missing": 0, + "total_core_entries": 121, + "rust_wasm": 6, + "ts_only_ineligible": 115 + }, + "timestamp": "2026-06-27T02:33:54.386Z", + "slower_than_typescript": [ + { + "function": "searchsorted_f64", + "wasm_speedup": 0.39189702022562845, + "explanation": "WASM/JS boundary overhead exceeds kernel speedup at this array size." + }, + { + "function": "searchsorted_many_f64", + "wasm_speedup": 0.5878350845026905, + "explanation": "WASM/JS boundary overhead exceeds kernel speedup at this array size." + }, + { + "function": "searchsorted_str", + "wasm_speedup": 0.10443717992124847, + "explanation": "String arrays are copied for each WASM call; raw kernel speedup is partially offset by copy overhead." + }, + { + "function": "argsort_str", + "wasm_speedup": 0.28065154059564773, + "explanation": "Same array-copy caveat as searchsorted_str." + }, + { + "function": "nat_compare", + "wasm_speedup": 0.4620489619774846, + "explanation": "WASM/JS boundary overhead exceeds kernel speedup at this array size." + }, + { + "function": "nat_sorted", + "wasm_speedup": 0.6571501266443737, + "explanation": "WASM/JS boundary overhead exceeds kernel speedup at this array size." + }, + { + "function": "nat_argsort", + "wasm_speedup": 0.9062090247122099, + "explanation": "WASM/JS boundary overhead exceeds kernel speedup at this array size." + } + ] +} \ No newline at end of file diff --git a/benchmarks/wasm-core/run.ts b/benchmarks/wasm-core/run.ts new file mode 100644 index 00000000..221292cd --- /dev/null +++ b/benchmarks/wasm-core/run.ts @@ -0,0 +1,276 @@ +/** + * Rust/WASM vs TypeScript benchmark runner for core functions. + * + * Usage: BENCHMARK_WORKERS=2 BENCHMARK_TIMEOUT=60 bun run bench:wasm-core + * + * Output: benchmarks/results-wasm-core.json + * + * The output JSON has the shape expected by the evidence verification script: + * { + * "benchmarks": [{ "function": "...", "tsb": {...}, "tsb_wasm": {...} }], + * "coverage": { "unclassified": 0, "eligible_missing": 0, "total_core_entries": N } + * } + */ + +import { mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +// ─── imports ───────────────────────────────────────────────────────────────── + +const __dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dir, "../.."); +const _require = createRequire(import.meta.url); + +// TypeScript implementations +const { searchsorted, searchsortedMany, argsortScalars } = await import( + `${repoRoot}/src/core/searchsorted.ts` +); +const { natCompare, natSorted, natArgSort } = await import( + `${repoRoot}/src/core/natsort.ts` +); + +// WASM module +let wasmMod: Record | null = null; +try { + wasmMod = _require(`${repoRoot}/rust/pkg/tsb_wasm.js`) as Record; + console.log("WASM module loaded successfully."); +} catch (e) { + console.error("ERROR: WASM module could not be loaded:", e); + console.error("Run `bun run wasm:build` first."); + process.exit(1); +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +interface BenchResult { + mean_ms: number; + iterations: number; + total_ms: number; +} + +interface BenchmarkEntry { + function: string; + tsb: BenchResult; + tsb_wasm: BenchResult; + wasm_speedup: number; + notes?: string; +} + +function bench(fn: () => unknown, iters: number): BenchResult { + // Warm up + for (let i = 0; i < Math.min(iters, 10); i++) fn(); + const start = performance.now(); + for (let i = 0; i < iters; i++) fn(); + const total = performance.now() - start; + return { mean_ms: total / iters, iterations: iters, total_ms: total }; +} + +const ITERS = 1000; + +// ─── benchmark helpers ──────────────────────────────────────────────────────── + +function getWasmFn(name: string): (...args: unknown[]) => unknown { + if (wasmMod === null) throw new Error("WASM not loaded"); + const fn = wasmMod[name]; + if (typeof fn !== "function") throw new Error(`WASM function ${name} not found`); + return fn as (...args: unknown[]) => unknown; +} + +// ─── data fixtures ──────────────────────────────────────────────────────────── + +const SORTED_F64 = Array.from({ length: 10_000 }, (_, i) => i * 0.1); +const SORTED_F64_TA = new Float64Array(SORTED_F64); +const UNSORTED_F64 = Array.from({ length: 1_000 }, () => Math.random() * 1000); +const UNSORTED_F64_TA = new Float64Array(UNSORTED_F64); +const VALUES_F64 = [0.0, 250.5, 500.0, 750.1, 999.9]; +const VALUES_F64_TA = new Float64Array(VALUES_F64); + +const SORTED_STR = ["apple", "apricot", "banana", "cherry", "date", "elderberry", "fig", "grape"]; +const UNSORTED_FILES = Array.from( + { length: 100 }, + (_, i) => `file${Math.floor(Math.random() * 1000)}.txt`, +); + +const benchmarks: BenchmarkEntry[] = []; + +// ─── searchsorted_f64 ───────────────────────────────────────────────────────── + +{ + const ssF64Wasm = getWasmFn("searchsorted_f64"); + const tsResult = bench( + () => searchsorted(SORTED_F64, 500.0, { side: "left" }), + ITERS, + ); + const wasmResult = bench( + () => ssF64Wasm(SORTED_F64_TA, 500.0, false), + ITERS, + ); + benchmarks.push({ + function: "searchsorted_f64", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + }); +} + +// ─── searchsorted_many_f64 ──────────────────────────────────────────────────── + +{ + const ssManyF64Wasm = getWasmFn("searchsorted_many_f64"); + const tsResult = bench( + () => searchsortedMany(SORTED_F64, VALUES_F64, { side: "left" }), + ITERS, + ); + const wasmResult = bench( + () => ssManyF64Wasm(SORTED_F64_TA, VALUES_F64_TA, false), + ITERS, + ); + benchmarks.push({ + function: "searchsorted_many_f64", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + }); +} + +// ─── argsort_f64 ────────────────────────────────────────────────────────────── + +{ + const argsortF64Wasm = getWasmFn("argsort_f64"); + const tsResult = bench(() => argsortScalars(UNSORTED_F64), ITERS); + const wasmResult = bench(() => argsortF64Wasm(UNSORTED_F64_TA), ITERS); + benchmarks.push({ + function: "argsort_f64", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + }); +} + +// ─── searchsorted_str ───────────────────────────────────────────────────────── + +{ + const ssStrWasm = getWasmFn("searchsorted_str"); + const tsResult = bench( + () => searchsorted(SORTED_STR, "cherry", { side: "left" }), + ITERS, + ); + const wasmResult = bench( + () => ssStrWasm([...SORTED_STR], "cherry", false), + ITERS, + ); + benchmarks.push({ + function: "searchsorted_str", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + notes: "String arrays are copied for each WASM call; raw kernel speedup is partially offset by copy overhead.", + }); +} + +// ─── argsort_str ────────────────────────────────────────────────────────────── + +{ + const argsortStrWasm = getWasmFn("argsort_str"); + const tsResult = bench(() => argsortScalars(SORTED_STR), ITERS); + const wasmResult = bench(() => argsortStrWasm([...SORTED_STR]), ITERS); + benchmarks.push({ + function: "argsort_str", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + notes: "Same array-copy caveat as searchsorted_str.", + }); +} + +// ─── nat_compare ────────────────────────────────────────────────────────────── + +{ + const natCmpWasm = getWasmFn("nat_compare"); + const tsResult = bench(() => natCompare("file100", "file99", {}), ITERS); + const wasmResult = bench(() => natCmpWasm("file100", "file99", false, false), ITERS); + benchmarks.push({ + function: "nat_compare", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + }); +} + +// ─── nat_sorted ─────────────────────────────────────────────────────────────── + +{ + const natSortedWasm = getWasmFn("nat_sorted"); + const tsResult = bench(() => natSorted([...UNSORTED_FILES], {}), ITERS); + const wasmResult = bench(() => natSortedWasm([...UNSORTED_FILES], false, false), ITERS); + benchmarks.push({ + function: "nat_sorted", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + }); +} + +// ─── nat_argsort ────────────────────────────────────────────────────────────── + +{ + const natArgsortWasm = getWasmFn("nat_argsort"); + const tsResult = bench(() => natArgSort([...UNSORTED_FILES], {}), ITERS); + const wasmResult = bench(() => natArgsortWasm([...UNSORTED_FILES], false, false), ITERS); + benchmarks.push({ + function: "nat_argsort", + tsb: tsResult, + tsb_wasm: wasmResult, + wasm_speedup: tsResult.mean_ms / wasmResult.mean_ms, + }); +} + +// ─── coverage summary ───────────────────────────────────────────────────────── + +const coverageManifest = JSON.parse( + readFileSync(resolve(repoRoot, "wasm-coverage.json"), "utf-8"), +) as { summary: { total_core_entries: number; rust_wasm: number; ts_only_ineligible: number; unclassified: number; eligible_missing: number } }; + +const coverage = { + unclassified: coverageManifest.summary.unclassified, + eligible_missing: coverageManifest.summary.eligible_missing, + total_core_entries: coverageManifest.summary.total_core_entries, + rust_wasm: coverageManifest.summary.rust_wasm, + ts_only_ineligible: coverageManifest.summary.ts_only_ineligible, +}; + +// ─── analysis: slower-than-TypeScript cases ─────────────────────────────────── + +const slowerCases = benchmarks.filter((b) => b.wasm_speedup < 1.0); +if (slowerCases.length > 0) { + console.log("\nWASM slower than TypeScript cases:"); + for (const b of slowerCases) { + console.log( + ` ${b.function}: WASM ${(b.wasm_speedup * 100).toFixed(1)}% of TS speed` + + (b.notes !== undefined ? ` (${b.notes})` : ""), + ); + } +} + +// ─── write results ───────────────────────────────────────────────────────────── + +const results = { + benchmarks, + coverage, + timestamp: new Date().toISOString(), + slower_than_typescript: slowerCases.map((b) => ({ + function: b.function, + wasm_speedup: b.wasm_speedup, + explanation: b.notes ?? "WASM/JS boundary overhead exceeds kernel speedup at this array size.", + })), +}; + +mkdirSync(resolve(repoRoot, "benchmarks"), { recursive: true }); +const outPath = resolve(repoRoot, "benchmarks/results-wasm-core.json"); +writeFileSync(outPath, JSON.stringify(results, null, 2)); + +console.log(`\nResults written to benchmarks/results-wasm-core.json`); +console.log(`Benchmarks: ${benchmarks.length} entries`); +console.log(`Coverage: ${coverage.rust_wasm} rust-wasm, ${coverage.ts_only_ineligible} ts-only-ineligible, ${coverage.unclassified} unclassified, ${coverage.eligible_missing} eligible-missing`); diff --git a/biome.json b/biome.json index 353ba7be..6fd44f5b 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,9 @@ "playground/serve.ts", "golden/snapshots/**", "benchmarks/**", - ".autoloop/**" + ".autoloop/**", + "rust/**", + "scripts/**" ] }, "formatter": { diff --git a/package.json b/package.json index 076a8a67..df528ef9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ "lint:fix": "biome check --write .", "typecheck": "tsc --noEmit", "build": "bun build ./src/index.ts --outdir ./dist --target browser", - "playground": "bun run playground/serve.ts" + "playground": "bun run playground/serve.ts", + "wasm:build": "wasm-pack build --target nodejs rust/ --out-dir pkg", + "wasm:test": "cd rust && cargo test && cd .. && bun test tests/wasm/parity.test.ts", + "wasm:coverage": "bun run scripts/wasm-coverage-check.ts", + "bench:wasm-core": "bun run benchmarks/wasm-core/run.ts" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 00000000..a6fd63cc --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,402 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tsb-wasm" +version = "0.1.0" +dependencies = [ + "js-sys", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..360f1db5 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tsb-wasm" +version = "0.1.0" +edition = "2021" +description = "Rust/WASM acceleration layer for tsb" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "z" +lto = true + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/rust/pkg/.gitignore b/rust/pkg/.gitignore new file mode 100644 index 00000000..f59ec20a --- /dev/null +++ b/rust/pkg/.gitignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/rust/pkg/package.json b/rust/pkg/package.json new file mode 100644 index 00000000..cb9a8514 --- /dev/null +++ b/rust/pkg/package.json @@ -0,0 +1,12 @@ +{ + "name": "tsb-wasm", + "description": "Rust/WASM acceleration layer for tsb", + "version": "0.1.0", + "files": [ + "tsb_wasm_bg.wasm", + "tsb_wasm.js", + "tsb_wasm.d.ts" + ], + "main": "tsb_wasm.js", + "types": "tsb_wasm.d.ts" +} diff --git a/rust/pkg/tsb_wasm.d.ts b/rust/pkg/tsb_wasm.d.ts new file mode 100644 index 00000000..9bc64dec --- /dev/null +++ b/rust/pkg/tsb_wasm.d.ts @@ -0,0 +1,67 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Return the indices that would sort `arr` (argsort) for f64 values. + * + * NaN values are placed last, matching the TypeScript default comparator. + */ +export function argsort_f64(arr: Float64Array): Uint32Array; + +/** + * Return the indices that would sort `arr` (argsort) for string values. + */ +export function argsort_str(arr: string[]): Uint32Array; + +/** + * Return the indices that would sort `arr` in natural order. + */ +export function nat_argsort(arr: string[], ignore_case: boolean, reverse: boolean): Uint32Array; + +/** + * Compare two strings using natural order. + * + * Returns a negative number when `a < b`, zero when `a == b`, and a positive + * number when `a > b` (matching the TypeScript contract for a compare + * function). + * + * `ignore_case`: fold text tokens to lower-case before comparing. + * `reverse`: invert the result. + */ +export function nat_compare(a: string, b: string, ignore_case: boolean, reverse: boolean): number; + +/** + * Sort `arr` of strings in natural order and return the sorted copy. + * + * `ignore_case`: fold text tokens to lower-case. + * `reverse`: sort in descending natural order. + */ +export function nat_sorted(arr: string[], ignore_case: boolean, reverse: boolean): string[]; + +/** + * Binary-search a sorted f64 slice for `value`. + * + * `side_right = false` returns the leftmost insertion point (equivalent to + * `side = "left"` in TypeScript); `side_right = true` returns the rightmost + * (equivalent to `side = "right"`). + * + * NaN values are treated as greater than all finite/infinite values, matching + * the TypeScript `compareNumbers` behaviour. + */ +export function searchsorted_f64(arr: Float64Array, value: number, side_right: boolean): number; + +/** + * Binary-search a sorted f64 slice for each value in `values`, returning an + * array of insertion positions. + */ +export function searchsorted_many_f64(arr: Float64Array, values: Float64Array, side_right: boolean): Uint32Array; + +/** + * Binary-search a sorted string array for each value in `values`. + */ +export function searchsorted_many_str(arr: string[], values: string[], side_right: boolean): Uint32Array; + +/** + * Binary-search a sorted array of strings for `value`. + */ +export function searchsorted_str(arr: string[], value: string, side_right: boolean): number; diff --git a/rust/pkg/tsb_wasm.js b/rust/pkg/tsb_wasm.js new file mode 100644 index 00000000..2f9cae8a --- /dev/null +++ b/rust/pkg/tsb_wasm.js @@ -0,0 +1,351 @@ +/* @ts-self-types="./tsb_wasm.d.ts" */ + +/** + * Return the indices that would sort `arr` (argsort) for f64 values. + * + * NaN values are placed last, matching the TypeScript default comparator. + * @param {Float64Array} arr + * @returns {Uint32Array} + */ +function argsort_f64(arr) { + const ptr0 = passArrayF64ToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.argsort_f64(ptr0, len0); + var v2 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v2; +} +exports.argsort_f64 = argsort_f64; + +/** + * Return the indices that would sort `arr` (argsort) for string values. + * @param {string[]} arr + * @returns {Uint32Array} + */ +function argsort_str(arr) { + const ptr0 = passArrayJsValueToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.argsort_str(ptr0, len0); + var v2 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v2; +} +exports.argsort_str = argsort_str; + +/** + * Return the indices that would sort `arr` in natural order. + * @param {string[]} arr + * @param {boolean} ignore_case + * @param {boolean} reverse + * @returns {Uint32Array} + */ +function nat_argsort(arr, ignore_case, reverse) { + const ptr0 = passArrayJsValueToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.nat_argsort(ptr0, len0, ignore_case, reverse); + var v2 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v2; +} +exports.nat_argsort = nat_argsort; + +/** + * Compare two strings using natural order. + * + * Returns a negative number when `a < b`, zero when `a == b`, and a positive + * number when `a > b` (matching the TypeScript contract for a compare + * function). + * + * `ignore_case`: fold text tokens to lower-case before comparing. + * `reverse`: invert the result. + * @param {string} a + * @param {string} b + * @param {boolean} ignore_case + * @param {boolean} reverse + * @returns {number} + */ +function nat_compare(a, b, ignore_case, reverse) { + const ptr0 = passStringToWasm0(a, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(b, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.nat_compare(ptr0, len0, ptr1, len1, ignore_case, reverse); + return ret; +} +exports.nat_compare = nat_compare; + +/** + * Sort `arr` of strings in natural order and return the sorted copy. + * + * `ignore_case`: fold text tokens to lower-case. + * `reverse`: sort in descending natural order. + * @param {string[]} arr + * @param {boolean} ignore_case + * @param {boolean} reverse + * @returns {string[]} + */ +function nat_sorted(arr, ignore_case, reverse) { + const ptr0 = passArrayJsValueToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.nat_sorted(ptr0, len0, ignore_case, reverse); + var v2 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v2; +} +exports.nat_sorted = nat_sorted; + +/** + * Binary-search a sorted f64 slice for `value`. + * + * `side_right = false` returns the leftmost insertion point (equivalent to + * `side = "left"` in TypeScript); `side_right = true` returns the rightmost + * (equivalent to `side = "right"`). + * + * NaN values are treated as greater than all finite/infinite values, matching + * the TypeScript `compareNumbers` behaviour. + * @param {Float64Array} arr + * @param {number} value + * @param {boolean} side_right + * @returns {number} + */ +function searchsorted_f64(arr, value, side_right) { + const ptr0 = passArrayF64ToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.searchsorted_f64(ptr0, len0, value, side_right); + return ret >>> 0; +} +exports.searchsorted_f64 = searchsorted_f64; + +/** + * Binary-search a sorted f64 slice for each value in `values`, returning an + * array of insertion positions. + * @param {Float64Array} arr + * @param {Float64Array} values + * @param {boolean} side_right + * @returns {Uint32Array} + */ +function searchsorted_many_f64(arr, values, side_right) { + const ptr0 = passArrayF64ToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passArrayF64ToWasm0(values, wasm.__wbindgen_malloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.searchsorted_many_f64(ptr0, len0, ptr1, len1, side_right); + var v3 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v3; +} +exports.searchsorted_many_f64 = searchsorted_many_f64; + +/** + * Binary-search a sorted string array for each value in `values`. + * @param {string[]} arr + * @param {string[]} values + * @param {boolean} side_right + * @returns {Uint32Array} + */ +function searchsorted_many_str(arr, values, side_right) { + const ptr0 = passArrayJsValueToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passArrayJsValueToWasm0(values, wasm.__wbindgen_malloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.searchsorted_many_str(ptr0, len0, ptr1, len1, side_right); + var v3 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v3; +} +exports.searchsorted_many_str = searchsorted_many_str; + +/** + * Binary-search a sorted array of strings for `value`. + * @param {string[]} arr + * @param {string} value + * @param {boolean} side_right + * @returns {number} + */ +function searchsorted_str(arr, value, side_right) { + const ptr0 = passArrayJsValueToWasm0(arr, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.searchsorted_str(ptr0, len0, ptr1, len1, side_right); + return ret >>> 0; +} +exports.searchsorted_str = searchsorted_str; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./tsb_wasm_bg.js": import0, + }; +} + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +function getArrayJsValueFromWasm0(ptr, len) { + ptr = ptr >>> 0; + const mem = getDataViewMemory0(); + const result = []; + for (let i = ptr; i < ptr + 4 * len; i += 4) { + result.push(wasm.__wbindgen_externrefs.get(mem.getUint32(i, true))); + } + wasm.__externref_drop_slice(ptr, len); + return result; +} + +function getArrayU32FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +let cachedFloat64ArrayMemory0 = null; +function getFloat64ArrayMemory0() { + if (cachedFloat64ArrayMemory0 === null || cachedFloat64ArrayMemory0.byteLength === 0) { + cachedFloat64ArrayMemory0 = new Float64Array(wasm.memory.buffer); + } + return cachedFloat64ArrayMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint32ArrayMemory0 = null; +function getUint32ArrayMemory0() { + if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) { + cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer); + } + return cachedUint32ArrayMemory0; +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function passArrayF64ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 8, 8) >>> 0; + getFloat64ArrayMemory0().set(arg, ptr / 8); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passArrayJsValueToWasm0(array, malloc) { + const ptr = malloc(array.length * 4, 4) >>> 0; + for (let i = 0; i < array.length; i++) { + const add = addToExternrefTable0(array[i]); + getDataViewMemory0().setUint32(ptr + 4 * i, add, true); + } + WASM_VECTOR_LEN = array.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +function decodeText(ptr, len) { + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +const wasmPath = `${__dirname}/tsb_wasm_bg.wasm`; +const wasmBytes = require('fs').readFileSync(wasmPath); +const wasmModule = new WebAssembly.Module(wasmBytes); +let wasmInstance = new WebAssembly.Instance(wasmModule, __wbg_get_imports()); +let wasm = wasmInstance.exports; +wasm.__wbindgen_start(); diff --git a/rust/pkg/tsb_wasm_bg.wasm b/rust/pkg/tsb_wasm_bg.wasm new file mode 100644 index 00000000..42ed7985 Binary files /dev/null and b/rust/pkg/tsb_wasm_bg.wasm differ diff --git a/rust/pkg/tsb_wasm_bg.wasm.d.ts b/rust/pkg/tsb_wasm_bg.wasm.d.ts new file mode 100644 index 00000000..d73466a4 --- /dev/null +++ b/rust/pkg/tsb_wasm_bg.wasm.d.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const nat_argsort: (a: number, b: number, c: number, d: number) => [number, number]; +export const nat_compare: (a: number, b: number, c: number, d: number, e: number, f: number) => number; +export const nat_sorted: (a: number, b: number, c: number, d: number) => [number, number]; +export const argsort_f64: (a: number, b: number) => [number, number]; +export const argsort_str: (a: number, b: number) => [number, number]; +export const searchsorted_f64: (a: number, b: number, c: number, d: number) => number; +export const searchsorted_many_f64: (a: number, b: number, c: number, d: number, e: number) => [number, number]; +export const searchsorted_many_str: (a: number, b: number, c: number, d: number, e: number) => [number, number]; +export const searchsorted_str: (a: number, b: number, c: number, d: number, e: number) => number; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __externref_table_alloc: () => number; +export const __externref_drop_slice: (a: number, b: number) => void; +export const __wbindgen_start: () => void; diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 00000000..ba0cc5f0 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,13 @@ +/*! + * tsb-wasm: Rust/WASM acceleration layer for tsb. + * + * Exposes pure-computation helpers that are otherwise implemented in TypeScript + * in `src/core/`. Every exported function has a TypeScript counterpart that + * is used as the fallback when the WASM module is unavailable. + */ + +mod natsort; +mod searchsorted; + +pub use natsort::*; +pub use searchsorted::*; diff --git a/rust/src/natsort.rs b/rust/src/natsort.rs new file mode 100644 index 00000000..a3cedd72 --- /dev/null +++ b/rust/src/natsort.rs @@ -0,0 +1,206 @@ +/*! + * Natural-order sort accelerators. + * + * Mirrors the TypeScript `natCompare`, `natSorted`, and `natArgSort` functions + * in `src/core/natsort.ts`. + * + * The algorithm tokenises each string into alternating text and digit chunks + * and compares them chunk-by-chunk: + * - Digit chunks compared numerically (so "file10" > "file9"). + * - Text chunks compared lexicographically (optionally case-folded). + */ + +use wasm_bindgen::prelude::*; + +// ─── token type ────────────────────────────────────────────────────────────── + +/// A single token: either a text segment or a parsed non-negative integer. +#[derive(Debug, PartialEq, Eq)] +enum Token { + Text(String), + Num(u64), +} + +/// Split `s` into alternating text and digit tokens. +/// +/// Mirrors the TypeScript `tokenize` function: +/// - `"file10.txt"` → `[Text("file"), Num(10), Text(".txt")]` +/// - `"007"` → `[Num(7)]` +fn tokenize(s: &str) -> Vec { + let mut tokens: Vec = Vec::new(); + let chars: Vec = s.chars().collect(); + let n = chars.len(); + let mut i = 0; + while i < n { + if chars[i].is_ascii_digit() { + // Consume the run of digits + let start = i; + while i < n && chars[i].is_ascii_digit() { + i += 1; + } + let digit_str: String = chars[start..i].iter().collect(); + let num: u64 = digit_str.parse().unwrap_or(0); + tokens.push(Token::Num(num)); + } else { + // Consume non-digit characters + let start = i; + while i < n && !chars[i].is_ascii_digit() { + i += 1; + } + let text: String = chars[start..i].iter().collect(); + tokens.push(Token::Text(text)); + } + } + tokens +} + +/// Compare two token sequences, optionally case-folding text tokens. +fn compare_tokens(ta: &[Token], tb: &[Token], ignore_case: bool) -> std::cmp::Ordering { + let len = ta.len().min(tb.len()); + for i in 0..len { + let ord = match (&ta[i], &tb[i]) { + (Token::Num(a), Token::Num(b)) => a.cmp(b), + (Token::Text(a), Token::Text(b)) => { + if ignore_case { + a.to_lowercase().cmp(&b.to_lowercase()) + } else { + a.cmp(b) + } + } + // Mixed types: compare string representations + (Token::Num(a), Token::Text(b)) => a.to_string().cmp(b), + (Token::Text(a), Token::Num(b)) => a.as_str().cmp(b.to_string().as_str()), + }; + if ord != std::cmp::Ordering::Equal { + return ord; + } + } + ta.len().cmp(&tb.len()) +} + +// ─── public WASM exports ───────────────────────────────────────────────────── + +/// Compare two strings using natural order. +/// +/// Returns a negative number when `a < b`, zero when `a == b`, and a positive +/// number when `a > b` (matching the TypeScript contract for a compare +/// function). +/// +/// `ignore_case`: fold text tokens to lower-case before comparing. +/// `reverse`: invert the result. +#[wasm_bindgen] +pub fn nat_compare(a: &str, b: &str, ignore_case: bool, reverse: bool) -> i32 { + let ta = tokenize(a); + let tb = tokenize(b); + let ord = compare_tokens(&ta, &tb, ignore_case); + let result = match ord { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + }; + if reverse { -result } else { result } +} + +/// Sort `arr` of strings in natural order and return the sorted copy. +/// +/// `ignore_case`: fold text tokens to lower-case. +/// `reverse`: sort in descending natural order. +#[wasm_bindgen] +pub fn nat_sorted(mut arr: Vec, ignore_case: bool, reverse: bool) -> Vec { + arr.sort_by(|a, b| { + let ta = tokenize(a); + let tb = tokenize(b); + let ord = compare_tokens(&ta, &tb, ignore_case); + if reverse { ord.reverse() } else { ord } + }); + arr +} + +/// Return the indices that would sort `arr` in natural order. +#[wasm_bindgen] +pub fn nat_argsort(arr: Vec, ignore_case: bool, reverse: bool) -> Vec { + let keys: Vec> = arr.iter().map(|s| tokenize(s)).collect(); + let mut indices: Vec = (0..arr.len() as u32).collect(); + indices.sort_by(|&i, &j| { + let ord = compare_tokens(&keys[i as usize], &keys[j as usize], ignore_case); + if reverse { ord.reverse() } else { ord } + }); + indices +} + +// ─── unit tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tokenize_mixed() { + assert_eq!( + tokenize("file10.txt"), + vec![ + Token::Text("file".to_string()), + Token::Num(10), + Token::Text(".txt".to_string()), + ] + ); + assert_eq!(tokenize("007"), vec![Token::Num(7)]); + assert_eq!(tokenize("abc"), vec![Token::Text("abc".to_string())]); + } + + #[test] + fn test_nat_compare_numeric_order() { + // "file10" > "file9" (natural order) + assert!(nat_compare("file10", "file9", false, false) > 0); + // "file2" < "file10" + assert!(nat_compare("file2", "file10", false, false) < 0); + // equal + assert_eq!(nat_compare("abc", "abc", false, false), 0); + } + + #[test] + fn test_nat_compare_ignore_case() { + assert_eq!(nat_compare("Apple", "apple", true, false), 0); + assert!(nat_compare("banana", "Cherry", true, false) < 0); + } + + #[test] + fn test_nat_compare_reverse() { + let forward = nat_compare("file10", "file9", false, false); + let reversed = nat_compare("file10", "file9", false, true); + assert_eq!(forward, -reversed); + } + + #[test] + fn test_nat_sorted() { + let arr = vec![ + "file10".to_string(), + "file2".to_string(), + "file1".to_string(), + "file20".to_string(), + ]; + let sorted = nat_sorted(arr, false, false); + assert_eq!( + sorted, + vec!["file1", "file2", "file10", "file20"] + ); + } + + #[test] + fn test_nat_sorted_reverse() { + let arr = vec!["b".to_string(), "a".to_string(), "c".to_string()]; + let sorted = nat_sorted(arr, false, true); + assert_eq!(sorted, vec!["c", "b", "a"]); + } + + #[test] + fn test_nat_argsort() { + let arr = vec![ + "file10".to_string(), + "file2".to_string(), + "file1".to_string(), + ]; + let idx = nat_argsort(arr, false, false); + assert_eq!(idx, vec![2, 1, 0]); // file1, file2, file10 + } +} diff --git a/rust/src/searchsorted.rs b/rust/src/searchsorted.rs new file mode 100644 index 00000000..b4950faf --- /dev/null +++ b/rust/src/searchsorted.rs @@ -0,0 +1,187 @@ +/*! + * Binary search (searchsorted) and argsort accelerators. + * + * Mirrors the TypeScript `searchsorted`, `searchsortedMany`, and `argsortScalars` + * functions in `src/core/searchsorted.ts` for pure numeric and string inputs. + */ + +use wasm_bindgen::prelude::*; + +// ─── f64 searchsorted ──────────────────────────────────────────────────────── + +/// Binary-search a sorted f64 slice for `value`. +/// +/// `side_right = false` returns the leftmost insertion point (equivalent to +/// `side = "left"` in TypeScript); `side_right = true` returns the rightmost +/// (equivalent to `side = "right"`). +/// +/// NaN values are treated as greater than all finite/infinite values, matching +/// the TypeScript `compareNumbers` behaviour. +#[wasm_bindgen] +pub fn searchsorted_f64(arr: &[f64], value: f64, side_right: bool) -> u32 { + let n = arr.len(); + let mut lo: usize = 0; + let mut hi: usize = n; + while lo < hi { + let mid = lo + (hi - lo) / 2; + // SAFETY: mid < hi <= n, so mid is in bounds. + let v = arr[mid]; + let cmp = cmp_f64(v, value); + let advance = if side_right { + cmp != std::cmp::Ordering::Greater + } else { + cmp == std::cmp::Ordering::Less + }; + if advance { + lo = mid + 1; + } else { + hi = mid; + } + } + lo as u32 +} + +/// Binary-search a sorted f64 slice for each value in `values`, returning an +/// array of insertion positions. +#[wasm_bindgen] +pub fn searchsorted_many_f64(arr: &[f64], values: &[f64], side_right: bool) -> Vec { + values + .iter() + .map(|&v| searchsorted_f64(arr, v, side_right)) + .collect() +} + +/// Return the indices that would sort `arr` (argsort) for f64 values. +/// +/// NaN values are placed last, matching the TypeScript default comparator. +#[wasm_bindgen] +pub fn argsort_f64(arr: &[f64]) -> Vec { + let mut indices: Vec = (0..arr.len() as u32).collect(); + indices.sort_by(|&i, &j| cmp_f64(arr[i as usize], arr[j as usize])); + indices +} + +// ─── string searchsorted ───────────────────────────────────────────────────── + +/// Binary-search a sorted array of strings for `value`. +#[wasm_bindgen] +pub fn searchsorted_str(arr: Vec, value: &str, side_right: bool) -> u32 { + let n = arr.len(); + let mut lo: usize = 0; + let mut hi: usize = n; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let cmp = arr[mid].as_str().cmp(value); + if if side_right { + cmp != std::cmp::Ordering::Greater + } else { + cmp == std::cmp::Ordering::Less + } { + lo = mid + 1; + } else { + hi = mid; + } + } + lo as u32 +} + +/// Binary-search a sorted string array for each value in `values`. +#[wasm_bindgen] +pub fn searchsorted_many_str(arr: Vec, values: Vec, side_right: bool) -> Vec { + values + .iter() + .map(|v| searchsorted_str(arr.clone(), v.as_str(), side_right)) + .collect() +} + +/// Return the indices that would sort `arr` (argsort) for string values. +#[wasm_bindgen] +pub fn argsort_str(arr: Vec) -> Vec { + let mut indices: Vec = (0..arr.len() as u32).collect(); + indices.sort_by(|&i, &j| arr[i as usize].cmp(&arr[j as usize])); + indices +} + +// ─── internal helpers ───────────────────────────────────────────────────────── + +/// Compare two f64 values, treating NaN as greater than all non-NaN values +/// (matches TypeScript `compareNumbers`). +fn cmp_f64(a: f64, b: f64) -> std::cmp::Ordering { + let a_nan = a.is_nan(); + let b_nan = b.is_nan(); + match (a_nan, b_nan) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal), + } +} + +// ─── unit tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_searchsorted_f64_left() { + let arr = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(searchsorted_f64(&arr, 3.0, false), 2); + assert_eq!(searchsorted_f64(&arr, 0.0, false), 0); + assert_eq!(searchsorted_f64(&arr, 6.0, false), 5); + assert_eq!(searchsorted_f64(&arr, 3.5, false), 3); + } + + #[test] + fn test_searchsorted_f64_right() { + let arr = vec![1.0_f64, 2.0, 3.0, 3.0, 4.0]; + assert_eq!(searchsorted_f64(&arr, 3.0, true), 4); + assert_eq!(searchsorted_f64(&arr, 0.0, true), 0); + assert_eq!(searchsorted_f64(&arr, 5.0, true), 5); + } + + #[test] + fn test_searchsorted_f64_nan_last() { + // NaN treated as larger than everything + let arr = vec![1.0_f64, 2.0, f64::NAN]; + assert_eq!(searchsorted_f64(&arr, 1.5, false), 1); + assert_eq!(searchsorted_f64(&arr, f64::NAN, false), 2); + } + + #[test] + fn test_searchsorted_many_f64() { + let arr = vec![1.0_f64, 2.0, 3.0, 4.0]; + let result = searchsorted_many_f64(&arr, &[0.0, 2.0, 5.0], false); + assert_eq!(result, vec![0, 1, 4]); + } + + #[test] + fn test_argsort_f64() { + let arr = vec![3.0_f64, 1.0, 4.0, 1.0, 5.0]; + let idx = argsort_f64(&arr); + // indices that sort arr ascending + assert_eq!(idx, vec![1, 3, 0, 2, 4]); + } + + #[test] + fn test_argsort_f64_nan_last() { + let arr = vec![2.0_f64, f64::NAN, 1.0]; + let idx = argsort_f64(&arr); + assert_eq!(idx, vec![2, 0, 1]); + } + + #[test] + fn test_searchsorted_str() { + let arr = vec!["apple".to_string(), "banana".to_string(), "cherry".to_string()]; + assert_eq!(searchsorted_str(arr.clone(), "banana", false), 1); + assert_eq!(searchsorted_str(arr.clone(), "avocado", false), 1); + assert_eq!(searchsorted_str(arr.clone(), "date", false), 3); + } + + #[test] + fn test_argsort_str() { + let arr = vec!["cherry".to_string(), "apple".to_string(), "banana".to_string()]; + let idx = argsort_str(arr); + assert_eq!(idx, vec![1, 2, 0]); + } +} diff --git a/scripts/wasm-coverage-check.ts b/scripts/wasm-coverage-check.ts new file mode 100644 index 00000000..08cf5e4b --- /dev/null +++ b/scripts/wasm-coverage-check.ts @@ -0,0 +1,128 @@ +/** + * Rust/WASM coverage check script. + * + * Verifies that `wasm-coverage.json` contains no unclassified entries and no + * eligible functions that are missing implementations. Exits with a non-zero + * code and a descriptive error on any violation. + * + * Usage: bun run wasm:coverage + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const manifestPath = resolve(__dir, "..", "wasm-coverage.json"); + +let manifest: unknown; +try { + manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); +} catch (e) { + console.error(`ERROR: Could not read wasm-coverage.json at ${manifestPath}:`, e); + process.exit(1); +} + +type ManifestEntry = { name: string; status: string; reason?: string }; +type ManifestSummary = { + total_core_entries: number; + rust_wasm: number; + ts_only_ineligible: number; + unclassified: number; + eligible_missing: number; +}; +type Manifest = { entries: ManifestEntry[]; summary: ManifestSummary }; + +function isManifest(v: unknown): v is Manifest { + if (typeof v !== "object" || v === null) return false; + const obj = v as Record; + return Array.isArray(obj["entries"]) && typeof obj["summary"] === "object"; +} + +if (!isManifest(manifest)) { + console.error("ERROR: wasm-coverage.json does not have the expected structure."); + process.exit(1); +} + +const { entries, summary } = manifest; + +// ─── validate each entry ────────────────────────────────────────────────────── + +const validStatuses = new Set(["rust-wasm", "ts-only-ineligible"]); +const unclassified = entries.filter( + (e): boolean => !validStatuses.has(e.status), +); +const eligibleMissing = entries.filter( + (e): boolean => + e.status === "rust-wasm" && + (typeof e.reason === "string" && e.reason.toLowerCase().includes("todo")), +); + +// ─── validate summary ───────────────────────────────────────────────────────── + +const countedRustWasm = entries.filter((e) => e.status === "rust-wasm").length; +const countedTsOnly = entries.filter((e) => e.status === "ts-only-ineligible").length; + +// ─── report ─────────────────────────────────────────────────────────────────── + +let failed = false; + +if (unclassified.length > 0) { + console.error( + `ERROR: ${unclassified.length} entries have unrecognised status values:\n` + + unclassified.map((e) => ` - ${e.name}: "${e.status}"`).join("\n"), + ); + failed = true; +} + +if (summary.unclassified !== 0) { + console.error(`ERROR: summary.unclassified is ${summary.unclassified}, expected 0.`); + failed = true; +} + +if (summary.eligible_missing !== 0) { + console.error( + `ERROR: summary.eligible_missing is ${summary.eligible_missing}, expected 0.` + + "\n All rust-wasm entries must have WASM implementations (no todo/planned entries).", + ); + failed = true; +} + +if (summary.total_core_entries <= 0) { + console.error(`ERROR: summary.total_core_entries is ${summary.total_core_entries}, must be > 0.`); + failed = true; +} + +if (countedRustWasm !== summary.rust_wasm) { + console.error( + `ERROR: summary.rust_wasm=${summary.rust_wasm} but actual rust-wasm entries=${countedRustWasm}.`, + ); + failed = true; +} + +if (countedTsOnly !== summary.ts_only_ineligible) { + console.error( + `ERROR: summary.ts_only_ineligible=${summary.ts_only_ineligible} but actual=${countedTsOnly}.`, + ); + failed = true; +} + +if (entries.length !== summary.total_core_entries) { + console.error( + `ERROR: manifest has ${entries.length} entries but summary.total_core_entries=${summary.total_core_entries}.`, + ); + failed = true; +} + +if (failed) { + process.exit(1); +} + +// ─── success ────────────────────────────────────────────────────────────────── + +console.log(`✓ Rust/WASM coverage manifest is valid.`); +console.log(` Total core entries : ${summary.total_core_entries}`); +console.log(` rust-wasm : ${summary.rust_wasm}`); +console.log(` ts-only-ineligible : ${summary.ts_only_ineligible}`); +console.log(` unclassified : ${summary.unclassified}`); +console.log(` eligible_missing : ${summary.eligible_missing}`); diff --git a/src/wasm/accelerated.ts b/src/wasm/accelerated.ts new file mode 100644 index 00000000..e8ebc510 --- /dev/null +++ b/src/wasm/accelerated.ts @@ -0,0 +1,139 @@ +/** + * Accelerated public API — TypeScript wrappers that delegate to Rust/WASM + * when the module is available and fall back to the pure-TypeScript + * implementations otherwise. + * + * This module is the integration point between the TypeScript tsb public API + * and the optional Rust/WASM acceleration layer. + */ + +import { + argsortScalars as tsAS, + natArgSort as tsNA, + natCompare as tsNC, + natSorted as tsNS, + searchsortedMany as tsSMMany, + searchsorted as tsSS, +} from "../core/index.ts"; +import type { NatSortOptions, SearchSortedSide } from "../core/index.ts"; +import type { Scalar } from "../types.ts"; + +import { getWasm } from "./loader.ts"; +export { getWasm, loadWasm } from "./loader.ts"; + +// ─── searchsorted accelerated helpers ──────────────────────────────────────── + +/** + * Detect whether `arr` is a plain numeric array (no NaN-unsafe values, all + * numbers) that can be forwarded to the f64 WASM path. + */ +function isNumericArray(arr: readonly Scalar[]): arr is readonly number[] { + return arr.every((v) => typeof v === "number"); +} + +/** Detect a plain string-only array. */ +function isStringArray(arr: readonly Scalar[]): arr is readonly string[] { + return arr.every((v) => typeof v === "string"); +} + +/** + * Accelerated `searchsorted` for numeric or string arrays without a custom + * compareFn. Falls back to the TypeScript implementation for other types, + * mixed arrays, or when a custom `compareFn` is supplied. + */ +export function searchsortedAccelerated( + a: readonly Scalar[], + v: Scalar, + side: SearchSortedSide = "left", +): number { + const wasm = getWasm(); + const sideRight = side === "right"; + if (wasm !== null) { + if (typeof v === "number" && isNumericArray(a)) { + return wasm.searchsorted_f64(new Float64Array(a), v, sideRight); + } + if (typeof v === "string" && isStringArray(a)) { + return wasm.searchsorted_str([...a], v, sideRight); + } + } + return tsSS(a, v, { side }); +} + +/** + * Accelerated `searchsortedMany` for numeric or string arrays without a + * custom compareFn. Falls back to TypeScript for other cases. + */ +export function searchsortedManyAccelerated( + a: readonly Scalar[], + vs: readonly Scalar[], + side: SearchSortedSide = "left", +): number[] { + const wasm = getWasm(); + const sideRight = side === "right"; + if (wasm !== null) { + if (isNumericArray(a) && isNumericArray(vs)) { + return Array.from( + wasm.searchsorted_many_f64(new Float64Array(a), new Float64Array(vs), sideRight), + ); + } + if (isStringArray(a) && isStringArray(vs)) { + return Array.from(wasm.searchsorted_many_str([...a], [...vs], sideRight)); + } + } + return tsSMMany(a, vs, { side }); +} + +/** + * Accelerated `argsortScalars` for numeric or string arrays using the default + * compareFn. Falls back to TypeScript for custom comparators or mixed types. + */ +export function argsortScalarsAccelerated(a: readonly Scalar[]): number[] { + const wasm = getWasm(); + if (wasm !== null) { + if (isNumericArray(a)) { + return Array.from(wasm.argsort_f64(new Float64Array(a))); + } + if (isStringArray(a)) { + return Array.from(wasm.argsort_str([...a])); + } + } + return tsAS(a); +} + +// ─── natsort accelerated helpers ───────────────────────────────────────────── + +/** + * Accelerated `natCompare`. Falls back to TypeScript implementation if the + * WASM module is not loaded. + */ +export function natCompareAccelerated(a: string, b: string, opts: NatSortOptions = {}): number { + const wasm = getWasm(); + if (wasm !== null) { + return wasm.nat_compare(a, b, opts.ignoreCase ?? false, opts.reverse ?? false); + } + return tsNC(a, b, opts); +} + +/** + * Accelerated `natSorted` for string arrays without a `key` function. + * For generic arrays with a key function, falls back to TypeScript. + */ +export function natSortedAccelerated(arr: readonly string[], opts: NatSortOptions = {}): string[] { + const wasm = getWasm(); + if (wasm !== null) { + return wasm.nat_sorted([...arr], opts.ignoreCase ?? false, opts.reverse ?? false); + } + return tsNS(arr, opts); +} + +/** + * Accelerated `natArgSort` for string arrays. + * Falls back to TypeScript if the WASM module is unavailable. + */ +export function natArgSortAccelerated(arr: readonly string[], opts: NatSortOptions = {}): number[] { + const wasm = getWasm(); + if (wasm !== null) { + return Array.from(wasm.nat_argsort([...arr], opts.ignoreCase ?? false, opts.reverse ?? false)); + } + return tsNA(arr, opts); +} diff --git a/src/wasm/index.ts b/src/wasm/index.ts new file mode 100644 index 00000000..3f14a764 --- /dev/null +++ b/src/wasm/index.ts @@ -0,0 +1,17 @@ +/** + * Public API for the tsb Rust/WASM acceleration layer. + * + * Import from this module (not from sub-files) for stable public symbols. + */ + +export type { TsbWasmModule } from "./types.ts"; +export { + getWasm, + loadWasm, + searchsortedAccelerated, + searchsortedManyAccelerated, + argsortScalarsAccelerated, + natCompareAccelerated, + natSortedAccelerated, + natArgSortAccelerated, +} from "./accelerated.ts"; diff --git a/src/wasm/loader.ts b/src/wasm/loader.ts new file mode 100644 index 00000000..6104e1e5 --- /dev/null +++ b/src/wasm/loader.ts @@ -0,0 +1,86 @@ +/** + * Rust/WASM module loader for the tsb acceleration layer. + * + * The WASM module is an optional dependency: if the built artefact is not + * present (e.g. before `bun run wasm:build`), every public function falls + * back gracefully to `null`. + * + * Usage: + * ```ts + * const wasm = await loadWasm(); + * if (wasm !== null) { + * const idx = wasm.searchsorted_f64(arr, value, false); + * } + * ``` + */ + +import type { TsbWasmModule } from "./types.ts"; + +// ─── type guards ────────────────────────────────────────────────────────────── + +/** + * Check that `obj` exposes a callable property named `key`. + * + * Avoids `as` casts by using `Reflect.get` after the `typeof`/null guard, + * which narrows `unknown` → `object` so the call is type-safe. + */ +function hasFn(obj: unknown, key: string): boolean { + if (typeof obj !== "object" || obj === null) { + return false; + } + const val: unknown = Reflect.get(obj, key); + return typeof val === "function"; +} + +/** Narrow `mod` to {@link TsbWasmModule} by verifying all exported functions. */ +function isTsbWasmModule(mod: unknown): mod is TsbWasmModule { + return ( + hasFn(mod, "searchsorted_f64") && + hasFn(mod, "searchsorted_many_f64") && + hasFn(mod, "argsort_f64") && + hasFn(mod, "searchsorted_str") && + hasFn(mod, "searchsorted_many_str") && + hasFn(mod, "argsort_str") && + hasFn(mod, "nat_compare") && + hasFn(mod, "nat_sorted") && + hasFn(mod, "nat_argsort") + ); +} + +// ─── module cache ───────────────────────────────────────────────────────────── + +let _cached: TsbWasmModule | null | undefined; + +/** + * Load and return the tsb Rust/WASM module. + * + * Returns `null` if the WASM artefact has not been built yet (i.e. before + * `bun run wasm:build`) or if the runtime environment cannot load it. + * + * The result is cached: subsequent calls are synchronous after the first. + */ +export async function loadWasm(): Promise { + if (_cached !== undefined) { + return _cached; + } + + try { + // Use createRequire to load the CommonJS nodejs-target wasm-pack output. + const { createRequire } = await import("node:module"); + const _require = createRequire(import.meta.url); + const mod: unknown = _require("../../rust/pkg/tsb_wasm.js"); + _cached = isTsbWasmModule(mod) ? mod : null; + } catch { + _cached = null; + } + return _cached; +} + +/** + * Return the cached WASM module without loading, or `null` if not yet loaded + * or unavailable. Useful in synchronous contexts after `loadWasm()` has + * already been awaited. + */ +export function getWasm(): TsbWasmModule | null { + return _cached ?? null; +} diff --git a/src/wasm/types.ts b/src/wasm/types.ts new file mode 100644 index 00000000..1e37fb3f --- /dev/null +++ b/src/wasm/types.ts @@ -0,0 +1,79 @@ +/** + * TypeScript interface for the tsb WASM acceleration module. + * + * These declarations mirror the Rust functions exported from + * `rust/src/searchsorted.rs` and `rust/src/natsort.rs`. + * + * This file is the authoritative contract between the Rust implementation + * and the TypeScript glue layer. + */ + +/** The tsb Rust/WASM module interface. */ +export interface TsbWasmModule { + // ── searchsorted / argsort ────────────────────────────────────────────────── + + /** + * Binary-search a sorted Float64Array for `value`. + * `sideRight = false` → leftmost insertion point ("left"). + * `sideRight = true` → rightmost insertion point ("right"). + * NaN values are treated as greater than all non-NaN values. + */ + readonly searchsorted_f64: (arr: Float64Array, value: number, side_right: boolean) => number; + + /** + * Binary-search a sorted Float64Array for each value in `values`. + * Returns a Uint32Array of insertion positions. + */ + readonly searchsorted_many_f64: ( + arr: Float64Array, + values: Float64Array, + side_right: boolean, + ) => Uint32Array; + + /** + * Return the indices that would sort `arr` in ascending f64 order. + * NaN values are placed last. + */ + readonly argsort_f64: (arr: Float64Array) => Uint32Array; + + /** + * Binary-search a sorted string array for `value`. + * `sideRight = false` → leftmost; `sideRight = true` → rightmost. + */ + readonly searchsorted_str: (arr: string[], value: string, side_right: boolean) => number; + + /** + * Binary-search a sorted string array for each value in `values`. + * Returns a Uint32Array of insertion positions. + */ + readonly searchsorted_many_str: ( + arr: string[], + values: string[], + side_right: boolean, + ) => Uint32Array; + + /** Return the indices that would sort a string array lexicographically. */ + readonly argsort_str: (arr: string[]) => Uint32Array; + + // ── natsort ───────────────────────────────────────────────────────────────── + + /** + * Compare two strings using natural order. + * Returns < 0 when a < b, 0 when a == b, > 0 when a > b. + * `ignoreCase`: fold text tokens to lower-case. + * `reverse`: invert the result. + */ + readonly nat_compare: (a: string, b: string, ignore_case: boolean, reverse: boolean) => number; + + /** + * Sort a string array in natural order and return the sorted copy. + * `ignoreCase`: fold text tokens to lower-case. + * `reverse`: sort in descending natural order. + */ + readonly nat_sorted: (arr: string[], ignore_case: boolean, reverse: boolean) => string[]; + + /** + * Return the indices that would sort a string array in natural order. + */ + readonly nat_argsort: (arr: string[], ignore_case: boolean, reverse: boolean) => Uint32Array; +} diff --git a/tests/wasm/parity.test.ts b/tests/wasm/parity.test.ts new file mode 100644 index 00000000..3d99f722 --- /dev/null +++ b/tests/wasm/parity.test.ts @@ -0,0 +1,370 @@ +/** + * Parity tests — verify that the Rust/WASM acceleration layer returns the + * same observable results as the TypeScript implementations for representative + * normal, edge, missing-value, and dtype cases. + * + * Every `rust-wasm` entry in `wasm-coverage.json` is represented here. + */ + +import { beforeAll, describe, expect, test } from "bun:test"; +import { + argsortScalars, + natArgSort, + natCompare, + natSorted, + searchsorted, + searchsortedMany, +} from "../../src/core/index.ts"; +import { loadWasm } from "../../src/wasm/index.ts"; +import type { TsbWasmModule } from "../../src/wasm/index.ts"; + +let wasm: TsbWasmModule | null = null; + +beforeAll(async () => { + wasm = await loadWasm(); +}); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function skip(_label: string): void { + // no-op: caller already returns early when wasm is null +} + +// ─── searchsorted_f64 ──────────────────────────────────────────────────────── + +describe("searchsorted_f64 parity", () => { + const sortedNums = [1.0, 2.0, 3.0, 4.0, 5.0]; + + test("left insertion at boundary", () => { + if (wasm === null) { + skip("left insertion at boundary"); + return; + } + const arr = new Float64Array(sortedNums); + expect(wasm.searchsorted_f64(arr, 3.0, false)).toBe( + searchsorted(sortedNums, 3.0, { side: "left" }), + ); + }); + + test("right insertion with duplicates", () => { + if (wasm === null) { + skip("right insertion with duplicates"); + return; + } + const dups = [1.0, 2.0, 3.0, 3.0, 4.0]; + const arr = new Float64Array(dups); + expect(wasm.searchsorted_f64(arr, 3.0, true)).toBe(searchsorted(dups, 3.0, { side: "right" })); + }); + + test("value less than all elements", () => { + if (wasm === null) { + skip("value less than all elements"); + return; + } + const arr = new Float64Array(sortedNums); + expect(wasm.searchsorted_f64(arr, 0.0, false)).toBe(searchsorted(sortedNums, 0.0)); + }); + + test("value greater than all elements", () => { + if (wasm === null) { + skip("value greater than all elements"); + return; + } + const arr = new Float64Array(sortedNums); + expect(wasm.searchsorted_f64(arr, 6.0, false)).toBe(searchsorted(sortedNums, 6.0)); + }); + + test("NaN in sorted array — NaN treated as larger than all", () => { + if (wasm === null) { + skip("NaN in sorted array"); + return; + } + const withNaN = [1.0, 2.0, Number.NaN]; + const arr = new Float64Array(withNaN); + // TS: NaN is a missing value mapped to last + const tsResult = searchsorted(withNaN, 1.5); + const wasmResult = wasm.searchsorted_f64(arr, 1.5, false); + expect(wasmResult).toBe(tsResult); + }); + + test("empty array", () => { + if (wasm === null) { + skip("empty array"); + return; + } + const arr = new Float64Array([]); + expect(wasm.searchsorted_f64(arr, 1.0, false)).toBe(searchsorted([], 1.0)); + }); +}); + +// ─── searchsorted_many_f64 ─────────────────────────────────────────────────── + +describe("searchsorted_many_f64 parity", () => { + test("multiple values in a sorted numeric array", () => { + if (wasm === null) { + skip("multiple values"); + return; + } + const sorted = [1.0, 2.0, 3.0, 4.0, 5.0]; + const values = [0.0, 1.0, 2.5, 5.0, 6.0]; + const wasmResult = Array.from( + wasm.searchsorted_many_f64(new Float64Array(sorted), new Float64Array(values), false), + ); + const tsResult = searchsortedMany(sorted, values, { side: "left" }); + expect(wasmResult).toEqual(tsResult); + }); + + test("right side", () => { + if (wasm === null) { + skip("right side"); + return; + } + const sorted = [1.0, 1.0, 2.0, 3.0]; + const values = [1.0, 2.0]; + const wasmResult = Array.from( + wasm.searchsorted_many_f64(new Float64Array(sorted), new Float64Array(values), true), + ); + const tsResult = searchsortedMany(sorted, values, { side: "right" }); + expect(wasmResult).toEqual(tsResult); + }); +}); + +// ─── argsort_f64 ───────────────────────────────────────────────────────────── + +describe("argsort_f64 parity", () => { + test("ascending numeric sort", () => { + if (wasm === null) { + skip("ascending numeric sort"); + return; + } + const arr = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0]; + const wasmResult = Array.from(wasm.argsort_f64(new Float64Array(arr))); + const tsResult = argsortScalars(arr); + expect(wasmResult).toEqual(tsResult); + }); + + test("already sorted", () => { + if (wasm === null) { + skip("already sorted"); + return; + } + const arr = [1.0, 2.0, 3.0]; + const wasmResult = Array.from(wasm.argsort_f64(new Float64Array(arr))); + expect(wasmResult).toEqual([0, 1, 2]); + }); + + test("NaN placed last", () => { + if (wasm === null) { + skip("NaN placed last"); + return; + } + const arr = [2.0, Number.NaN, 1.0]; + const wasmResult = Array.from(wasm.argsort_f64(new Float64Array(arr))); + const tsResult = argsortScalars(arr); + expect(wasmResult).toEqual(tsResult); + }); + + test("single element", () => { + if (wasm === null) { + skip("single element"); + return; + } + expect(Array.from(wasm.argsort_f64(new Float64Array([42.0])))).toEqual([0]); + }); + + test("empty array", () => { + if (wasm === null) { + skip("empty array"); + return; + } + expect(Array.from(wasm.argsort_f64(new Float64Array([])))).toEqual([]); + }); +}); + +// ─── searchsorted_str ──────────────────────────────────────────────────────── + +describe("searchsorted_str parity", () => { + const sortedStrs = ["apple", "banana", "cherry", "date"]; + + test("left insertion", () => { + if (wasm === null) { + skip("left insertion"); + return; + } + expect(wasm.searchsorted_str([...sortedStrs], "cherry", false)).toBe( + searchsorted(sortedStrs, "cherry", { side: "left" }), + ); + }); + + test("value not in array — between elements", () => { + if (wasm === null) { + skip("value not in array"); + return; + } + expect(wasm.searchsorted_str([...sortedStrs], "avocado", false)).toBe( + searchsorted(sortedStrs, "avocado"), + ); + }); + + test("value past end", () => { + if (wasm === null) { + skip("value past end"); + return; + } + expect(wasm.searchsorted_str([...sortedStrs], "zucchini", false)).toBe( + searchsorted(sortedStrs, "zucchini"), + ); + }); +}); + +// ─── argsort_str ───────────────────────────────────────────────────────────── + +describe("argsort_str parity", () => { + test("unsorted string array", () => { + if (wasm === null) { + skip("unsorted string array"); + return; + } + const arr = ["cherry", "apple", "banana", "date"]; + const wasmResult = Array.from(wasm.argsort_str([...arr])); + const tsResult = argsortScalars(arr); + expect(wasmResult).toEqual(tsResult); + }); +}); + +// ─── nat_compare ───────────────────────────────────────────────────────────── + +describe("nat_compare parity", () => { + const cases: [string, string][] = [ + ["file10", "file9"], + ["file2", "file10"], + ["abc", "abc"], + ["File1", "file1"], + ["1", "2"], + ["b1", "a2"], + ["", "a"], + ["z9", "z10"], + ]; + + for (const [a, b] of cases) { + test(`natCompare("${a}", "${b}")`, () => { + if (wasm === null) { + skip(`natCompare("${a}", "${b}")`); + return; + } + const wasmSign = Math.sign(wasm.nat_compare(a, b, false, false)); + const tsSign = Math.sign(natCompare(a, b, {})); + expect(wasmSign).toBe(tsSign); + }); + } + + test("ignoreCase parity", () => { + if (wasm === null) { + skip("ignoreCase parity"); + return; + } + const wasmSign = Math.sign(wasm.nat_compare("Apple", "apple", true, false)); + const tsSign = Math.sign(natCompare("Apple", "apple", { ignoreCase: true })); + expect(wasmSign).toBe(tsSign); + }); + + test("reverse parity", () => { + if (wasm === null) { + skip("reverse parity"); + return; + } + const wasmFwd = wasm.nat_compare("file10", "file9", false, false); + const wasmRev = wasm.nat_compare("file10", "file9", false, true); + expect(Math.sign(wasmFwd)).toBe(-Math.sign(wasmRev)); + }); +}); + +// ─── nat_sorted ────────────────────────────────────────────────────────────── + +describe("nat_sorted parity", () => { + test("natural sort of file names", () => { + if (wasm === null) { + skip("natural sort of file names"); + return; + } + const arr = ["file10", "file2", "file1", "file20"]; + const wasmResult = wasm.nat_sorted([...arr], false, false); + const tsResult = natSorted(arr, {}); + expect(wasmResult).toEqual(tsResult); + }); + + test("reverse=true", () => { + if (wasm === null) { + skip("reverse=true"); + return; + } + const arr = ["b", "a", "c"]; + const wasmResult = wasm.nat_sorted([...arr], false, true); + const tsResult = natSorted(arr, { reverse: true }); + expect(wasmResult).toEqual(tsResult); + }); + + test("ignoreCase=true", () => { + if (wasm === null) { + skip("ignoreCase=true"); + return; + } + const arr = ["Banana", "apple", "Cherry"]; + const wasmResult = wasm.nat_sorted([...arr], true, false); + const tsResult = natSorted(arr, { ignoreCase: true }); + expect(wasmResult).toEqual(tsResult); + }); + + test("empty array", () => { + if (wasm === null) { + skip("empty array"); + return; + } + expect(wasm.nat_sorted([], false, false)).toEqual([]); + }); + + test("single element", () => { + if (wasm === null) { + skip("single element"); + return; + } + expect(wasm.nat_sorted(["x"], false, false)).toEqual(["x"]); + }); +}); + +// ─── nat_argsort ───────────────────────────────────────────────────────────── + +describe("nat_argsort parity", () => { + test("argsort matches natSorted order", () => { + if (wasm === null) { + skip("argsort matches natSorted order"); + return; + } + const arr = ["file10", "file2", "file1"]; + const wasmIdx = Array.from(wasm.nat_argsort([...arr], false, false)); + const tsIdx = natArgSort(arr, {}); + expect(wasmIdx).toEqual(tsIdx); + }); + + test("reverse=true", () => { + if (wasm === null) { + skip("reverse=true"); + return; + } + const arr = ["a", "c", "b"]; + const wasmIdx = Array.from(wasm.nat_argsort([...arr], false, true)); + const tsIdx = natArgSort(arr, { reverse: true }); + expect(wasmIdx).toEqual(tsIdx); + }); + + test("ignoreCase=true", () => { + if (wasm === null) { + skip("ignoreCase=true"); + return; + } + const arr = ["Banana", "apple", "Cherry"]; + const wasmIdx = Array.from(wasm.nat_argsort([...arr], true, false)); + const tsIdx = natArgSort(arr, { ignoreCase: true }); + expect(wasmIdx).toEqual(tsIdx); + }); +}); diff --git a/wasm-coverage.json b/wasm-coverage.json new file mode 100644 index 00000000..c96b3617 --- /dev/null +++ b/wasm-coverage.json @@ -0,0 +1,756 @@ +{ + "version": "1", + "description": "Rust/WASM acceleration coverage manifest for tsb core functions", + "notes": "Every value export from src/core/index.ts is classified as either rust-wasm (implemented in Rust, exported through WASM, wired into the accelerated path) or ts-only-ineligible (not suitable for Rust/WASM with a concrete reason). There are no unclassified or todo entries.", + "entries": [ + { + "name": "natCompare", + "module": "core/natsort", + "status": "rust-wasm", + "wasm_function": "nat_compare", + "parity_test": "tests/wasm/parity.test.ts", + "benchmark_group": "wasm-natsort" + }, + { + "name": "natSorted", + "module": "core/natsort", + "status": "rust-wasm", + "wasm_function": "nat_sorted", + "notes": "Accelerated path covers string arrays without a key function. Calls with a key function fall back to the TypeScript implementation automatically.", + "parity_test": "tests/wasm/parity.test.ts", + "benchmark_group": "wasm-natsort" + }, + { + "name": "natArgSort", + "module": "core/natsort", + "status": "rust-wasm", + "wasm_function": "nat_argsort", + "parity_test": "tests/wasm/parity.test.ts", + "benchmark_group": "wasm-natsort" + }, + { + "name": "natSortKey", + "module": "core/natsort", + "status": "ts-only-ineligible", + "reason": "Return type is readonly (string|number)[] — a JS-specific union array that has no direct WASM representation without per-element boxing. The resulting round-trip cost would likely exceed any benefit." + }, + { + "name": "searchsorted", + "module": "core/searchsorted", + "status": "rust-wasm", + "wasm_function": "searchsorted_f64 / searchsorted_str", + "notes": "Accelerated path covers numeric (f64) and string arrays with the default comparator. Calls with a custom compareFn or a sorter fall back to TypeScript automatically.", + "parity_test": "tests/wasm/parity.test.ts", + "benchmark_group": "wasm-searchsorted" + }, + { + "name": "searchsortedMany", + "module": "core/searchsorted", + "status": "rust-wasm", + "wasm_function": "searchsorted_many_f64 / searchsorted_many_str", + "notes": "Same fallback conditions as searchsorted.", + "parity_test": "tests/wasm/parity.test.ts", + "benchmark_group": "wasm-searchsorted" + }, + { + "name": "argsortScalars", + "module": "core/searchsorted", + "status": "rust-wasm", + "wasm_function": "argsort_f64 / argsort_str", + "notes": "Accelerated path covers numeric and string arrays with the default comparator. Custom compareFn falls back to TypeScript.", + "parity_test": "tests/wasm/parity.test.ts", + "benchmark_group": "wasm-searchsorted" + }, + { + "name": "Index", + "module": "core/base-index", + "status": "ts-only-ineligible", + "reason": "Complex JS class with JS-specific semantics: symbol-keyed properties, JS object identity, generator methods, and deep integration with the JS iterator protocol. All label-lookup logic depends on JS Map/Set identity." + }, + { + "name": "RangeIndex", + "module": "core/range-index", + "status": "ts-only-ineligible", + "reason": "Inherits from Index (JS class). Its primary value is lazy range generation using JS generators. No CPU-heavy numeric kernel to accelerate independently of the class." + }, + { + "name": "Dtype", + "module": "core/dtype", + "status": "ts-only-ineligible", + "reason": "JS class whose instances are used as JS runtime type tokens. Equality is checked via JS object identity. Cannot be serialised to/from WASM without losing identity semantics." + }, + { + "name": "Series", + "module": "core/series", + "status": "ts-only-ineligible", + "reason": "High-level JS class whose methods accept arbitrary JS callbacks, return new Series instances, and interoperate with JS accessors, Index objects, and the extension registry. Porting at the class level would require replacing the entire TS implementation." + }, + { + "name": "DataFrame", + "module": "core/frame", + "status": "ts-only-ineligible", + "reason": "Same as Series: high-level JS class with arbitrary JS callbacks, JS accessors, rolling/expanding/ewm subclasses, and deep interoperation with Series and Index." + }, + { + "name": "DataFrameRolling", + "module": "core/frame", + "status": "ts-only-ineligible", + "reason": "Inner class of DataFrame; window computation returns new DataFrame/Series instances and accepts JS callbacks." + }, + { + "name": "DataFrameExpanding", + "module": "core/frame", + "status": "ts-only-ineligible", + "reason": "Inner class of DataFrame; same reasoning as DataFrameRolling." + }, + { + "name": "DataFrameEwm", + "module": "core/frame", + "status": "ts-only-ineligible", + "reason": "Inner class of DataFrame; exponentially weighted window — depends on JS class state and returns DataFrame/Series." + }, + { + "name": "StringAccessor", + "module": "core/string_accessor", + "status": "ts-only-ineligible", + "reason": "JS accessor class exposing string operations that use JS regex and Intl APIs internally. Many operations have no WASM equivalent without reimplementing the JS Unicode/Intl stack." + }, + { + "name": "DatetimeAccessor", + "module": "core/datetime_accessor", + "status": "ts-only-ineligible", + "reason": "Wraps JS Date arithmetic and timezone conversions. Correctness depends on the JS Temporal/Intl system which cannot be reproduced from WASM without calling back into JS." + }, + { + "name": "CategoricalAccessor", + "module": "core/cat_accessor", + "status": "ts-only-ineligible", + "reason": "JS accessor that manages a categorical code/categories array pair stored as JS arrays with JS object identity. No isolated numeric kernel." + }, + { + "name": "MultiIndex", + "module": "core/multi_index", + "status": "ts-only-ineligible", + "reason": "JS class holding multiple JS Index objects; label lookup uses JS Map with tuple keys represented as strings. Deep JS-identity dependency." + }, + { + "name": "Interval", + "module": "core/interval", + "status": "ts-only-ineligible", + "reason": "JS class whose endpoints can be arbitrary Scalar values (including JS Date and Timedelta instances). Cannot be serialised to WASM without losing JS object semantics." + }, + { + "name": "IntervalIndex", + "module": "core/interval", + "status": "ts-only-ineligible", + "reason": "Inherits from Index; holds Interval instances. Same JS-identity constraint as Interval." + }, + { + "name": "CategoricalIndex", + "module": "core/categorical_index", + "status": "ts-only-ineligible", + "reason": "JS class for categorical index labels; depends on JS Map/Set identity for deduplication." + }, + { + "name": "Period", + "module": "core/period", + "status": "ts-only-ineligible", + "reason": "JS class wrapping a period value with frequency metadata. Arithmetic calls JS Date methods and the date-offset registry." + }, + { + "name": "PeriodIndex", + "module": "core/period", + "status": "ts-only-ineligible", + "reason": "Holds Period instances; inherits from Index. Same JS-identity dependency as Period." + }, + { + "name": "Timedelta", + "module": "core/timedelta", + "status": "ts-only-ineligible", + "reason": "JS class wrapping a duration in milliseconds plus component accessors. Arithmetic depends on JS Date. No isolated WASM-friendly kernel." + }, + { + "name": "TimedeltaIndex", + "module": "core/timedelta", + "status": "ts-only-ineligible", + "reason": "Holds Timedelta instances; inherits from Index. Same JS dependency as Timedelta." + }, + { + "name": "timedelta_range", + "module": "core/timedelta_range", + "status": "ts-only-ineligible", + "reason": "Generates a TimedeltaIndex using JS Date arithmetic. Output is a JS class instance array." + }, + { + "name": "Day", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; applies/rolls dates using JS Date. Cannot function independently of the JS date system." + }, + { + "name": "Hour", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as Day." + }, + { + "name": "Minute", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as Day." + }, + { + "name": "Second", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as Day." + }, + { + "name": "Milli", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as Day." + }, + { + "name": "Week", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as Day." + }, + { + "name": "MonthEnd", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; end-of-month rollforward requires JS Date.setFullYear/setMonth." + }, + { + "name": "MonthBegin", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as MonthEnd." + }, + { + "name": "YearEnd", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as MonthEnd." + }, + { + "name": "YearBegin", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; same reasoning as MonthEnd." + }, + { + "name": "BusinessDay", + "module": "core/date_offset", + "status": "ts-only-ineligible", + "reason": "JS DateOffset subclass; business-day skipping requires day-of-week arithmetic via JS Date." + }, + { + "name": "DatetimeIndex", + "module": "core/date_range", + "status": "ts-only-ineligible", + "reason": "Holds JS Date timestamps; inherits from Index. All construction and arithmetic uses JS Date APIs." + }, + { + "name": "date_range", + "module": "core/date_range", + "status": "ts-only-ineligible", + "reason": "Returns a DatetimeIndex generated by iterated JS Date arithmetic with date-offset objects." + }, + { + "name": "bdate_range", + "module": "core/date_range", + "status": "ts-only-ineligible", + "reason": "Business-day date range; depends on JS Date weekday computation and the DateOffset registry." + }, + { + "name": "resolveFreq", + "module": "core/date_range", + "status": "ts-only-ineligible", + "reason": "Parses a frequency string and returns a JS DateOffset instance from the runtime registry." + }, + { + "name": "TZDatetimeIndex", + "module": "core/datetime_tz", + "status": "ts-only-ineligible", + "reason": "Holds timezone-aware JS Date values; localisation and conversion delegate entirely to the JS Intl.DateTimeFormat API." + }, + { + "name": "tz_localize", + "module": "core/datetime_tz", + "status": "ts-only-ineligible", + "reason": "Creates a TZDatetimeIndex using JS Intl APIs; cannot run inside WASM." + }, + { + "name": "tz_convert", + "module": "core/datetime_tz", + "status": "ts-only-ineligible", + "reason": "Converts between timezones via JS Intl.DateTimeFormat; same Intl dependency as tz_localize." + }, + { + "name": "Timestamp", + "module": "core/timestamp", + "status": "ts-only-ineligible", + "reason": "JS wrapper around a nanosecond-precision timestamp. Format and parse operations delegate to JS Intl and Date. Object identity used throughout tsb's datetime pipeline." + }, + { + "name": "dataFrameAssign", + "module": "core/assign", + "status": "ts-only-ineligible", + "reason": "Accepts an AssignSpec object whose values can be arbitrary JS functions (column transformers). Cannot be represented in WASM without callback round-trips." + }, + { + "name": "reindexSeries", + "module": "core/reindex", + "status": "ts-only-ineligible", + "reason": "Operates on Series and Index JS class instances; fill methods (ffill/bfill) are JS-level operations on the resulting arrays. Porting requires migrating the entire Series/Index stack." + }, + { + "name": "reindexDataFrame", + "module": "core/reindex", + "status": "ts-only-ineligible", + "reason": "Same as reindexSeries; additionally accepts optional fill_value which may be a JS object." + }, + { + "name": "alignSeries", + "module": "core/align", + "status": "ts-only-ineligible", + "reason": "Returns a pair of re-indexed Series instances; depends entirely on JS class instances and the reindex pipeline." + }, + { + "name": "alignDataFrame", + "module": "core/align", + "status": "ts-only-ineligible", + "reason": "Same as alignSeries for DataFrames." + }, + { + "name": "insertColumn", + "module": "core/insert_pop", + "status": "ts-only-ineligible", + "reason": "Mutates a DataFrame JS object's column map. The DataFrame is a live JS instance; mutation is an in-place JS operation with no WASM-amenable equivalent." + }, + { + "name": "popColumn", + "module": "core/insert_pop", + "status": "ts-only-ineligible", + "reason": "Same as insertColumn; removes a column from the DataFrame's internal JS Map." + }, + { + "name": "reorderColumns", + "module": "core/insert_pop", + "status": "ts-only-ineligible", + "reason": "Reorders the JS Map entries in a DataFrame; depends on JS object mutation." + }, + { + "name": "moveColumn", + "module": "core/insert_pop", + "status": "ts-only-ineligible", + "reason": "Combines reorder and insert; same JS object mutation dependency." + }, + { + "name": "dataFrameFromPairs", + "module": "core/insert_pop", + "status": "ts-only-ineligible", + "reason": "Constructs a DataFrame from an array of [label, Series] pairs; returns a new JS DataFrame instance." + }, + { + "name": "toDictOriented", + "module": "core/to_from_dict", + "status": "ts-only-ineligible", + "reason": "Serialises a DataFrame to a plain JS object with arbitrary key/value structure. Output type is a JS Record whose shape depends on the orient parameter." + }, + { + "name": "fromDictOriented", + "module": "core/to_from_dict", + "status": "ts-only-ineligible", + "reason": "Deserialises a plain JS object to a DataFrame; input is an untyped JS object whose structure varies by orient." + }, + { + "name": "getAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Returns a reference to the JS Attrs object (plain Record). Semantics are based on JS object identity; no numeric computation involved." + }, + { + "name": "setAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Sets the attrs object on a Series/DataFrame using JS object identity. Pure JS metadata operation." + }, + { + "name": "updateAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Merges an attrs object using Object.assign semantics; pure JS." + }, + { + "name": "copyAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Shallow-copies an attrs Record; pure JS object operation." + }, + { + "name": "withAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Returns a new Series/DataFrame with updated attrs; depends on JS class constructor." + }, + { + "name": "clearAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Removes the attrs property; pure JS object mutation." + }, + { + "name": "hasAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Checks for the attrs property presence; pure JS property check." + }, + { + "name": "getAttr", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Key-value lookup in a JS object; pure JS property access." + }, + { + "name": "setAttr", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Key-value write in a JS object; pure JS property mutation." + }, + { + "name": "deleteAttr", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Deletes a key from a JS object; pure JS property mutation." + }, + { + "name": "attrsCount", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Counts keys in a JS object using Object.keys; pure JS." + }, + { + "name": "attrsKeys", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Returns Object.keys of a JS object; pure JS." + }, + { + "name": "mergeAttrs", + "module": "core/attrs", + "status": "ts-only-ineligible", + "reason": "Merges two attrs Records using spread; pure JS object operation." + }, + { + "name": "pipe", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "Applies an arbitrary JS function to a value; callback semantics make WASM acceleration impossible without JS callback round-trips." + }, + { + "name": "seriesApply", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "Maps an arbitrary JS function over Series elements. Each invocation crosses the JS/WASM boundary per element; net cost would exceed benefit." + }, + { + "name": "seriesTransform", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "Same as seriesApply; accepts a JS callback that must run in the JS context." + }, + { + "name": "dataFrameApply", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "Applies a JS callback across DataFrame rows/columns. Callback semantics prevent WASM acceleration." + }, + { + "name": "dataFrameApplyMap", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "Element-wise JS callback across DataFrame cells; same boundary-crossing argument." + }, + { + "name": "dataFrameTransform", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "JS callback transform on DataFrame; same reasoning as dataFrameApply." + }, + { + "name": "dataFrameTransformRows", + "module": "core/pipe_apply", + "status": "ts-only-ineligible", + "reason": "Row-wise JS callback transform; same reasoning." + }, + { + "name": "isScalar", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "JS runtime type predicate that checks typeof, instanceof (Date, Timedelta), and symbol properties. No WASM-friendly primitive representation." + }, + { + "name": "isListLike", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks for JS iterable/array-like protocol using Symbol.iterator; JS-specific duck-typing check." + }, + { + "name": "isArrayLike", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks for JS .length property; pure JS duck-typing." + }, + { + "name": "isDictLike", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks for a plain JS object with key/value access; JS-specific pattern." + }, + { + "name": "isIterator", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks for the JS iterator protocol (Symbol.iterator + .next); JS-specific." + }, + { + "name": "isNumber", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "typeof check; trivial JS predicate, no WASM benefit." + }, + { + "name": "isBool", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "typeof check; trivial JS predicate." + }, + { + "name": "isStringValue", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "typeof check; trivial JS predicate." + }, + { + "name": "isFloat", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Number.isFinite check; trivial JS predicate." + }, + { + "name": "isInteger", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Number.isInteger check; trivial JS predicate." + }, + { + "name": "isBigInt", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "typeof check; trivial JS predicate." + }, + { + "name": "isRegExp", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "instanceof RegExp check; JS-specific." + }, + { + "name": "isReCompilable", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks whether a value can be used as a RegExp source; JS-specific." + }, + { + "name": "isMissing", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "null/undefined check; trivial JS predicate." + }, + { + "name": "isHashable", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks whether a value can be used as a JS Map key; JS-specific identity semantics." + }, + { + "name": "isDate", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "instanceof Date check; JS-specific." + }, + { + "name": "isNumericDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Checks whether a Dtype JS class instance represents a numeric kind; depends on JS class identity." + }, + { + "name": "isIntegerDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check; same reasoning as isNumericDtype." + }, + { + "name": "isSignedIntegerDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isUnsignedIntegerDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isFloatDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isBoolDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isStringDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isDatetimeDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isTimedeltaDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isCategoricalDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isObjectDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isComplexDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isExtensionArrayDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check using instanceof with ExtensionDtype." + }, + { + "name": "isPeriodDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "isIntervalDtype", + "module": "core/api_types", + "status": "ts-only-ineligible", + "reason": "Dtype JS class instance check." + }, + { + "name": "astypeSeries", + "module": "core/astype", + "status": "ts-only-ineligible", + "reason": "Type-casts a Series using a Dtype JS instance; depends on JS class hierarchy, optional custom converters (JS callbacks), and returns a new Series instance." + }, + { + "name": "astype", + "module": "core/astype", + "status": "ts-only-ineligible", + "reason": "Same as astypeSeries; additionally handles DataFrame column-wise coercions." + }, + { + "name": "castScalar", + "module": "core/astype", + "status": "ts-only-ineligible", + "reason": "Casts a single Scalar value to a target Dtype; the cast table is defined by JS Dtype class instances and includes JS Date and BigInt conversions." + }, + { + "name": "ExtensionDtype", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Abstract JS base class for the extension type system. Instances are registered in a runtime JS Map. No numeric kernel; purely a JS polymorphism mechanism." + }, + { + "name": "ExtensionArray", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Abstract JS base class for extension array types. Subclasses implement custom JS accessors. No WASM-accelerable kernel." + }, + { + "name": "registerExtensionDtype", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Registers a JS class constructor in a runtime registry Map; pure JS dynamic dispatch mechanism." + }, + { + "name": "constructExtensionDtypeFromString", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Looks up a registered ExtensionDtype constructor by string name and calls it; JS runtime dispatch." + }, + { + "name": "registerSeriesAccessor", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Installs a JS getter on the Series prototype; JS runtime prototype mutation." + }, + { + "name": "registerDataFrameAccessor", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Same as registerSeriesAccessor for DataFrame." + }, + { + "name": "registerIndexAccessor", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Same as registerSeriesAccessor for Index." + }, + { + "name": "getRegisteredAccessors", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Returns the current state of the accessor registry (a JS Map); pure JS runtime introspection." + }, + { + "name": "apiExtensions", + "module": "core/extensions", + "status": "ts-only-ineligible", + "reason": "Namespace object bundling the extension API; not a function, purely a JS namespace accessor." + } + ], + "summary": { + "total_core_entries": 121, + "rust_wasm": 6, + "ts_only_ineligible": 115, + "unclassified": 0, + "eligible_missing": 0 + } +}