lightning-levenshtein
v0.0.6
Published
Blazing-fast Levenshtein distance with bit-parallel Myers algorithm and early exits.
Maintainers
Readme
⚡ lightning-levenshtein
Fast Levenshtein distance in pure JavaScript. Compact default API for general-purpose edit distance, plus opt-in subpaths for maximum throughput and Unicode-width character tables.
Features
- Specialized kernels for very short strings
- Precompiled bit-parallel dispatch for short inputs
- Fixed-width Myers variants for medium inputs
- Generalized Myers fallback for large inputs
- Zero runtime dependencies
- Works in Node.js and browsers
Install
pnpm add lightning-levenshtein
# or
npm install lightning-levenshteinNode.js consumers require Node 18 or newer. Browser consumers can use a bundler-facing source entrypoint or an explicit /min bundle.
Quick start
import { distance, distanceMax, closest } from "lightning-levenshtein";
distance("kitten", "sitting"); // 3
distanceMax("kitten", "sitting", 2) > 2; // true (over threshold)
closest("kitten", ["kitchen", "sitting"]); // "kitchen"API
The package exposes four logical entrypoints, each with an ESM-source path for bundlers and a pre-built minified path for unbundled <script type="module"> use:
{
"exports": {
".": { "import": "./src/index.js", "default": "./dist/lightning-levenshtein.min.js" },
"./min": { "default": "./dist/lightning-levenshtein.min.js" },
"./v2": { "import": "./src/v2/index.js", "default": "./dist/lightning-levenshtein-v2.min.js" },
"./v2/min": { "default": "./dist/lightning-levenshtein-v2.min.js" },
"./unicode": { "import": "./src/unicode.js", "default": "./dist/lightning-levenshtein-unicode.min.js" },
"./unicode/min": { "default": "./dist/lightning-levenshtein-unicode.min.js" },
"./profiles": { "import": "./src/profiles.js", "default": "./dist/lightning-levenshtein-profiles.min.js" },
"./profiles/min": { "default": "./dist/lightning-levenshtein-profiles.min.js" }
}
}The ., ./v2, ./unicode, and ./profiles entrypoints route bundlers at ESM source so unused code can be tree-shaken. Explicit /min subpaths expose the pre-built Closure bundles for consumers that want them directly.
Default API
import { distance, distanceMax, closest } from "lightning-levenshtein";The stable core API. Bundlers receive ESM source; unbundled consumers can use lightning-levenshtein/min to point at the pre-built bundle directly.
distanceMax(a, b, maxDistance) returns the exact distance when it is within the effective threshold. When the threshold is exceeded, it returns a value greater than the threshold rather than a sentinel such as -1. A fractional threshold between 0 and 1 is interpreted relative to the original length of a and rounded up.
closest(str, candidates, maxDistance) returns the first best candidate within the threshold. Candidate order breaks equal-distance ties.
Max-throughput API
The /v2 subpath exposes the larger max-throughput runtime:
import { levenshteinLightning } from "lightning-levenshtein/v2";Bundlers receive src/v2/index.js; lightning-levenshtein/v2/min exposes the pre-built bundle. The v2 runtime uses more aggressive length-based dispatch, tiny-string fast paths, precompiled 32-bit kernels, fixed-width Myers variants, and a generalized large-input fallback. Its specialized kernels currently allocate 6 MiB of UTF-16 code-unit PEQ payload per module realm or worker. Choose it when throughput matters more than the extra JavaScript and memory footprint.
Unicode API
Full-width UTF-16 code-unit distance:
import { distanceUnicode } from "lightning-levenshtein/unicode";Use this path when your strings can contain code units above 255, such as many BMP characters, and you need consistent full-width behavior at every input length. It is intentionally pre-routed so the hot loop does not scan each call to select a character table. Use lightning-levenshtein/unicode/min for the pre-built bundle.
Profiles API
Create a reusable distance function with an explicit character-table width:
import { createDistance } from "lightning-levenshtein/profiles";
const distanceAscii = createDistance({ profile: "ascii" });
const distanceLatin1 = createDistance({
profile: "latin1",
outOfRange: "assume-valid"
});Available profiles are ascii, latin1, and codeUnit. The default outOfRange: "throw" policy validates the selected range. Use assume-valid only when inputs were validated or normalized before the call. lightning-levenshtein/profiles/min exposes the pre-built bundle.
Notes
Which one should I pick?
Use the default package entrypoint if you want the stable general-purpose API with the smallest production build:
import { distance, distanceMax, closest } from "lightning-levenshtein";Use the v2 subpath if you specifically want the specialized levenshteinLightning runtime and are comfortable with the larger payload:
import { levenshteinLightning } from "lightning-levenshtein/v2";Use the unicode subpath if you need consistent full-width UTF-16 code-unit behavior:
import { distanceUnicode } from "lightning-levenshtein/unicode";Use the profiles subpath when you want an isolated distance function with an explicit character range and predictable per-instance memory:
import { createDistance } from "lightning-levenshtein/profiles";
const distanceAscii = createDistance({ profile: "ascii" });
const distanceLatin1 = createDistance({
profile: "latin1",
outOfRange: "assume-valid"
});Profiles are ascii (0..127), latin1 (0..255), and codeUnit (0..65535). The default outOfRange policy is throw. Use assume-valid only when the application has already validated or normalized both strings; out-of-profile input under that policy has undefined results.
Character table strategy
The default API uses a 256-entry PEQ table through 64 code units, so those tiers assume ASCII/Latin-1-style input. Its long-string fallback uses two full-width PEQ lanes. The default therefore optimizes common short and medium inputs but does not promise one uniform character-table width across every tier.
The unicode subpath exposes distanceUnicode, which is backed by a full UTF-16 code-unit PEQ table at every tier. That path is intentionally separate so callers can opt into consistent wider character support without adding per-call detection to the hot path.
The design direction is:
- keep
distancefast and pre-routed for common ASCII/Latin-1 workloads - expose full-width UTF-16 code-unit behavior through the deliberate
/unicodesubpath - share kernel implementations by binding the PEQ table before entering the hot function
- avoid naive automatic routing that scans both strings on every call
The /profiles factory implements that direction without changing the default entrypoint. Each returned function owns its mutable tables and scratch state, so create one per worker or independent execution context and reuse it synchronously.
The repository design note Levenshtein Use Cases and Text Profiles covers real-world workloads, precise comparison units, current per-worker memory costs, and configurable-profile design.
PEQ memory inventory
Static typed-array payload currently scales with each worker or module realm:
| Entrypoint | PEQ payload per worker | 4 workers | 8 workers |
| --- | ---: | ---: | ---: |
| default | about 513 KiB | about 2 MiB | about 4 MiB |
| /unicode | 768 KiB | 3 MiB | 6 MiB |
| /v2 | 6 MiB | 24 MiB | 48 MiB |
Profile factories allocate three PEQ lanes per returned function: 1.5 KiB for ASCII, 3 KiB for Latin-1, or 768 KiB for full code-unit coverage.
These figures count PEQ typed-array payload, not total process memory, JavaScript objects, or retained scratch buffers. Module tables and profile instances are reused by synchronous calls; separate workers load separate state.
Dense integer sequences remain the preferred future shape for DNA, proteins, phonemes, transcript words, and custom alphabets because they can use a table sized to the encoded symbol range. That separate token API is planned, not currently published.
See the checked-in stable-core integration plan for the implemented API, validation policies, file scope, test matrix, worker benchmarks, and v2 deferral criteria. The technical reflection and benchmark-hardening sprint record the broader architectural assessment and next measurement work.
Dispatch strategy
The runtime selects the cheapest correct kernel for the current input size.
- 1–32 chars: precompiled bit-parallel kernels
- 33–64 chars: fixed-width Myers specialization
- 65–96 chars: fixed-width Myers specialization
- 97–128 chars: fixed-width Myers specialization
- 129–224 chars: generalized macro-block Myers dispatch
- 225–256 chars: fixed-width Myers specialization
- 257–512 chars: generalized macro-block Myers dispatch
- 513+ chars: large-input generalized Myers dispatch
This keeps tiny inputs fast without sacrificing larger-input performance.
Benchmark
The benchmark harness generates the same string pairs for every library at each tested length and seed.
Node v24.11.0 on win32 x64, 13th Gen Intel(R) Core(TM) i5-13600K. Results generated 2026-07-16.
Methodology:
- 500 random equal-length string pairs per test size
- 3 seeds:
1337,7331,20250321 - 500 ms measurement window per seed
- 3 warm-up rounds before timing
- alphabet:
A-Z,a-z,0-9 - reported table values: mean ops/ms across 3 seeds
Mean ops/ms:
| Test Target | N=1 | N=2 | N=4 | N=8 | N=16 | N=32 | N=64 | N=128 | N=256 | N=512 | N=1024 | |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| | lightning-levenshtein-v2 | 176959 | 87310 | 45325 | 29719 | 11565 | 6559 | 1582 | 557.3 | 136.1 | 34.14 | 10.22 | | lightning-levenshtein-v1 | 89792 | 59605 | 40893 | 25256 | 8702 | 5080 | 1211 | 419.8 | 123.0 | 33.25 | 8.580 | | fastest-levenshtein | 95025 | 73865 | 43340 | 22888 | 8073 | 4404 | 1024 | 288.2 | 77.55 | 19.27 | 4.968 | | js-levenshtein | 100407 | 86948 | 22572 | 10990 | 3226 | 915.9 | 238.8 | 60.73 | 15.77 | 4.014 | 1.004 | | leven | 73872 | 43095 | 21606 | 8014 | 1778 | 415.8 | 113.5 | 29.13 | 7.573 | 1.908 | 0.480 | | levenshtein-edit-distance | 116046 | 56325 | 24109 | 8368 | 1776 | 394.7 | 104.9 | 26.94 | 6.886 | 1.750 | 0.429 |
Relative throughput vs fastest-levenshtein. This chart normalizes fastest-levenshtein to 100% at each string length and shows every other library relative to that baseline. Use it for an apples-to-apples comparison against the package most people already know. Values above 100% mean faster than fastest-levenshtein; values below 100% mean slower.
Throughput across input sizes. Mean ops/sec shown on a log-scaled Y axis across the full tested range.
Relative throughput, lightning-levenshtein only. Same baseline as above, narrowed to just lightning-levenshtein for a clearer read.
Rank by input length. Where each library ranks at each tested string length. Useful because raw throughput can be noisy to read at a glance, while rank makes the ordering obvious. If a library is consistently ranked first across the range, you can see that immediately without squinting at the absolute numbers.
Results
lightning-levenshtein-v2records the highest mean throughput in this checked-in Node benchmark at every tested length.- Winning lengths:
N=1,N=2,N=4,N=8,N=16,N=32,N=64,N=128,N=256,N=512,N=1024. - At
N=1024, mean throughput is 10.22 ops/ms versus 4.968 ops/ms forfastest-levenshtein. - At
N=32, mean throughput is 6559 ops/ms versus 4404 ops/ms forfastest-levenshtein. - At
N=8, mean throughput is 29719 ops/ms versus 22888 ops/ms forfastest-levenshtein.
Reproducing the benchmark
pnpm run bench:packages:verify
pnpm run bench:packages:promotion:checkQualification measurements require a settled host and three separate raw repetitions. The checked-in aggregate must then be promoted explicitly before pnpm run bench:packages:render can update this section. See the package benchmark qualification workflow and evidence index.
Project layout
src/v2/
index.js
myers32-unrolledA.js
myers_64.js
myers_96.js
myers_128.js
myers_256.js
myers_x64.js
myers_x128.js
bench/bolt/
experimental and historical kernel variants
bench/packages/
run-bench.js
render-readme-table.js
render-readme-line-chart.js
render-readme-rank-chart.js
render-relative-bar-chart.js
render-readme-relative-fastest-chart.js
results.json
codegen/tools/
generateMyers32-A.cjs # owns the production v2 short-string table
generateMyers32-B.cjs # experimental comparison generatorRun pnpm run codegen:myers32:a after changing the production Myers32 generator. The command refreshes both the comparison artifact and src/v2/myers32-unrolledA.js; pnpm run codegen:check verifies that neither output has drifted.
License
Source-available under AGPL-3.0 with WATT3D Additional Terms. The project is not represented as OSI-approved open source because the additional terms restrict commercial and AI-training uses. See LICENSE, ADDITIONAL_TERMS.md, and the licensing position. The legal texts control; commercial use outside the public terms requires a separate WATT3D license. © WATT3D.
