Two open models that turn receipts, mobile-money messages, bills and letters into clean JSON, in English and African languages, with a confidence score on every field.
A lot of the paperwork that runs African businesses never touches a PDF. It arrives as a mobile-money SMS, a photo of a market receipt in naira, a screenshot of a bank alert, or a tenancy notice in Pidgin or Hausa. Pulling the amount, the date and the name out of that by hand is slow, and general-purpose extraction models were trained mostly on Western invoices.
Today we’re releasing MIST-Extract 1.0, built for exactly these documents.
What it does
Give it a JSON template and a document. The document can be text, a photo or scan, or a screenshot. It fills in the template:
MIST-Extract-Flash-1.0, 2B parameters: about 10 documents a second on a single RTX A6000.
Both are full fine-tunes of Qwen3.5, released under Apache-2.0, and use the same template format as NuExtract, so existing templates work unchanged.
How it does
We tested both models against NuExtract3 (4B), the untuned Qwen3.5-4B, LiquidAI’s LFM2 Extract models and GLiNER2.5, on the same 2,016 held-out documents in 32 languages. Every model got the same input and the same scoring rules.
Field accuracy
MIST 4B
Flash 2B
NuExtract3
Qwen3.5-4B
All held-out documents
0.898
0.879
0.801
0.762
Receipt photos
1.000
0.999
0.925
0.738
African documents
0.931
0.912
0.919
0.890
Named entities, 21 African languages
0.728
0.701
0.393
0.432
The fields that matter most in these documents are dates, amounts and currencies:
Field type
MIST 4B
Flash 2B
NuExtract3
Dates
0.979
0.975
0.884
Amounts
0.984
0.970
0.964
Currency
0.975
0.975
0.966
On English documents of these kinds, MIST-Extract gets 91.8% of documents completely right, against 67.9% for NuExtract3. The gap is widest in languages that rarely appear in training data: in chiShona, Luo and isiXhosa, MIST-Extract-1.0 is 40 to 55 points ahead.
Confidence you can act on
A score is only useful if it means something. When MIST-Extract-1.0 says 0.66, it is right about 66% of the time; across held-out fields its calibration error is 0.008. On one receipt photo it misread the shop name, and told us so:
Then call it with any OpenAI client, passing your template as chat_template_kwargs. The model cards have full examples for text, images, instructions and per-field confidence.
MIST-Extract is built for African and English everyday documents. For general Western invoices and multi-page PDFs, a general-purpose extractor such as NuExtract3 is the better fit.
Meet PurpleMIST-Flash: a 7.9B decision model that matches DeepSeek-V4-Pro, a model about 200 times its size, across eight African languages. Its probabilities are about five times closer to the truth.
A decision model doesn’t write text. You give it a piece of state (a WhatsApp message, a support ticket, a call transcript, a form record) and a set of typed questions: which team should handle this?, is the customer asking for a refund?, how urgent is it? For every question it returns a probability for each option, in one forward pass, in milliseconds. It is the fastest way to triage, route, moderate and automate at scale. With TypeSafe’s Jev, more than a hundred open reproductions, and Ollama’s new /v1/systemone endpoint, it has become one of the most active corners of open AI.
Until now, almost all of it worked only in English. Today we’re changing that with three releases:
•PurpleMIST-Flash-1.0 (7.9B): our flagship decision model, for English and eight African languages.
•PurpleMIST-Mini-1.0 (1.9B): the same interface in a model small enough for almost any GPU.
•African Typed Decisions: to our knowledge, the first decision benchmark for African languages. It has 3,739 cases and 16,881 decisions in Amharic, Hausa, Igbo, Nigerian Pidgin, Somali, Swahili, Yorùbá and isiZulu, and an open leaderboard.
Frontier accuracy at a fraction of the size
On African Typed Decisions, PurpleMIST-Flash scores 0.668 accuracy, level with DeepSeek-V4-Pro (0.669), a 1.6-trillion-parameter model. Where it pulls ahead is the part that matters for a decision model: how much you can trust each probability.
Model
Size
English
8 African languages
KL ↓
Brier ↓
PurpleMIST-Flash-1.0
7.9B
0.720
0.668
0.214
0.115
DeepSeek-V4-Pro (prompted)
1.6T
0.720
0.669
1.125
0.199
Qwen3.8-Flash (prompted)
undisclosed
0.712
0.643
0.862
0.216
OpenDecider-small
4B
0.648
0.477
0.365
0.199
laya-multilingual
0.32B
0.348
0.322
5.053
0.545
Other open decision models collapse outside English: OpenDecider-small loses 0.171 accuracy. PurpleMIST-Flash loses just 0.053. It leads every open decision model in every one of the eight languages.
Strong in English too
•LocalLLaMA/typed-decisions: 0.720 accuracy, zero-shot. Its probability quality, KL 0.189 and Brier 0.098, ranks 2nd among the published zero-shot models, ahead of Liquid AI d1 and TypeSafe Jev.
•Jev Decision Index (37 public benchmarks, run with the maintainers’ own kit): ahead of 8 of the 13 models of its size. It is 4.8 points above their median, and above the median in language understanding (+6.4), knowledge and reasoning (+4.3), tools (+3.8) and retrieval (+1.4). We’ve submitted it for the index’s private evaluation.
Probabilities you can act on
A decision model is only as useful as its confidence. When it says 90%, it should be right about 90% of the time. Every PurpleMIST model is calibrated across domains: one temperature per question type, fitted on thousands of states it never saw in training, drawn evenly from eight different sources. The result is an expected calibration error of 0.049 across the eight African languages and 0.056 in English. So you can set a threshold once and trust it: auto-approve above 0.9, send to a human below.
Fast at any length
•Speed: a median of 63 ms per request on a single GPU, over 140,000 benchmark requests of every size.
•Long inputs: up to 65,536 tokens per pass. Long contracts, transcripts and documents are read whole, never truncated.
•Many questions: any number of them about a state share one pass, and any number of options per question works, because the model reads each option’s text rather than memorising labels.
How it works
PurpleMIST-Flash is built on the text model of Qwen3.5-9B with a purpose-built pointer head. The whole state and all its questions go in together, and each option is scored directly against the question it belongs to. It was trained on 300,000 real and synthetic decision states, including 86,134 decisions written natively in, or translated into, the eight African languages.
Start in minutes
import os, sys
from huggingface_hub import hf_hub_download
repo = "olaverse/PurpleMIST-Flash-1.0"
sys.path.insert(0, os.path.dirname(hf_hub_download(repo, "purplemist.py")))
from purplemist import Decider
d = Decider.from_pretrained(repo, device="cuda")
d.decide({"message": "Ẹ jọ̀ọ́, wọ́n ti yọ owó lẹ́ẹ̀mejì lórí káàdì mi. Mo fẹ́ kí ẹ dá owó mi padà."},
{"team": {"type": "choice", "instructions": "Which team should handle this?",
"criteria": {"billing": "payments and refunds", "delivery": "orders in transit"}},
"refund": {"type": "noul", "instructions": "The customer is asking for money back."}})
Or run it as a server. serve.py ships in each model repo and uses the same POST /v1/systemone request and answer format as Ollama’s decision endpoint, so code written for that API reads PurpleMIST’s answers the same way:
Flash runs on a single 24 GB GPU. PurpleMIST-Mini brings the same interface to 1.9B parameters. In English it beats models up to four times its size, including Bongard-mini (7.5B) and Jeff-Gemma4-E2B (4.6B), with a median latency of 46 ms. Both are open weights under Apache-2.0.
Benchmark your model
African Typed Decisions is open. Score your model on the all config, one request per case, and open a discussion with your numbers. We’ll add you to the leaderboard. If you build for Hausa, Yorùbá, Swahili or any of the other languages and want decision models that understand your users, we’d love to hear from you at olaverse.co.uk.
About the benchmark: references come from LLM teachers, and the African cases are machine-written and machine-checked. PurpleMIST was trained on separate data from the same pipeline. Full methodology is on the dataset card.
Diacritics carry meaning. In Yorùbá, ọkọ́ is a hoe, ọkọ̀ a vehicle and ọkọ a husband. In Arabic, the short vowels that distinguish kataba (he wrote) from kutiba (it was written) are usually not written at all. Text without its marks is ambiguous for readers, for text-to-speech, and for every model downstream.
diactag-1.0 restored diacritics in ten languages with an unusual guarantee: it cannot change your text. It never generates characters. It reads the input and, for each letter, classifies which marks belong on it, so the output is always the input with marks added. That matters more than it sounds: when we asked Claude Sonnet 4.5 only to add marks, it rewrote 26% of Yorùbá and 34% of Hausa sentences (from the diacnet-1.1 evaluation). The diactag-1.0 write-up covers the design in detail.
diactag-2.0 adds Arabic, keeps the guarantee, and keeps 1.0’s accuracy on the original ten languages: Yorùbá, Igbo, Hausa, Vietnamese, Polish, Turkish, Portuguese, Spanish, French and Italian. It is 37.9M parameters, ships a 38.6 MB int8 ONNX export, and restores a typical sentence in about 8 ms on a laptop CPU.
Fitting Arabic into a tagger built for Latin script
diactag factors every character into a base letter plus two labels: a SHAPE (letter identity, such as ẹ vs e, ơ vs o, ł vs l) and a TONE (tone, stress or accent). Arabic maps onto that cleanly once you decide what is spelling and what is a mark:
•Vowels, tanwīn and sukūn (fatḥa, ḍamma, kasra, -an/-un/-in, sukūn) become TONE classes.
•Shadda (consonant doubling) and dagger alif become SHAPE classes, so a letter can carry a shadda and a vowel at the same time.
•Hamza letters (أ إ آ ؤ ئ) are spelling, not diacritics. Unicode would normally decompose أ into alif plus a combining hamza; diactag-2.0 deliberately does not, so the model never adds or removes a hamza. If the input spells a hamza, it stays; if it doesn’t, the model won’t invent one.
•A legality mask per language rules out impossible combinations, such as a Yorùbá tone on an Arabic letter or an Arabic vowel on a Latin one.
The label space grew from spec 1.x to 2.0.0: 986 characters, 18 shape classes and 15 tone classes. The important detail is that every existing label kept its index. Instead of training from scratch, we copied diactag-1.0’s weights into the larger output layer unchanged, added fresh rows for Arabic, and continued training for 20,000 steps, about two hours on one A100. Measured side by side with the same scoring code, 2.0 matches 1.0 on the original languages (mean diacritic error rate 0.0130 against 0.0127), so adding a language cost nothing on the existing ones.
Two kinds of Arabic vowelling
Fully vowelled Arabic includes the grammatical case ending on almost every word’s last letter: it is required for teaching material, Qur’anic and classical text, and text-to-speech. But it is also the hardest part of the task, because it depends on syntax, and most modern Arabic that is vowelled at all leaves it off.
diactag-2.0 does both from one model:
from olaverse.nlp import Diacritizer
d = Diacritizer(model="diactag-2.0", lang="ar")
d.restore("ذهب الطالب إلى المدرسة في الصباح")
# 'ذَهَبَ الطَّالِبُ إلَى الْمَدْرَسَةِ فِي الصَّبَاحِ'
d.restore("ذهب الطالب إلى المدرسة في الصباح", case_endings=False)
# 'ذَهَب الطَّالِب إلَى الْمَدْرَسَة فِي الصَّبَاح'
case_endings=False drops the vowel, tanwīn or sukūn from each word’s final letter and keeps the shadda. On Classical Arabic it cuts the error rate from 0.059 to 0.038.
How it compares
We scored diactag-2.0 against four open Arabic diacritizers on the same sentences, counting Arabic letters only. Scoring is strict: an output that changes, adds or drops a letter counts the whole sentence as wrong, because in a real pipeline it is wrong.
System
Type
WikiNews (modern news)
SadeedDiac-25 benchmark
Sentences with altered letters
CATT-EO
character transformer
0.040
0.047
0.1%
diactag-2.0
character tagger, 37.9M
0.086
0.099
0.0%
Shakkala v3
BiLSTM
0.106
0.107
0.0%
Fine-Tashkeel
ByT5, generative
0.149
0.216
14.2%
Tashkeel-700M
LLM, generative
0.296
0.309
24.4%
On modern standard Arabic, diactag-2.0 ranks second of the five, behind CATT, at a fraction of the size of the generative systems, and it is one of the two that never touch the input. The two generative models alter the letters of one sentence in seven and one in four respectively, which is exactly the failure diactag was built to rule out. Modern Arabic is where diactag-2.0 has the most room to grow.
We audited our own benchmark
Releasing diactag-2.0 meant looking hard at diacbench, the benchmark we use for every diacritics model. Reading model errors closely, we were no longer sure every “error” was the model’s: some looked like errors in the references, such as missing underdots and wrong tones in Yorùbá, missing dotted vowels in Igbo, and Hausa kasar where the correct spelling is ƙasar.
So we ran an audit:
1.Two second opinions. DeepSeek-V4-Pro (a large LLM) and diactag-2.0 (a tagger) re-marked every sentence independently.
2.Flags. A word was flagged only when both systems agreed with each other and disagreed with the reference: 1,537 flags, mostly Yorùbá (6.9% of words), Igbo (2.4%) and Hausa (1.1%).
3.An independent judge. A third model, Claude Sonnet 5.5, saw each flagged word in its sentence and chose the reference form, the flagged form, both or neither, with a confidence. Only high-confidence choices of the flagged form became corrections: 609 of the 1,537.
4.A manual check. On 30 Yorùbá flags, all 12 accepted corrections matched a manual judgement; across about 40 accepted corrections checked in five languages, one was wrong and was reverted.
diacbench v2 is public, with every change listed in a changelog and the original v1 references kept in their own column. On v2, diactag-2.0’s Yorùbá error falls from 0.079 to 0.073 and Hausa from 0.0044 to 0.0025.
There is a catch, and the dataset card says it plainly: corrections were only proposed where the two re-marking systems agreed, so v2 cannot fix errors they share, and its gains favour systems that behave like them. That is why we report v1 and v2 side by side, and why we recommend everyone does.
Try it
pip install "olaverse[deeplearning]" # add [onnx] for the int8 ONNX backend
from olaverse.nlp import Diacritizer
d = Diacritizer(model="diactag-2.0", lang="yor")
d.restore("se eranko naa si gbo o?") # 'ṣé ẹranko náà sì gbọ́ ọ?'
Diacritizer(model="diactag-2.0").restore("Toi khong biet tieng Viet") # language detected: 'Tôi không biết tiếng Việt'
fast = Diacritizer(model="diactag-2.0", lang="yor", onnx=True) # int8 ONNX on CPU
text, details = d.restore("se eranko naa si gbo o?", return_details=True) # per-character confidence
The full training and inference code, the training notebook and the benchmark notebook are in the model repository, so it can be reproduced or fine-tuned.
If you need the highest accuracy on Yorùbá or the European languages and can run a larger model, see diacnet-2.0, our generative diacritizer released alongside it.
Diacritic restoration is easy to state. Given se eranko naa si gbo o, produce ṣé ẹranko náà sì gbọ́ ọ. Every character keeps its identity; some acquire marks.
The standard approach treats it as translation. Bare text in, marked text out, generated token by token. That is what diacnet did — ByT5 fine-tuned on 7 million sentences.
It works, mostly. But “mostly” hides something. On Hausa, only 94.7% of diacnet-1.1 outputs still stripped back to their input. The other 5.3% weren’t mis-accented — they were different text. Words dropped. Clauses rewritten. Punctuation invented. A model asked to add accents had quietly decided to edit.
That is not a tuning problem. A generative decoder can emit any token at any position, so nothing in the architecture prevents it. You can penalise it in training and it gets rarer, but it never becomes impossible — and “rare” is not a property you can put in a contract.
We wanted one where it is impossible.
Reframing: classify, don’t generate
The task has a hard structural constraint that generation throws away. The output has exactly as many characters as the input, in the same order, with the same base letters. Only the marks change.
So don’t generate. Classify. For each character, predict which diacritics it carries, and copy the base character through.
input s e r a n k o
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
╔═══════════════════════════════════════════════════╗
║ character transformer encoder ║
╚═══════════════════════════════════════════════════╝
│ │ │ │ │ │ │
SHAPE DOT · · · · · ·
TONE · · · ACUTE · · ·
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
output ṣ e r á n k o
└──────┴──────┴──────┴──────┴──────┴──────┘
base letters COPIED, never predicted
The base character is not an output of the network. There is no mechanism by which the model can change it, so strip(output) == strip(input) holds by construction — for a trained model, an untrained one, or the int8 export.
We test exactly that: the invariant suite runs against a randomly initialised model. If the guarantee only held for a good model, it would not be a guarantee.
The model
Twelve layers, 512 hidden, 37.6M parameters — against 580M for the seq2seq model it replaces.
graphemes [B,T] language id [B]
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ char emb │ │ lang emb │ 11th slot =
│ 403×512 │ │ 11×512 │ "unknown language"
└──────┬──────┘ └──────┬──────┘
│ │
└──────────────► + ◄───────────┘ additive: no <yor> token,
│ no position spent
▼
╔═══════════════════════════════════════╗
║ × 12 encoder block ║
║ ┌─────────────────────────────────┐ ║ bidirectional —
║ │ RMSNorm → MHA(8 heads, RoPE) │ ║ a diacritic depends on
║ │ + residual │ ║ what follows as much
║ │ RMSNorm → SwiGLU 512→1344→512 │ ║ as what precedes
║ │ + residual │ ║
║ └─────────────────────────────────┘ ║
╚═══════════════════╤═══════════════════╝
│ H [B, T, 512]
┌──────────┬────────┴────────┬───────────┐
▼ ▼ ▼ ▼
┌─────────┐┌─────────┐ ┌──────────┐┌──────────┐
│ SHAPE ││ TONE │ │ MLM ││ LID │
│ 512→15 ││ 512→8 │ │ 512→403 ││ 512→10 │
│per char ││per char │ │ tied emb ││mean-pool │
└────┬────┘└────┬────┘ └────┬─────┘└────┬─────┘
│ │ │ │
└────┬─────┘ training only language
▼
legality mask → argmax → compose(base, shape, tone)
Where the parameters go:
component
shape
params
character embedding
403 × 512
206k
language embedding
11 × 512
6k
12 × encoder block
attn 4·512² + FFN 3·512·1344
37.4M
SHAPE head
512 × 15
7.7k
TONE head
512 × 8
4.1k
LID head
512 × 10
5.1k
MLM head
tied to embedding
0
The task heads together are 12k parameters — 0.03% of the model. Nearly everything is shared encoder, which is why ten languages cost roughly the same as one.
loss = 1.0·CE(shape) + 1.0·CE(tone) + 0.3·CE(masked_char) + 0.05·CE(language)
SHAPE and TONE are the task. MLM is auxiliary and dropped at inference — it forces lexical knowledge into the encoder, which is what tone disambiguation needs. Its target is always the fully marked character; masking ú and accepting u would teach the encoder that bare vowels are valid Yorùbá, the exact failure being fixed. LID is nearly free and makes the API restore(text) instead of restore(text, lang).
Three choices that carry weight:
RoPE, not learned positions. Documents are processed with a sliding window, so the model sees offsets at inference it never saw in training. Learned absolute positions generalise badly to that; rotary ones don’t.
Language as an additive embedding. No <yor> prefix token — nothing to confuse with content, no position spent. During training the language embedding is dropped 12% of the time, giving a genuine “unknown language” mode, so being wrong about the language degrades gracefully instead of catastrophically.
Trained from scratch. We considered starting from an existing encoder, but its tokenizer was trained on diacritized text while our inputs are stripped — every subword boundary would be wrong in the direction that matters most.
Splitting shape from tone
The naive version assigns each character one label from a flat set: acute, grave, underdot, circumflex-plus-acute. That works for Spanish and falls apart on Vietnamese, where stacked marks produce ~60 classes, most of them rare.
Instead, factorize:
ẹ́ → e + DOT_BELOW + acute (Yorùbá)
ộ → o + CIRCUMFLEX + dot-below (Vietnamese)
ữ → u + HORN + tilde (Vietnamese)
ń → n + — + acute (Polish)
SHAPE is marks that change letter identity. TONE is the mark that changes pitch or stress. Two heads, two label sets, two loss terms.
The subtlety — and the reason it works — is that which marks belong to which slot is language-dependent:
U+0323 dot below → SHAPE in Yorùbá (ẹ and e are different letters)
→ TONE in Vietnamese (nặng, one of six tones)
U+0303 tilde → SHAPE in Spanish (ñ is a letter)
→ TONE in Vietnamese (ngã)
A flat label set cannot express that. It sees ọ and assigns one class, blind to the fact that the same codepoint plays different grammatical roles in the two languages.
Factorized, Vietnamese collapses to 5 shapes × 6 tones, every component seen thousands of times. Across all ten languages the final space is 15 shapes and 8 tones.
The bigger payoff is diagnostic. Yorùbá’s dominant failure is tone direction — the model knows a mark belongs there and picks the wrong one, writing pátápátá for pàtàpàtà. In a flat label space that is indistinguishable from any other error. Split, it gets its own head, its own confidence and its own metric:
checkpoint
Yorùbá DER
tone portion
10k steps
0.1306
87%
30k steps
0.1138
81%
120k steps
0.0836
83%
Four fifths of the remaining error, at every stage of training, is one failure mode. Aggregate DER would have shown a number going down and told us nothing about what to do next.
Graphemes, not codepoints
Yorùbá stacks marks: ọ̀ is o with an underdot and a grave. Unicode has no precomposed codepoint for that combination, so even after NFC normalisation it stays two codepoints. len("ọ̀rọ̀") is 5, not 3.
Iterating over the string misaligns every label after the first stacked character. Everything here operates on graphemes — a base codepoint plus any combining marks that follow it. A small detail that silently determines whether the hardest language in the set trains at all.
What the corpus was teaching us
This is the finding that changed the result more than any architectural decision.
Before training, we plotted diacritic density — the fraction of eligible characters actually carrying a mark — per language. Nine of ten gave a single hump. Yorùbá gave two:
count
│ ▁▄██▇▅▃▂▁
│ ▂████████▃ ▁▂▃▃▂▁
│ ▃██████████▅▂ ▁▃██████▃▁
└──────┴───────────┴──────────────────┴──────────┴────► density
0.10 0.43
89% of sentences 11% of sentences
UNDER-MARKED correct
Correctly marked Yorùbá lands around 0.30–0.45 — nearly half its characters are vowels, most carry a high or low tone, plus underdots on ẹ, ọ and ṣ.
Which means the large mode is the broken one. Eighty-nine per cent of our Yorùbá training data was under-marked: real words with their tone marks simply omitted. And we were using it as ground truth. Every one of those sentences was teaching the model that bare vowels are correct.
That is diacnet-1.1‘s documented failure, sitting in a histogram. It also explains something we had noticed and shrugged at: diacnet-1.1 is worse than 1.0 on all three African languages. More data, trained longer, learned to omit tone more confidently.
The fix is not to delete the data
The obvious move is to drop the low-density mode. We didn’t, because under-marked Yorùbá is still Yorùbá — the words are real, the morphology is real. It is excellent data for learning the language. It is poisonous data for learning tone marks.
Since we already had two objectives on one encoder, we could separate them. Sentences below the detected threshold keep their masked-character targets and contribute no diacritic supervision at all:
density 0.458 ABOVE floor → 23 diacritic labels, 8 MLM labels
density 0.000 BELOW floor → 0 diacritic labels, 8 MLM labels
└── still teaches vocabulary
The MLM head trains on all 1.9M Yorùbá sentences. The tagger heads train on the clean ~205k. Same corpus, two different notions of what counts as a label.
Detecting it without eyeballing a threshold
We wanted this automatic. The first attempt used Otsu’s method, the standard image-binarisation algorithm — and it reported every language as bimodal.
Otsu returns a split for any distribution. On a single right-skewed hump — which is what density distributions look like — that split lands near the left edge, where “the peak below the cut” is two nearly-empty bins and every statistic derived from it is noise. Our test fixtures used symmetric Gaussians, which hid the failure completely.
The fix was to find peaks directly and measure how deep the trough between them falls, requiring both sides to hold at least 5% of the mass so a tail cannot masquerade as a mode. Measured that way the answer is not close:
yor ████████████████████████ 0.55 BIMODAL
pol ████ 0.10
tur ███ 0.07
ita ▊ 0.02
ibo 0.00
Igbo and Hausa are correctly left alone, and that matters: standard Igbo orthography omits tone by convention, and Hausa rarely writes it at all. Their low density is a property of the writing system, not a defect. No threshold should touch them.
What falls out of the architecture
Per-character classification gives you several things that are awkward or impossible in a generative setup.
Automatic language detection. A fourth head classifies the language from the mean-pooled encoder state. It costs almost nothing — DER 0.0132 with the language supplied, 0.0133 with it detected — and most integrations don’t reliably know the input language.
Per-character confidence. The joint shape × tone distribution gives a calibrated probability per character (temperature-fitted on validation, T = 1.14). Threshold it, and characters below are left exactly as the user typed them:
threshold
coverage
DER on committed
0.00
100.0%
0.0132
0.90
97.1%
0.0039
0.99
91.9%
0.0008
At 0.90: 97% of characters restored at 99.6% accuracy, the rest flagged. This is the number to put in front of a customer, because it answers the question they have. A wrong tone mark changes meaning; a missing one is merely incomplete. The threshold is per-request, so a CMS pre-fill and a legal pipeline sit at different points on the same curve.
Legality masking. For each (language, base character) we record which (shape, tone) pairs were ever observed and mask the rest to −∞ at decode time. No acute on Turkish vowels, no Vietnamese horn on Polish. An interesting result: turning it off changes DER by zero to four decimal places. The model learned the orthographies well enough that the constraint never binds. We keep it as a guarantee for out-of-distribution input — it costs nothing.
Protected spans. URLs, emails, @handles, inline code and CONSTANT_NAMES pass through untouched, and were excluded from the training loss. An accent inside a URL is never correct, and it is the error a reader notices first.
Documents, not sentences. No sentence splitting. Overlapping windows are planned across the input and only the centre of each is trusted, so every character is predicted with context on both sides. The window plan is proved to partition the input exactly — 500 randomised cases in CI.
Yorùbá 0.2006 → 0.0836, a 58% reduction. Hausa 0.0593 → 0.0041, 93% — though much of the Hausa gain is the compliance guarantee rather than better modelling, since a large share of the old error was corruption rather than wrong accents.
37.6M parameters against 580M.
Against frontier LLMs
300 sentences per language, matching how the baselines were run.
lang
diactag-1.0
Claude Sonnet 4.5
GPT-4o-mini
yor
0.0933
0.1913
0.2811
ibo
0.0110
0.0427
0.1277
hau
0.0041
0.0178
0.1432
vie
0.0166
0.0107
0.0399
fra
0.0014
0.0052
0.0023
Best on 7 of 10. Vietnamese and Portuguese genuinely lose.
Those LLM numbers deserve a footnote, because they are the charitable ones. They come from a harness that checks whether the output still strips back to the input and throws it away when it doesn’t. Raw, against the floor of copying the input unchanged:
lang
copy-input floor
Claude, raw
hau
0.0236
0.3509
ibo
0.1171
0.2261
fra
0.0471
0.1171
On three languages, a frontier model makes the text worse than doing nothing at all. Hausa is already 97.6% correct if you leave it alone; Claude returns it 35% wrong.
That fallback harness is our architecture, reimplemented externally and imperfectly. The difference is that here there is nothing to discard.
Deployment
backend
chars/s
p50
size
DER
PyTorch CPU
105
591 ms
150 MB
0.0105
ONNX fp32
202
286 ms
150.9 MB
0.0105
ONNX int8
244
200 ms
38.3 MB
0.0108
Three times faster and four times smaller for +0.03pp DER. Compliance stays 1.0000 under quantisation — the guarantee is architectural, not a property of a particular numeric precision.
A 38MB artifact at 244 characters per second on one CPU core takes the GPU off the serving bill entirely.
One honest caveat: p95 is 788–1099 ms across all backends while p50 varies 3×. The tail is length-driven — long sentences need more windows — not backend-driven. Quantisation doesn’t fix it.
Reproducing it
pip install olaverse[deeplearning]
from olaverse.nlp import Diacritizer
d = Diacritizer(model="diactag-1.0", lang="yo")
d.restore("se eranko naa si gbo o?")
# → 'ṣé ẹranko náà sì gbọ́ ọ?'
Data. 7.0M sentences from a web + Wikipedia corpus, plus 131k from a Q&A passage set — the only non-encyclopedic register in the mix — gated on diacritic density and deduplicated against the eval splits by stripped-form hash.
Two filters worth copying if you build something similar. Script filtering: all ten languages are Latin, but Hausa Wikipedia carries Ajami and Yorùbá articles carry Korean and Devanagari names; those contribute nothing but identity labels. Cross-language filtering: a Vietnamese name inside a Turkish sentence factorizes into composite classes that exist in no language, because Turkish declares no tone slot for those marks to occupy. Without both, the label space inflates from 15 shape classes to 99, most of them junk competing in the same softmax.
Training. AdamW, lr 3e-4 with 2000 warmup steps and cosine decay to 5%, weight_decay 0.05, batch 128 × 2 accumulation = 256, seq len 256, bf16, 120k steps. About 13 hours on a single A100.
Language balancing. Temperature sampling at α=0.5. At α=1 Yorùbá and Igbo would be 54% of the mix; at α=0 Hausa’s 400k sentences get seen 5× per Yorùbá epoch and overfit.
Augmentation.partial_mark_prob=0.30 with keep range [0.05, 0.9], case perturbation (2% upper, 3% lower, 1% title), 3% protected-span injection, mlm_prob=0.15, lang_dropout=0.12.
Evaluation. Report shape_DER and tone_DER separately, and compliance as a hard assertion rather than a metric. If compliance is not exactly 1.0, the architecture is broken and nothing else in the table means anything.
What it still gets wrong
Yorùbá. DER 0.0836, 83% of it tone direction. Sentence-level exact match is 0.084 — 92 of every 100 Yorùbá sentences contain at least one wrong mark. A harsher framing than DER, and a truer one.
Igbo and Hausa tone numbers are not achievements.tone_DER of 0.0013 and 0.0001 looks superb and means little, because those orthographies barely write tone. Nothing to learn, nothing to get wrong.
No typo correction. The architecture cannot insert or delete characters, so it cannot fix Ile → Ilé and teh → the in one pass. That is the price of the guarantee, and we think it is the right trade — a model that reliably does one thing beats one that sometimes does two. The per-character confidence is a natural trigger for a separate corrector.
Dense input degrades. The Polish pangram Zażółć gęślą jaźń has density 0.565 against a Polish median of 0.068 — nine times denser than real Polish — and the model misses six characters, despite a Polish DER of 0.0022.
Some errors are irreducible.Viaggio and Viaggiò are both valid Italian and the stripped form contains no information distinguishing them. Every language has a nonzero floor.
Three things worth taking away
Plot your data before you train. The bimodal Yorùbá distribution took ten minutes to find and changed the outcome more than any modelling decision. We nearly skipped it.
Make your metric decompose along your failure modes. Aggregate DER would have gone down and taught us nothing. Splitting shape from tone told us, at every checkpoint, that four fifths of the problem was one thing — and that the other nine languages had already converged.
Put your guarantees in the architecture, not the loss function. A constraint the model can violate is one you have to monitor, apologise for, and wrap in a fallback. A constraint it cannot violate is one you can sell.
A 30.9M-parameter encoder built from the ground up for Hausa, Yoruba, Igbo, and Nigerian Pidgin — with a tokenizer that beats GPT-4o’s on all four.
Most “African language support” in NLP is an afterthought: a massive multilingual model where Yoruba got a sliver of the training budget and the tokenizer shreds every word into fragments. Today we’re releasing something different — mist-encoder-base-ng, a compact encoder pretrained entirely from scratch on Nigerian text, open-sourced on Hugging Face under Apache 2.0.
It covers the four major languages of Nigeria — Hausa, Yoruba, Igbo, and Nigerian Pidgin — and it’s built to be a workhorse base: load the encoder body, attach a head, and fine-tune for classification, sentiment, language ID, or sentence embeddings.
from transformers import AutoModel, AutoTokenizer
tok = AutoTokenizer.from_pretrained("olaverse/mist-encoder-base-ng")
enc = AutoModel.from_pretrained("olaverse/mist-encoder-base-ng")
Why from scratch?
Fine-tuning an existing multilingual model inherits that model’s tokenizer — and for Nigerian languages, general-purpose tokenizers are quietly terrible. Every extra subword a tokenizer needs per word means less real signal fits in a fixed context window, slower inference, and weaker per-token representations.
So before training the model, we built the tokenizer: otk-bpe-50k, a unified byte-level BPE for Naija text with a ~50k vocabulary, NFC diacritic preservation, code-mixed English support, and 0% unknown tokens. The fertility numbers (tokens per word — lower is better) speak for themselves:
Tokenizer
Hausa
Yoruba
Igbo
Pidgin
otk-bpe-50k (ours)
1.231
1.296
1.416
1.249
GPT-4o (o200k)
1.589
1.687
1.807
1.304
AfroXLMR
1.604
2.277
2.570
1.401
Our tokenizer is more efficient on Nigerian text than the tokenizer behind GPT-4o — on all four languages. On Igbo, AfroXLMR needs over 2.5 tokens per word; ours needs 1.4.
The model
On top of that tokenizer sits a ModernBERT architecture — 6 layers, hidden size 384, 1024 max positions — pretrained with masked language modeling on ~480k carefully prepared chunks. The corpus combines FineWeb-2, the audited WURA corpus, and a fresh Nigerian Pidgin dataset, all cross-deduplicated (FineWeb-2 and WURA both descend from Common Crawl, so overlap was real), language-balanced so abundant Hausa didn’t drown out scarce Igbo and Pidgin, and chunked into windows so no text was wasted on truncation. Every source is attribution-only licensed, which is what makes the clean Apache 2.0 release possible.
The result is 30.9M parameters — and since the embedding table is about two-thirds of that, the transformer itself is only ~11M. For comparison: AfriBERTa is 126M, mBERT is 178M, XLM-R is 270M.
What a 30.9M specialist can do
On MasakhaNEWS topic classification (macro-F1), the model is genuinely competitive at a quarter of the baselines’ size:
Model
Params
Hausa
Yoruba
Igbo
Pidgin
mist-encoder-base-ng
30.9M
0.878
0.859
0.803
0.898
AfriBERTa
126M
0.924
0.921
0.914
0.991
mBERT
178M
0.806
0.886
0.805
0.967
It beats mBERT on Hausa and ties it on Igbo — with under a fifth of the parameters. That efficiency is the point: this is a model you can fine-tune on a laptop and deploy on-device or in low-resource environments where 126M+ models are impractical.
What it can’t do (yet) — and exactly why
We publish our weaknesses with the same rigor as our wins. Named-entity recognition is this model’s honest weak spot: it trails AfriBERTa and mBERT by roughly 10–20 entity-F1 on MasakhaNER 2.0, with Hausa NER lowest at 0.656.
And we didn’t just report the gap — we diagnosed it. The deficit is structural, not a tuning artifact: it persists across random seeds and labeling schemes. Two causes: the unified 50k tokenizer fragments entity words (person and place names) more than language-specific tokenizers — on Hausa NER text, ~61% of entity words split into multiple subwords versus ~21% for AfriBERTa — and at 30.9M parameters the model has less capacity to reassemble meaning from those fragments. If NER accuracy is critical to your application, use a larger model. If sentence-level understanding at minimal cost is what you need, this is your base.
The model card also lays out an evidence-backed v2 roadmap: more capacity (~50–80M), a less-fragmenting tokenizer for token-level tasks, and more Pidgin and Hausa data. Notably, longer pretraining isn’t on the list — eval loss plateaued by epoch 11, so we know that lever is spent.
Part of a bigger picture
mist-encoder-base-ng joins the Mist Embedder collection and sits alongside our LID models (which are fine-tuned classifiers) as complementary infrastructure: a from-scratch, Nigerian-first foundation others can build on. Two fine-tunes of it already exist on the Hub, and there’s a demo Space to try it.
Apache 2.0, free for commercial use. If you fine-tune it for a Nigerian-language task — sentiment, topic tagging, embeddings, anything — open a discussion on the model page and show us. That’s the whole point of releasing the base.
Olaverse Lab builds open-source AI with first-class support for underserved languages. Explore all our models at huggingface.co/olaverse.
A 300M-parameter open-source model that turns a user’s first message into a clean chat title — instantly, on almost any hardware.
Open any chat app you’ve built and look at the sidebar. If it’s full of entries like “New chat”, “Untitled”, or the raw first 40 characters of whatever the user typed, you already know the problem mist-tg-0.3b solves.
Today we’re releasing mist-tg-0.3b, a tiny title-generation model, open-sourced on Hugging Face under Apache 2.0. Give it a user’s first message and it returns a short, readable title — the kind you’d actually want in a conversation list.
"My laptop keeps freezing every time I open more than
five browser tabs, any idea why?"
↓
"Laptop Freezing Impact"
Why a dedicated model for something so small?
Because the obvious alternatives are all worse in practice. Truncating the first message gives ugly, uninformative titles. Calling a large LLM for every new conversation adds cost and latency to the single most frequent event in a chat product. A purpose-built small model hits the sweet spot: at ~300M parameters, mist-tg-0.3b runs comfortably on CPU, adds negligible cost per conversation, and does exactly one job well.
This is the same philosophy behind the rest of the Mist family: not every problem needs a frontier model. Some need a fast, cheap, reliable specialist.
How it works
mist-tg-0.3b is fine-tuned from google/byt5-small, a byte-level seq2seq T5. It was trained for one epoch on SupraLabs/chat-titles-filtered-115K, a filtered dataset of 115K real chat messages paired with titles.
The byte-level base turns out to matter. Because ByT5 operates on raw bytes rather than a fixed subword vocabulary, there’s no tokenizer to fight and no prompt template to remember — you pass the raw message string in, and a title comes out:
from transformers import AutoTokenizer, T5ForConditionalGeneration
tok = AutoTokenizer.from_pretrained("olaverse/mist-tg-0.3b")
model = T5ForConditionalGeneration.from_pretrained("olaverse/mist-tg-0.3b")
message = "How do I get my sourdough starter to rise faster in a cold kitchen?"
inputs = tok(message, return_tensors="pt", truncation=True, max_length=256)
output_ids = model.generate(**inputs, max_new_tokens=32)
print(tok.decode(output_ids[0], skip_special_tokens=True))
Inputs are capped at 256 bytes and outputs at 64 — which fits the task: a first message and a sidebar-length title.
An unexpected bonus: Latin-script transfer
The model was trained end-to-end on English, and English is where it’s production-ready. But byte-level modeling produced a genuinely interesting side effect: because Latin-script languages share bytes with English, same-language title extraction often just works for them — despite the model never being trained on non-English targets.
In an informal cross-language check across 25 languages (hand-written chat-style messages, qualitative judgment rather than a scored benchmark), roughly 17–19 of 25 languages produced usable same-language titles — almost all of them Latin-script: French, German, Dutch, Vietnamese, Indonesian, Yoruba, Igbo, Hausa, Swahili, Zulu, Xhosa, Shona, Somali, Afrikaans, and more.
Where it doesn’t work — and why we’re telling you
We believe model cards should say what a model can’t do as plainly as what it can. mist-tg-0.3b is not reliable for non-Latin scripts. All four non-Latin-script languages we tested — Japanese, Korean, Hindi, and Amharic — failed to produce same-language output, generating unrelated English text instead. The byte-level transfer trick depends on shared script; where the bytes diverge, so does the behavior. Fixing this properly would require training on non-English targets, which is a natural direction for a future version.
If your user base writes in CJK, Hangul, Devanagari, or Ethiopic scripts, this model isn’t the right tool yet. If your users write in English or Latin-script languages — including many African languages that rarely get this kind of support — it very likely is.
Training data: https://huggingface.co/datasets/SupraLabs/chat-titles-filtered-115K
The full Mist Generators collection: https://huggingface.co/collections/olaverse/mist-generators
It’s Apache 2.0 — free for commercial use, no strings. If you drop it into a chat product, we’d love to hear how it holds up on your real traffic. Open a discussion on the model page and tell us.
Olaverse Lab builds open-source AI with first-class support for underserved languages. Explore all our models at huggingface.co/olaverse, or read about mist-qg-1.5b, our multilingual question generator.
Diacritics carry real meaning. In Yorùbá, the same base letters can spell three different words depending on the tone marks: ogun (war), ògùn (medicine), ogún (twenty). In Vietnamese, Polish, and Turkish, dropping accents doesn’t just look wrong — it can make text ambiguous or outright unreadable. Yet diacritics are exactly what gets lost the moment text is typed on a non-native keyboard, scraped from the web, or transcribed casually.
diacnet-1.0, built by Olaverse, is a single model that restores diacritics across 10 languages spanning West Africa, Europe, and Southeast Asia — Yorùbá, Vietnamese, Igbo, Hausa, Polish, Turkish, Portuguese, Spanish, French, and Italian — without needing a separate model or vocabulary per language.
diacnet is fine-tuned from google/byt5-small, a byte-level sequence-to-sequence model. Working at the byte level rather than the word level means the same mechanism handles Yorùbá tone marks, Vietnamese combining diacritics, and Polish or Turkish special characters — no per-language vocabulary needed.
A language tag prefix (<yor>, <vie>, <pol>, etc.) tells the model which diacritic inventory to apply to a given input, so one joint model covers all 10 languages instead of ten separate ones.
Training is fully self-supervised: clean, already-diacritized text is the target, and diacritics are deterministically stripped to create the training input. No manual annotation required.
from transformers import AutoTokenizer, T5ForConditionalGeneration
tok = AutoTokenizer.from_pretrained("olaverse/diacnet-1.0")
model = T5ForConditionalGeneration.from_pretrained("olaverse/diacnet-1.0")
text = "<yor> se eranko naa si gbo o?"
inputs = tok(text, return_tensors="pt")
output_ids = model.generate(**inputs, max_new_tokens=256)
print(tok.decode(output_ids[0], skip_special_tokens=True))
# ṣé ẹranko náà sì gbọ́ ọ?
The benchmarks — tested on real external data
Early validation numbers looked great: a median character error rate (CER) of about 0.02 across most languages. But that number came from a held-out slice of the same training distribution — machine-generated text, not the messy real-world input diacnet would actually see in the wild.
So I re-ran evaluation on genuinely external test sets: Tatoeba for Spanish, French, Italian, Portuguese, Polish, Turkish, and Vietnamese; MasakhaNEWS for Igbo and Hausa; and the MENYO-20k test split for Yorùbá — roughly 1,000 diacritic-bearing sentences per language, stripped of diacritics as input, with the original text as ground truth.
Word error rate (%, lower is better), diacnet-1.0 vs. doing nothing:
Language
Copy-input (floor)
diacnet-1.0
Italian
14.16
0.62
French
18.19
1.45
Portuguese
19.47
1.61
Spanish
17.38
2.07
Polish
40.01
13.18
Turkish
42.76
19.49
Vietnamese
86.25
21.60
Igbo
34.11
8.00
Yorùbá
87.31
29.08
Hausa
7.80
8.27 ⚠️
The good
On 9 of 10 languages, diacnet beats the “do nothing” floor by a wide margin. Vietnamese goes from an unusable 86.3 WER to 21.6. Italian drops from 14.2 to 0.6. These are real, reproducible gains on text the model never saw during training.
The honest middle: Yorùbá
At 29.1 WER, Yorùbá is a big improvement over doing nothing (87.3) — but it’s still behind a dedicated Yorùbá-only specialist, Davlan/mt5_base_yoruba_adr, which scored 16.4 WER on an earlier run of the same test set, despite being roughly twice diacnet’s size.
The gap isn’t really about model capacity. It’s about ambiguity: the same base letters in Yorùbá can spell multiple valid words depending on tone, and resolving that often requires semantic context a byte-level model trained mostly on machine-generated text hasn’t seen enough of. Closing this gap means more human-authored Yorùbá training data — not just a bigger model.
The failure: Hausa
diacnet-1.0 currently does not work for Hausa. It scores slightly worse than doing nothing at all (8.3 vs. 7.8 WER). With only about 15 Hausa examples in the training set, the model never learned Hausa’s hooked-letter inventory (ɓ, ɗ, ƙ) and mostly just passes text through unchanged.
Don’t use diacnet-1.0 for Hausa diacritic restoration right now. Fixing this is the top priority for the next version.
Why publish the bad numbers too
It would have been easy to keep the old ~0.02 CER figure and call it a day — it’s not technically false, just measured on the wrong distribution. But a model that quietly fails on Hausa while claiming “near-perfect restoration” is worse than useless for anyone building on it in production. Anyone integrating this needs to know exactly where it helps and where it doesn’t.
The evaluation code and test sets are open too — olaverse/diacbench — so these numbers are reproducible and other models can be compared on the same footing.
What’s next: diacnet-1.1
Hausa — train on adequate Hausa data (Wura, MasakhaNEWS), targeting a clear win over the copy-input floor.
Yorùbá — incorporate human-authored training text (e.g. the MENYO-20k train split) to close the gap to the monolingual specialist.
Turkish/Vietnamese — remaining errors cluster in lexically ambiguous cases; larger-capacity variants are under evaluation.
If you have Hausa diacritic data, human-authored Yorùbá text, or want to stress-test the model on real-world messy input, get in touch — we’re actively looking for collaborators and data partners ahead of diacnet-1.1.