← All posts

The phonological layer: ten models, three wrong turns, and one correction

2026-08-24 · by Ronald Tse

Introduction

Interscript has always been about deterministic transliteration: 289 authority-backed systems, one map format, byte-identical output from every runtime. But some conversions have no committee. Restoring the haraqat a scribe left out. Turning unwritten Thai into phonemes. Reading Urdu aloud. Those are learned — and for two years they lived in a research branch, promising and unshipped.

This August we shipped the phonological layer: ten neural models under the same discipline as the maps — one artifact format, checksums verified on every load, identical output from Ruby, Python, and TypeScript, and a measured number next to every claim. This post is the story of what shipped, what didn’t, and the two weeks of debugging that taught us our own decode was lying to us.

What shipped

The catalog now spans four languages and two tiers:

Model Task Measured Artifact

khm-latn-1.0

transliteration

CER 27.42

fp32, 1.3 GiB

urd-g2p-1.0

grapheme→phoneme

CER 14.77

fp32, 1.3 GiB

urd-diac-1.0

diacritization

CER 3.74

fp32, 1.3 GiB

heb-diac-1.0

diacritization

DER 29.0 greedy / 17.5 beam-4

fp32, parts

tha-g2p-base-1.0

grapheme→phoneme

PER 9.19 (teacher 4.43)

fp32, parts

fas-g2p-1.0

grapheme→phoneme

CER ≈1.6 — above published SOTA on SentenceBench

fp32, parts

tha-g2p-small-1.0

grapheme→phoneme

PER 2.85 greedy

int8, 246 MiB

tha-g2p-small-1.0-int4

same student, 4-bit

byte-identical decode

int4, 193 MiB

heb-diac-small-1.0

diacritization

DER 30.37 (teacher 24.79)

fp32, 1.3 GiB

Every student was distilled from a frozen, independently evaluated teacher, gated at a pre-agreed error budget before release. Every artifact is a zip — metadata, ONNX graphs, per-member SHA-256 — that any runtime can verify and serve. Models above GitHub’s 2 GiB asset cap ship as checksummed parts the runtimes reassemble transparently.

Wrong turn 1: the 30 MB tier that wouldn’t exist

The obvious request: make the client models small. We tried everything. Custom 33M and 70M byte-level students from scratch. Microkimi-style linear bridges that map teacher activations into the student’s geometry. Depth-pruning ByT5. The bridges helped structure (75.8 → 71.1 PER on Thai) but nothing rescued accuracy: a randomly initialized sub-100M byte model simply cannot generalize, and ByT5’s smallest pretrained rung (300M) has no useful prune below it — its quality lives in its width.

We re-tested the hypothesis on Arabic with clean labels and a pretrained backbone at the small rung: the from-scratch 33M student fits its training data perfectly and then emits nothing at inference — an immediate end-of-sequence, scoring the bare-text error constant. The conclusion, now measured in two task families: a pretrained backbone is non-negotiable. The client tier ships at ByT5-small, quantized — 246 MiB at int8, 193 MiB at int4, with the 4-bit drop costing 0.17 points of character error and a parity gate to prove it.

Wrong turn 2: the bug that looked like infrastructure

For two days, every Arabic training run reported its labels file "torn" — two to six valid pairs surviving out of eleven thousand, on a file that read perfectly from a laptop. We blamed volume replication, wrote retry loops, shipped labels inside container images. All of it was misdirection for three stacked bugs, each masking the next:

  1. str.splitlines() is not a line split — it fragments on code points that legitimately appear inside Arabic text, shredding healthy files mid-line.

  2. The platform’s file-transfer layers re-encode non-ASCII text from this workstation — our labels were double-encoded in transit, and the "corruption on the volume" was corruption at the door.

  3. A hardcoded 384-byte label filter silently discarded nearly every Arabic label, because Arabic trains on 1,450-byte windows.

The lesson generalizes: when a file "reads corrupt" remotely and clean locally, suspect the parser and the transport before the storage. Our labels now travel as gzip+base64 — pure ASCII, immune to every encoding layer — and are split on plain newlines.

The correction: our decode was lying

The deepest problem was the one we trusted most. Evaluation harnesses inherited beam-4 decoding from teacher-side convention; the runtimes ship greedy. When we finally ported beam search into the runtimes and measured both decodes on the shipped artifact, through the exact ONNX path users get, the numbers disagreed absurdly: the published 12.06% PER was really 2.85% — a 4× error — with nearly identical exact-match.

The cause is statistical, not mechanical. Distilled byte-level students have flat per-token distributions — the top prediction is barely above uniform. Greedy rides those tiny margins consistently. Beam search with length normalization systematically prefers long outputs on flat distributions; with raw scores, short ones. Either way, every sentence the model didn’t get exactly right is mangled, and edit distance explodes while exact-match barely moves.

So: greedy is the runtime protocol, greedy is what we publish, and the beam-4 figures stand only as measurements under that decode. The correction rippled through the whole provenance chain — results log, model metadata, the index — because a number you can’t trace to a protocol is a number you can’t trust.

The discipline

Everything above survives because of one contract: no number ships without a harness, and no harness without a public protocol. Teachers are frozen before distillation and never LLM-generated — language models hallucinate haraqat, and a poisoned teacher poisons every student. Students are gated against their teacher on the same test set. Release artifacts carry their own provenance, and the index refuses entries whose metrics don’t trace to the results log.

The two-layer stack is now complete: 289 deterministic maps for the conversions authorities have published, and a measured neural layer for the ones they haven’t. Same discipline, same checksums, same bytes out of every runtime.

Try it: pip install secrystSecryst::Model.load("tha-g2p-small-1.0") in Ruby, Model.load in Python, transliterateAsync in TypeScript. The catalog lives at interscript-ml; the measured story behind every number lives in its results log.