Fuzzy search over records — a behavioural Python port of fuse.js.
Project description
pyfuse
A Python port of fuse.js, the fuzzy-search library, originally built for Port Mortem 2026 (Track H, Open Pair: TypeScript → Python). The main goal was behavioural equivalence with the original, not a lookalike API.
What it is
fuse.js isn't a string-similarity function. It's a small search engine: you
give it a list of records, it gives you ranked matches, and it tolerates typos
along the way. Under the hood that's a modified Bitap scan plus weighted keys,
nested-path lookups, extended-query operators (=exact, ^prefix,
!inverse, and friends), $and/$or composition, token search with IDF
ranking, and a scoring pass to tie it together.
This is a real re-implementation of that engine in Python, not a wrapper. The package has zero runtime dependencies and there is no Node anywhere in it. fuse.js does show up in this repo, but only as a test oracle — something to diff against.
Why Python needs this
Python's fuzzy-matching story is good if you're comparing two strings.
rapidfuzz and thefuzz are fast and well maintained. It falls apart the
moment you want to search a collection: weighted fields, nested paths, query
operators, relevance ranking. At that point your options are to write scoring
by hand or to stand up Elasticsearch, and neither is proportionate to "find the
right row in these 5,000 records."
fuse.js is exactly the missing middle, and it only runs in JavaScript — an ecosystem that most data and backend work has already left. So the capability exists, it's just on the wrong side of a language boundary. Moving it is the whole point of this port.
Install
pip install pyfusejs # the distribution
from pyfuse import Fuse # the import
The two names differ because pyfuse was already registered on PyPI. This is
ordinary — python-dateutil imports as dateutil, beautifulsoup4 as bs4 —
but it is worth stating once rather than leaving you to discover it.
Python 3.10+. No runtime dependencies: pyfusejs is the only thing that lands
in your environment.
Quick start
from pyfuse import Fuse
books = [
{"title": "Old Man's War", "author": "John Scalzi"},
{"title": "The Lock Artist", "author": "Steve Hamilton"},
]
fuse = Fuse(books, {"keys": ["title", "author"], "include_score": True})
for result in fuse.search("old man"):
print(result.item["title"], result.score)
Options are snake_case the way Python expects. The camelCase spellings are accepted too, so a config file shared with a JS codebase works as-is.
Try it without installing anything
The port is pure Python with zero dependencies, which means it runs unmodified
in the browser under Pyodide. That page installs the actual wheel — the same
one you'd pip install — and searches with it. Type a query, misspell it on
purpose, drag the threshold, edit the corpus. Nothing is sent anywhere; it all
runs in your tab.
There's also a guided tour in the terminal:
just demo # eight scenes, each printing the code it runs
just demo --interactive # ...then a live search prompt
just playground # serve the browser playground locally
Worked examples
Three scripts in examples/ that use the port to solve a real
problem rather than to show off an API. Each documents the option choices it
made and why, and the numbers in the commentary were measured, not guessed.
| What it does | |
|---|---|
ticket_triage.py |
Routes messy free-text support tickets to a runbook, with cutoffs for auto-route / suggest / escalate. Shows why long queries need token search, and why IDF misleads on a small corpus. |
reconcile.py |
Fuzzy-joins two record sets after a migration drops the key — ACME CORPORATION LTD vs Acme Corp. Limited. Reports matched, needs-review, and missing in both directions. |
search_json.py |
A general CLI: fuzzy-search any JSON or JSONL file, with inferred or explicit keys, extended operators and highlighted output. |
python examples/ticket_triage.py
python examples/reconcile.py
python examples/search_json.py tests/original/test/fixtures/books.json "ste ham"
Build and run
One command:
just build # install + verify it imports
Or, if you'd rather not have Python locally:
docker build -t pyfuse . && docker run --rm pyfuse
The rest:
just test # the ported pytest suite (417 tests)
just compat # the ORIGINAL fuse.js vitest suite, run against this port
just diff # differential checks against the fuse.js oracle
just fuzz # 60s differential fuzz -> fuzz/log.txt
just bench # benchmark vs fuse.js -> bench/results.json
just check # ruff + mypy --strict
just unsafe # escape-hatch census
The original fuse.js suite runs against this port
285 of 297 pass (95.96%), with every test file byte-for-byte unmodified.
The suite is JavaScript, run by vitest, and the rules say don't touch it. The
way through turned out to be small: all 13 behavioural spec files import the
engine from the same specifier, '../dist/fuse.mjs'. Point a vitest alias at a
shim that speaks the fuse.js API and forwards every call to Python, and the
tests run unchanged without ever knowing what's underneath.
just compat # see compat/README.md for how it works
None of the 12 failures is a bug in the port. Ten of them hand fuse.js a
JavaScript function (sortFn, getFn, a callable tokenize, Fuse.use),
and a closure over a live JS heap cannot be serialised into Python at any
price. The other two are divergences this port chose on purpose and wrote down
(DECISIONS.md §13 and §19) — those tests failing is the
documentation being true. All twelve are named individually in
compat/README.md, and the run output is committed as
compat/results.txt.
What "equivalent" actually means here
Structure matches exactly. Result sets, ordering, match indices, keys, refIndex. A 60-second differential fuzz run against the live fuse.js oracle:
cases : 51,569 (859/s)
STRUCTURAL DIVERGENCES : 0
Scores agree to about 1e-13 relative, not to the bit. This one isn't a
shortcut, it's a wall. CPython and V8 ship different pow and log: CPython's
are correctly rounded, V8's are not, and on roughly 10% of the calls the
scorer makes they disagree by 1 ULP. Scoring does one pow per matched key, so
that error rides through the whole chain. Worst case measured across 45k fuzz
cases was 8.5e-14 relative.
And yes, that has one visible consequence. 1 ULP is enough to break an
exact score tie, which means the tie-break fires in one engine and not the
other, and the two return the same documents in a different order. Under a
limit, a different document can make the cut. It happened in 8 of 51,569
cases (0.016%). fuzz/log.txt reports it as its own category instead of
burying it in the score-noise bucket — I'd claimed earlier that ordering was
never affected, and the fuzz run is what proved me wrong.
The full write-up, including the ~200-line fdlibm Math.pow transcription that
got to 95.6% bit-exact and why chasing the last 4.4% was the wrong use of the
remaining time, is in DECISIONS.md.
Performance
fuse.js is about 13x faster. That was always going to happen: V8 JITs the Bitap inner loop and CPython interprets it. What this port buys you is reach and parity, not speed, and it would be dishonest to present it otherwise.
| pyfuse | fuse.js | |
|---|---|---|
| throughput | 7.0 searches/s | 92.8 searches/s |
| latency p50 | 114.9 ms | 9.0 ms |
| latency p99 | 637.0 ms | 49.8 ms |
| startup | 13.7 ms | 7.7 ms |
| peak RSS | 31.3 MiB | 53.7 MiB |
400 documents, 4 keys, 150 queries, both engines warmed up first. The method,
the confounders, and why you shouldn't read too much into the RSS number are
all in bench/methodology.md.
Engineering notes
- Zero runtime dependencies, same as fuse.js itself.
mypy --strictclean, and zerocast,type: ignore,eval/execor bareexceptanywhere insrc/.just unsafecounts them if you don't believe it.- 417 native tests, plus 285 of the original suite's 297. Some of the native
ones are property-based (
hypothesis) and run against the live oracle rather than against fixed expectations. - Anything that diverges is in DECISIONS.md with the evidence that led there.
Scope
Ported in full: the Bitap core, indexing and key weighting, scoring, extended search, logical queries, token search with IDF ranking. About 3,283 lines.
Left out: src/workers/*, the Web Worker and worker-thread plumbing. It's
JS-runtime machinery rather than search behaviour, and the reasoning is in
DECISIONS.md §17.
Attribution and license
Ported from fuse.js (krisk/Fuse), © Kirollos Risk, Apache-2.0. This port is Apache-2.0 as well. See NOTICE.
Pinned source commit: 45bac9f (tag v7.5.0).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyfusejs-7.5.0.tar.gz.
File metadata
- Download URL: pyfusejs-7.5.0.tar.gz
- Upload date:
- Size: 91.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f8765926dfacabba2279f53a89b5106ac36ae4def777a4380045bafc167417a8
|
|
| MD5 |
8ecb80a382d440e3c85fb3e5c1dfba44
|
|
| BLAKE2b-256 |
0fed01aab14b604b140c9d96711b604742ee64773eb421d73d3c228e2e0a4110
|
Provenance
The following attestation bundles were made for pyfusejs-7.5.0.tar.gz:
Publisher:
publish.yml on ayanbag/pyfuse
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyfusejs-7.5.0.tar.gz -
Subject digest:
f8765926dfacabba2279f53a89b5106ac36ae4def777a4380045bafc167417a8 - Sigstore transparency entry: 2334571566
- Sigstore integration time:
-
Permalink:
ayanbag/pyfuse@aa03aa182463573d1f84c4f1703f951cfb4c1b21 -
Branch / Tag:
refs/tags/v7.5.0 - Owner: https://github.com/ayanbag
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@aa03aa182463573d1f84c4f1703f951cfb4c1b21 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyfusejs-7.5.0-py3-none-any.whl.
File metadata
- Download URL: pyfusejs-7.5.0-py3-none-any.whl
- Upload date:
- Size: 72.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
273d635697e6ef7d63ded8b36d5f4509f2b5e3380129d0d86d26b4081bf7e2c5
|
|
| MD5 |
fee60b9c1a161fc6dc61034a5061c1ec
|
|
| BLAKE2b-256 |
a93610072a7fddc3a823c7bfee040161c2f1d3f7463bd715c8062236fb878859
|
Provenance
The following attestation bundles were made for pyfusejs-7.5.0-py3-none-any.whl:
Publisher:
publish.yml on ayanbag/pyfuse
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyfusejs-7.5.0-py3-none-any.whl -
Subject digest:
273d635697e6ef7d63ded8b36d5f4509f2b5e3380129d0d86d26b4081bf7e2c5 - Sigstore transparency entry: 2334571604
- Sigstore integration time:
-
Permalink:
ayanbag/pyfuse@aa03aa182463573d1f84c4f1703f951cfb4c1b21 -
Branch / Tag:
refs/tags/v7.5.0 - Owner: https://github.com/ayanbag
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@aa03aa182463573d1f84c4f1703f951cfb4c1b21 -
Trigger Event:
push
-
Statement type: