Field notes · a three-day build
Bringing dead text back to life
A clean-room rebuild of Punto Switcher's oldest trick — first shaped by behavior and benchmarks, then checked against the actual binaries to settle the rule tags Punto never documented.
The problem
You typed the right keys on the wrong layout
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
Three days, initial release to signed v0.2.0
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.
- Initial Upyr release — tray app, layout swap, macOS accessibility plumbing
- feat: train compact language n-gram model — the statistical scorer arrives
- fix: recognize mistyped Ukrainian bracket words
the moment the design turns: [ks, is not punctuation, it's хліб - fix: honor Ukrainian physical punctuation keys
scoring moves into layout-independent physical-key space
- fix: handle names and terminal punctuation
preserve a trailing comma: Jkmuf, → Ольга, - test: simulate multilingual typing streams — event-sequence coverage
- feat: extract core and add wasm delivery foundation
the engine becomes portable — same decision logic in the browser - release: harden Upyr 0.1.0 public preview — first tagged build, universal macOS bundle
- perf + trigger layer + recall benchmark
the findings get adopted: a trigger layer for the short-word blind spot, policy rules for the collisions the holdout found - ci(security): scanners, SBOM, and Cosign signing — CycloneDX SBOM, keyless signatures → v0.2.0
The design, reverse-engineered from behaviour
A two-layer decision, because one layer can't be both safe and complete
Punto never says how it decides, so the first Upyr build watched behavior — keys in, corrections out — until the shape showed through. Later, radare2 and Ghidra confirmed the deeper split: Punto's Mac build uses bit flags and string checks, while the Windows build walks a compiled rule automaton. Upyr keeps the portable lesson: 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.
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
What's actually inside Punto Switcher
Fair thing to ask of anything calling itself a teardown: what is Punto made of, and what cracked it open? The answer ended up being part public file layout, part binary work.
Punto Switcher is Yandex's long-running layout switcher, with Windows and macOS builds. The Windows executable (punto.exe) 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 |
The public file layout matches what the binaries load. The reverse-engineering pass confirmed the same data family on Mac and Windows, including ps.dat, triggers, transliteration tables, replace rules, exceptions, title/path filters, and the diary store.
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.
The part Ghidra settled: the A tag
The .dat files are still not part of Upyr, but ps.dat is no longer a mystery blob. The Windows loader around FUN_0041ea80 XOR-decodes it with 0xaa, stores decoded lines, and later feeds them into a trie-like automaton. The matcher functions FUN_004dfec0 and FUN_004e02f0 call a small tag predicate, FUN_004b16a0.
That predicate starts with the decisive branch: if the tag is A, return true. On Windows, A is a live any-position wildcard. On macOS, the string-rule parser sets the A bit and the checker never tests it. Same data, different behavior.
| Build | Engine shape | A tag result |
|---|---|---|
| macOS | bit flags plus prefix/suffix/substring string checks | parsed to 0x20, never tested → dead |
| Windows | compiled trie/automaton with per-node tag predicate | return true → live any-position wildcard |
This is the one finding Upyr adopted. Trigger patterns now support word, word*, *word, and *word*. The built-in table stays exact; the wildcard is available when a rule genuinely needs Punto's Windows-style "match anywhere" behavior.
What Upyr reconstructs is still the shape, not the proprietary content: triggers.dat becomes a small rule table written from scratch; ps.dat becomes a 2.8 MiB signed n-gram model plus a small stop-list; diary.dat becomes nothing at all. Reverse-engineering answered the semantics. The shipped engine remains independent.
The insight that reshaped the design
Model the physical keys, not the characters
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
What a deliberately harsh benchmark said
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:
Finding — the recall gap is a choice, not a limit
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.
The model is strong; the conservatism is policy
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.
And the one that overruled intuition: more data made it worse
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
Every finding changed a specific line of the product
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
Not more features. More things you can verify.
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 and macOS builds; proprietary engines with divergent rule semantics | 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
The limits, stated the way the benchmark states them
- Every result is a deterministic corpus sample, not a real-world incidence claim. "0 false corrections in 20,000 words" is a strong safety signal, not a promise about your actual typing.
- The boundary corpus evaluates the decision at a single word boundary (a Space). Navigation keys, mid-word layout changes, correction timing, and tracker resets are a separate, still-unbuilt benchmark layer.
- Default recall is deliberately incomplete. Short-frequent words are abstained on until a dictionary or a less conservative default is adopted.
- The current preview download is not yet Apple-notarized; the macOS bundle is ad-hoc signed, so Gatekeeper may block it.