Field notes · a three-day build

Bringing dead text back to life

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.

Rust · WASM EN ↔ UK signed n-gram precision 1.000
[ х
[ks,хліб
one key. two meanings. the whole product is choosing right.

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.

Day 1 · Jul 16The core insight lands
  • Initial Upyr releasetray app, layout swap, macOS accessibility plumbing
  • feat: train compact language n-gram modelthe 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
Day 2 · Jul 17Hardening & extraction
  • fix: handle names and terminal punctuation
    preserve a trailing comma: Jkmuf,Ольга,
  • test: simulate multilingual typing streamsevent-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 previewfirst tagged build, universal macOS bundle
Day 3 · Jul 18Measurement & supply chain
  • 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 signingCycloneDX 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 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.

On the ethics of the teardown No qualms here. Only Punto's observable behaviour was studied — key sequences in, corrections out — never its code; Upyr is clean-room and shares nothing with it. And there's a pointed irony in the subject: Punto Switcher ships a keystroke "diary" that records everything typed and uploads to Yandex — the textbook definition of the spyware problem Upyr's no-network runtime exists to refuse. Reverse-engineering the good idea out of a tool that logs your keystrokes felt less like a liberty than a correction.
finished word physical keys key tracker layout-independent KeyboardEvent.code space LAYER 1 triggers.dat 12 exact rules · deterministic override the model LAYER 2 signed n-gram 173,964 records · ~2.8 MiB abstains when unsure Correct → Keep a trigger can force either way

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? 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:

FileRole
Data/triggers.datthe switching trigger rules — the file this project's source names as its model
Data/ps.datthe main language dictionary (the several-million-word list)
Data/translit.dattransliteration table
User Data/replace.datautoreplace / abbreviation expansions
User Data/user.dicuser exceptions dictionary
diary.datthe 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.

The file worth staring at Punto's Diary (Дневник) feature logs every keystroke to 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.

So what was the "tooling"? There wasn't any.

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:

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

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.

# on a Ukrainian layout, these physical keys are LETTERS, not punctuation [ ] ; ' \ , . / х ї ж є ґ б ю . # so the keys [ k s , are not "bracket, two letters, comma" — [ks, хліб (bread)

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:

1.000
precision on the 191-case boundary corpus
0
false corrections in 20,000 clean holdout words
173,964
signed n-gram records · ~2.8 MiB
12
deterministic triggers, seed set

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:

10066330 0.0% 93.1% 98.5% ≤ 3 chars4–6 chars7+ chars default profile · Ukrainian typed on English layout · recall %

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.

10066330 75.1 92.3 94.3 99.4 UK → ENEN → UK recall %, whole-word correction · same model, two policies

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.

90898887 89 88 89 90 100K naive 1M +strength selected 1M exact boundary corrections out of 90 · model revisions, left to right

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 exposedWhat 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.

DimensionPunto SwitcherUpyr
LanguagesRussian-firstEN ↔ UK first-class, incl. the Ukrainian punctuation row [];'\,./
PlatformsWindows onlymacOS primary; Windows / Linux preview; and the same engine in the browser via WASM
Runtime privacyClosed; ships a keystroke "diary" that records typed text and uploads to YandexNo telemetry, no HTTP client in the runtime — enforced by a check_privacy.py gate in CI
Memory safetyNative, pre-Rust eraRust throughout
Correctness evidenceNone publishedPrecision 1.000 on 191 boundary cases; 0 / 20,000 clean holdout; corpora pinned by SHA-256, one-command reproducible
Supply chainProprietary binaryMIT; CycloneDX SBOM, keyless Cosign signatures, SHA256SUMS, provenance attestations
The modelOpaque173,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

What I took away The part worth reverse-engineering was never the keyboard map — that's public and it never changes. It was the judgement: hard rules for what you're sure of, a cautious model for everything else, both working on physical keys instead of the letters on screen, and a benchmark strict enough to prove me wrong twice. Punto shipped all of this twenty years ago and never had to explain it. Building it back by hand, the thing that surprised me most was how much of the work is teaching software when to keep its hands off.