Field notes · a three-day build
A clean-room rebuild of Punto Switcher's oldest trick — built in three days by watching what it does, never touching its code, then letting the benchmarks tell me where I'd got it wrong.
The problem
You go to write привіт, the Ukrainian layout isn't active, and the keys come out as ghbdsn. Or you mean hello and get руддщ. If you type in two languages, you do this a hundred times a day, and every time you have to stop, delete, switch, retype.
Punto Switcher — Yandex's long-running tool — made its name just fixing it for you: watch the keys, spot the gibberish, quietly retype it in the other layout. I wanted that for Ukrainian, without the tool that comes with it. So I built Upyr — Ukrainian for a revenant, the undead brought back, which is more or less what it does to your mangled text. Rust, clean-room, not a line shared with Punto.
Swapping the letters is the easy part — QWERTY and ЙЦУКЕН are fixed positional maps, a fifty-line lookup. The hard part is knowing when to swap them. Every finished word is a yes/no: leave it, or rewrite it? Rewrite a word the person actually meant and you've just mangled their text — and that's the failure that gets an app deleted. A missed fix is a shrug; a wrong fix is a betrayal. So the whole design bends toward one rule: when in doubt, do nothing.
How it shipped
The whole thing is right there in the commit log. Every node below is a real commit — and the garnet ones are the moments something clicked, or a benchmark caught me out.
The design, reverse-engineered from behaviour
Punto never says how it decides, so I watched what it does — keys in, corrections out — until the shape showed through. It's two layers, and Upyr copies both. A short table of hard rules gets the first say and can overrule anything. Everything else goes to a statistical model that's happy to say "I don't know" and stay quiet. That rule table is lifted straight from Punto's triggers.dat — my source file admits as much in the comments.
The two layers aren't doing the same job. The model is careful and won't guess. The rules are for the handful of cases I'm already certain about — including the short, common words the model is built to skip on purpose.
What the teardown was — and wasn't
Fair thing to ask of anything calling itself a teardown: what is Punto made of, and what cracked it open? Here's what's publicly documented — and an honest account of the method.
Punto Switcher is a Windows program (punto.exe) from Yandex. It picks the layout with a dictionary of several million words plus an "impossible-combination" rule — a Russian word can't begin with certain letters, so a run of them betrays the wrong layout — re-checked on every Space, Enter or Tab. Its state lives in a handful of binary, non-plaintext .dat files:
| File | Role |
|---|---|
Data/triggers.dat | the switching trigger rules — the file this project's source names as its model |
Data/ps.dat | the main language dictionary (the several-million-word list) |
Data/translit.dat | transliteration table |
User Data/replace.dat | autoreplace / abbreviation expansions |
User Data/user.dic | user exceptions dictionary |
diary.dat | the keystroke diary |
Public file layout, from third-party write-ups — not from inspecting Punto's code. The data files are encrypted binary; even the autoreplace list won't open in a text editor.
diary.dat — messenger chats, search queries, passwords in the clear — and the program keeps its own network connection, which is why independent write-ups treat it as a keylogger and some antivirus vendors flag the bundle as a PUA. Not an incidental risk: a shipped, documented feature.
Those .dat files are encrypted binary blobs. Upyr never cracked them — no disassembler, no unpacked ps.dat, no dictionary lifted from Yandex. The method was black-box: feed key sequences in, watch the corrections come out, and rebuild the architecture those files imply rather than their contents. That's the whole meaning of "clean-room" here — and it's why you won't find Punto's dictionaries anywhere in this project. What got reconstructed was the shape:
triggers.dat → a 12-rule trigger table written from scratch;ps.dat → not a big dictionary at all, but a 2.8 MiB signed n-gram model plus a 296-word stop-list — a deliberately different, statistical answer to the same question;diary.dat → nothing. On purpose. The one Punto feature Upyr most wanted to not have.Studying a program's public file layout and observable behaviour is a world away from copying its code — and keeping strictly to the former is what lets Upyr share nothing with Punto while still learning from it. Sources on Punto's internals are listed at the foot of the page.
The insight that reshaped the design
My first scorer asked the obvious question: "does ghbdsn look more English or more Ukrainian?" That's already the wrong question, and four characters showed me why.
Look at the string [ks, character by character and you see bracket-letter-letter-comma — punctuation-riddled junk you'd never touch. But it's an ordinary word. So I stopped scoring the letters on screen and started scoring the keys underneath them: Upyr tracks physical positions (the same names the browser uses in KeyboardEvent.code) and asks the model about the word those keys would make in the other layout. That one change is what lets [ks, → хліб land, while a genuine Jkmuf, → Ольга, still keeps its comma.
The findings
Two benchmarks watch the behaviour, both with their word lists frozen by SHA-256 so the numbers are reproducible instead of a vibe. The headline figures:
Precision only tells me I'm not breaking good text. It says nothing about recall — how often I actually fix the broken text. So a second benchmark types out 1,200 Ukrainian and 1,000 English words on the wrong layout and runs the real app over them. Here's the default recall, broken down by how long the word is:
A flat 0% on short words — by design. With min_word_length = 4, the most frequent words in the language (не, як, на, що) are never touched. That is the single largest recall hole, and it is deliberate: three characters carry too little signal to clear a bar set high enough to be safe.
Switch to aggressive thresholds — no model change — and recall jumps. So the quarter of Ukrainian words the default misses isn't a ceiling; it's a precision-over-recall decision you can dial.
92% / 99% is sitting right there, whenever I decide the risk is worth it. The words I was "missing" weren't a wall I'd hit — they were a setting I'd chosen. A knob I can turn beats a model I'd have to retrain, every time.
Going in, the assumption was "bigger corpus → better model." The record says otherwise. The gain came from capacity and quantization tuning, not volume.
Throwing 10× more text at it made it worse. I'd never have caught that without a fixed benchmark to change one thing at a time against — which is the whole reason I wrote the benchmark before the "obvious" upgrade, not after it bit me. A separate 20,000-word Wikipedia holdout (deliberately a different kind of text than the training data) then came back 0 false corrections — and turned up four real Ukrainian words that collided with English ones, now nailed down as regression tests so they can't creep back.
Findings → adoption
A benchmark that doesn't change the code is just a nice number. Each of these actually did — here's the commit for each.
| What the benchmark exposed | What changed, and where |
|---|---|
| A character model can't read physical punctuation keys as letters ([ks, = хліб) | Scoring moved to layout-independent physical-key space; the scorer measures the intended candidate.fix: recognize mistyped Ukrainian bracket words · fix: honor Ukrainian physical punctuation keys |
| Proper names with trailing punctuation were being mangled (Jkmuf,) | A punctuation-preserving candidate is scored first, then the plain conversion.fix: handle names and terminal punctuation |
| Default recall is a flat 0% on short words — the most frequent words in the language | A deterministic trigger layer gives high-certainty short sequences a path around the model's confidence floor.perf + trigger layer + recall benchmark |
| The clean holdout surfaced four native Ukrainian collisions (incl. дупу vs physical lege) | The punctuation relaxation was made one-directional; ambiguous pairs now demand an extra half-margin. All four are pinned as regression cases.perf + trigger layer + recall benchmark |
| A naive 10× corpus scored worse (88/90) | Selection tuned capacity + quantized strength, not volume — 1M / 1.75× / 32 reached 90/90 with zero clean-holdout errors.feat: train compact language n-gram model |
Why this implementation is better — where it is
Let me be fair: Punto is a twenty-year-old product that does far more than this, and it shipped the trick long before I did. I'm making a narrow claim — for the English ↔ Ukrainian job, on the rows below, my version is the stronger one. And every Upyr cell is something you can go and check yourself, which is the whole point.
| Dimension | Punto Switcher | Upyr |
|---|---|---|
| Languages | Russian-first | EN ↔ UK first-class, incl. the Ukrainian punctuation row [];'\,./ |
| Platforms | Windows only | macOS primary; Windows / Linux preview; and the same engine in the browser via WASM |
| Runtime privacy | Closed; ships a keystroke "diary" that records typed text and uploads to Yandex | No telemetry, no HTTP client in the runtime — enforced by a check_privacy.py gate in CI |
| Memory safety | Native, pre-Rust era | Rust throughout |
| Correctness evidence | None published | Precision 1.000 on 191 boundary cases; 0 / 20,000 clean holdout; corpora pinned by SHA-256, one-command reproducible |
| Supply chain | Proprietary binary | MIT; CycloneDX SBOM, keyless Cosign signatures, SHA256SUMS, provenance attestations |
| The model | Opaque | 173,964-record signed n-gram, ~2.8 MiB, binary-search lookup, fully documented |
The honest scope: this is "better on these axes for this language pair," not "better product." Feature breadth, language coverage, and two decades of edge-case hardening still belong to Punto.
What this is, and isn't