I built HaSeen to read an Arabic turn and say, for every word, whether it belongs to the dialect the voice agent was told to speak, and what it is when it does not. It runs offline on a CPU, with no LLM. The name is the idea: Egyptian says هيتم, MSA says سيتم; one letter decides the dialect.
dialect_drift red flag, a live guard for LiveKit — tried on a real Tuner workspace.See it in Tuner →Builds 1 and 2 are HaSeen 1 and HaSeen 2 in the repository's notes. I keep all three and their results, but the one I ship and stand behind is Build 3 (HaSeen 3.0.0): the only one measured on data that none of them had seen.
Given a text and a target dialect, label every word as conforming or deviating and say what a deviating word is instead. No cloud LLM at inference: the system must run air-gapped. Graded on architecture, productisation, ML judgment, code quality, reproducibility and communication.
I sent five questions within hours of receiving the task; Mohamed answered all five in 16 minutes. The "your call" answers became decisions I state in the README.
| My question | Answer | What I did |
|---|---|---|
| A word valid in both the target and MSA: conforming? | Yes | One naturalness bit per variety; a word deviates only when the target's bit is 0. Formal service words (الفاتورة، التحويل) conform everywhere. |
| Does morphology count (هـ، بـ، ما…ش)? | Your call | Positive evidence only: سوف and سـ+verb deviate in Egyptian, the هـ-future deviates outside Egypt; a word that only lacks a prefix conforms. |
| CPU or GPU? Self-hosted model? | GPU, yes | I still made it run on CPU (7 ms int8), because a guard before TTS runs on every turn. |
| Arabizi / code-switching? | Your call | Latin script, digits and names always conform and are not scored. |
| LLMs to build data offline? | Your call | Only for evaluation candidates and Claude-assisted labelling that I verified; training labels come from expert lexicons, rules and corpus evidence. |
Deviation F1 ≥ 0.85; p95 latency under 20 ms on CPU; one command from a fresh clone to a working CLI. PRD
The target is a dialect as a customer-service agent speaks it: semi-formal. I fixed this with 42 yes/no checks on real cases, as a native Egyptian speaker.
The task asked for at least one dialect. I shipped six targets, because Tuner's customers are spread across the Gulf, Egypt, the Levant and Iraq.
I worked with Claude Code as a research assistant in the terminal: it read papers and dataset cards for me, wrote and ran code, drove the cloud GPU sandboxes, and drafted labels and pages. Every direction, data source, judgment call and spend was mine. This is the split, with the evidence for it.
| Who | What |
|---|---|
| Yahya | Set the direction and the bar: "results first, quality is everything", "train only what we can make strong", "study everything before you train". Chose every target and every merge. |
| Yahya | Found and obtained data: emailed Prof. Mustafa Jarrar (Birzeit SinaLab), who replied the same day; filled the per-corpus forms with my university email and got seven corpora (Curras, Baladi, Nâbra, Lisan Iraqi / Yemeni / Sudanese / Libyan); obtained the full MADAR Lexicon and filled the MADAR Corpus-26 form; registered with CAMeL Lab; brought my own Egyptian speech corpus (MasriSpeech, 52.9K utterances); pointed to SADA speaker metadata and the QCRI datasets. |
| Yahya | Gave the native judgments: 42 register questions, 100 of 100 training sentences checked, and the decision that Gulf stays strict where I had no native Gulf annotator. |
| Yahya | Stopped what was not worth it: the hosted LLM router after one costly run, crawling sites that block AI crawlers or need a CAPTCHA, and a second training round that would have bought +0.01. |
| Claude | Read and summarised papers, dataset cards and licences; wrote the labeler, builders, training and release code; ran the GPU sandboxes; drafted labels and gold candidates for me to verify; built these pages. |
| Tuner | Answered the five scope questions in 16 minutes. |
Provenance on every row, split isolation, generation QA, teacher consistency, gold review, label-error mining, CheckList, eval integrity, reproducibility. PRD §8
Expected results and the model-selection rule written before every training round, and never edited after. Deviations from the rule are recorded as such.
Every GPU session logged with time and cost; I approved each one. compute-budget.md
Before choosing anything I read the papers, listed every dataset with its licence, and ran three small spikes (about $0.03) to check what really exists. These findings shaped everything after.
In the AMIYA shared task, a baseline LLM answered in Egyptian only 11% of the time when asked to. Sentence-level scores cannot say which word broke the dialect; a word-level detector can.
GPT-4 scores about 46 macro-F1 on dialect identification; fine-tuned MARBERT about 83. So the decider had to be a trained encoder, not a prompted LLM.
Published Gulf cue lists had precision 0.0 on multi-dialect data; 56% of dialect sentences are valid in several regions (NADI 2024 moved to multi-label). Context and multi-label output are required.
LinCE MSA–Egyptian was offline. So I had to build labelled data myself, from parallel corpora and expert lexicons.
"Deviating if the Egyptian analyzer cannot parse it" scored F1 0.16: the Egyptian database analyzes 99% of MSA words. Rules became evidence for labels, not the decider.
VARabi/ALDi-Token-DI already tags MSA vs dialect per token (MARBERT). It misses a single سوف inside Egyptian, so my data had to teach single-word slips.
On 30K Egyptian words: 1.86 sub-words per word and 2.3% unknown, against far worse for MSA-only models.
Found later, in my own work: a bench made of the same corpus family as training overstated Build 2 (0.932 → 0.819 on fresh speech). Build 3's test was built first, from new sources.
Notes: landscape · papers · data and models · repos and tools
I studied every source one by one: what it really contains, its licence, how to get it, and whether its text is human, model-transcribed or synthetic. Then I set one rule for all three builds.
Allowed in the test and in training: SADA (Saudi TV), SDAIA SCC, MGB-2, MGB-3, MasriSpeech, QCRI ArabicCulturalQA, Curras, Nâbra, Baladi, Lisan, MADAR.
Training only, never test: Lahgtna, Syrian podcasts, Masri 100h.
TTS from LLM scripts, synthetic sets, a mislabelled podcast set, Gulf tweets (mixed MSA / Libyan), ASLC-448 (scripted), QADI and WASIL (IDs only, no text), sites that block AI crawlers or need a CAPTCHA.
| Source | What it gave | How I got it |
|---|---|---|
| MADAR Lexicon + Corpus-26 | 1,045 concepts × 25 cities (47K forms); 2,000 sentences in 25 cities + MSA. The backbone of the labels and of the challenge test. | Full lexicon obtained by me; Corpus-26 by form. Research licence: never redistributed. |
| Birzeit SinaLab corpora | Palestinian, Lebanese, Syrian (10 local dialects), Yemeni 1.05M tokens, Iraqi, Sudanese, Libyan, with MSA lemmas. | Email to Prof. Jarrar (replied "Try this"), then 7 per-corpus forms with my university email. |
| SADA (Saudi TV) | 150K+ utterances from 64 shows with speaker dialect: Najdi, Hijazi, Khaliji. Speaker-disjoint test split. | Text mirror plus speaker metadata I pointed to. |
| MasriSpeech | 52.9K native Egyptian transcripts. | My own dataset. |
| QCRI MGB-2 / MGB-3, ArabicCulturalQA | Al Jazeera MSA, Egyptian broadcast, parallel native post-edited Egyptian / Levantine / Gulf / MSA / Maghrebi. | I asked to check all QCRI datasets; these three passed. |
| mo3jam | 31.6K crowd definitions from 24 countries; 1,782 entries held out as a lexical probe (my idea). | Crawled politely; votes used as a quality filter. |
| Alexandria, Levanti, Iraqi factlogic, Lahga, CALIMA, Dial2MSA … | Native and parallel text, dictionaries, MSA morphology. | Public, licences recorded per source. |
Both datasets are JSON Lines: one sentence per line, one list entry per word. Pick a file, step through real rows, and click a word to see every label it carries. The column table updates with the values of the row you are looking at.
| column | type | this row |
|---|
One note per source, with counts and verdicts: research/sources/01–15 · dataset overview · V3 data audit
I chose the architecture in four steps, each settled by evidence before training. Click a step.
Same encoder, three different tops. Switch models to see what each one removed and added; every block opens its details.
| Optimiser | AdamW, weight decay 0.01; encoder lr 4e-5, heads lr 1e-3; linear warm-up 6%, then linear decay; gradient clip 1.0 |
| Batch, length | 32 sentences, up to 128 sub-words; fp16 mixed precision on one NVIDIA L4 |
| Schedule | 1 epoch over 501,080 sentences = 15,659 steps (the dev curve is flat after about 13K steps) |
| Loss | masked binary cross-entropy on the 7 naturalness bits (unknown = weight 0, "not natural" weighted ×3) + other-dialect + non-Arabic + 0.2 × register |
| Calibration | temperature scaling on dev (LBFGS), then one threshold per target under a 1% false-alarm cap |
| Export | ONNX opset 17, dynamic int8 per-tensor quantisation (per-channel was tested and rejected: 81.6% agreement) |
train_v3.py · posthoc_v3.py · export_release.py · decision.py · nine-option study
Six small runs on one L4 (about $0.95) compared backbones, warm starts and heads, then two ways of labelling the same data, on 30 realistic service sentences.
| Labels | Backbone / head | synthetic F1 | realistic F1 |
|---|---|---|---|
| v1 (sentence-level "shared") | MARBERTv2 / linear | 0.841 | 0.609 |
| v1 | ALDi-Token-DI / linear | 0.836 | 0.627 |
| v1 | ALDi-Token-DI / pointer | 0.834 | 0.587 |
| v1 | CAMeLBERT-Mix / linear | 0.830 | 0.568 |
| v2 (corpus-level validity) | ALDi-Token-DI / linear | 0.871 | 0.800 |
| v2 | ALDi-Token-DI / pointer | 0.874 | 0.769 |
Changing only how shared words were labelled lifted realistic F1 from 0.627 to 0.800, while all backbones and heads sat within about one point. That lesson held for three days: every big gain afterwards also came from the labels. Pilot report
In every build I built the evaluation before the training data, from different sources, and froze it with sha256 hashes. The final test is the hardest of the three.
| Build | Test | Size | Lesson |
|---|---|---|---|
| 1 | 560 replies from 5 open LLM families, labelled with Claude's help and settled by my 42 register checks; a DeepSeek committee agreed on 97.7% of tokens but κ was 0.21, so it was recorded, not trusted. | 794 sentences | Real agent slips, but small and Egyptian-centred. |
| 2 | MADAR Corpus-26 swaps (expert concept forms in 25 cities) + native sentences. | 10,155 sentences | Exact and large, but in the same style as the training corpus. |
| 3 | Fresh human speech: Saudi TV (speaker-disjoint), Egyptian speech, Al Jazeera, Levantine and Iraqi corpora; challenge swaps; 200 LLM agent replies; 900 held-out dictionary entries. | 15,357 sentences | Built before any Build-3 training data. Every hold-out hash removed from training. |
Natural test sentences per target, by source. Every row is browsable in the dataset explorer (04 · Data sources) and on Hugging Face.
The MSA set held talk-show sentences labelled as clean MSA («مصر هي اللي صدرت…»). I read all 558 MGB-2 sentences and removed 70, by the same rule on dev and test, without looking at model output for test. script
natural (false alarms on native speech), challenge (recall on swapped words, 25% from concepts never trained on), agent and probe (real LLM replies and unseen dictionary words).
No training sentence shares a sentence or any normalized 6-gram with any test file (869 rows removed; a second pass removes 0). filter
Bench card · on Hugging Face: HaSeen-3-Bench · public part: data/eval_public
The research says an LLM teacher can be worse than the student on dialects, so training labels come from an evidence-ordered labeler that says "unknown" when it is not sure.
| Build | sentences | Families |
|---|---|---|
| 1 | 92K | native text, 158K aligned dialect↔MSA pairs, Lahga substitutions, MSA-future transforms, 197 hand-written service turns × 3 varieties |
| 2 | 344K | + MADAR 25-city swaps, Birzeit corpora, Alexandria, mo3jam; 9 bits per word |
| 3 | 501K | + SADA, MasriSpeech, MGB-3, SCC, ArabicCulturalQA; labeler reviewed word by word (below); 7 bits per word |
Pick a word. The labeler reads its evidence in order and writes one bit per variety: 1 natural, 0 not natural, ? unknown (no loss in training). Compare before and after my review.
A type-level diff of old and new labels on the 20,000 most frequent training words showed what each rule changed; two rules that hurt were rolled back before training.
Data card · labeler · on Hugging Face: HaSeen-3-Mix
I wrote the expected result before every round and kept every run, including the one that collapsed. Fixes came from dev data, never from the test.
The first pointer head collapsed: its three option vectors were nearly identical, so it learned one "naturalness" score. I gave each option a learned residual and it trained. Three seeds: Egyptian F1 0.966 ± 0.005, 74 behaviour tests, ECE 0.0005. Shipped the seed with the best dev score, not the best gold, to keep the gold clean.
Four runs in parallel on one L4, then three more after labeler fixes. The run with the best dev F1 failed the task example (أبغى), so I added a known-answer gate and shipped the only run that passed 54/54, recording the deviation from my own rule.
The training curve was flat, so more epochs would not help. I scored the labeler itself on dev: 0.815. The model had reached its teacher's ceiling.
I did not start the GPU until the data was right: labeler review, a clean rebuild (an earlier build was corrupted by two writers and discarded), the leakage filter, a label spot check, and the whole pipeline run end to end on a tiny model. Round 2: dev 0.876, known answers 71/73 (round 1 had failed at 69/73).
Rule written before the results: pass the known-answer gate, then the higher dev F1. Linear 0.876 vs pointer 0.871 → linear. Exported to int8, thresholds refit on the int8 outputs, test scored once.
On held-out training labels both rounds reach 0.98: the model learns its teacher almost perfectly either way. On the human-checked test dev, round 2 is 5 points higher. The difference came from better labels, not from training longer.
Pre-registration and decision records · selection · known answers
All six targets stay under 1% false alarms on native speech, and what HaSeen flags is right 91–97% of the time. MSA and Egyptian pass the F1 ≥ 0.90 gate; Saudi and Gulf are within 0.02 of it; Levantine and Iraqi miss about a quarter of swapped words. Dev 0.874, test 0.869: no sign of over-fitting.
Build 1 knows only three varieties, so part of its gap is by design; Build 2's thresholds were refit on this dev exactly as Build 3's.
200 replies of four open models asked to answer "in dialect T": Gulf F1 0.71, Saudi 0.64, Iraqi 0.51, Levantine 0.49; precision above recall; Iraqi false alarms on 3% of words.
900 held-out mo3jam entries: own-variety words kept 84%, other-variety words caught 57%.
Target Egyptian: أبغى → Saudi / Gulf, سوف → MSA, every other word conforms.
RESULTS.md · test JSON · Build 2 on this test · same test through the package
| Runtime (batch 1, 15-word sentence) | p50 | p95 | size |
|---|---|---|---|
| ONNX Runtime int8, CPU (4 threads) · what ships | 7.12 ms | 9.09 ms | 155 MB |
| ONNX Runtime fp32, CPU | 14.05 ms | 15.62 ms | 619 MB |
| PyTorch fp32, CPU | 29.82 ms | 32.46 ms | 619 MB |
| My laptop CPU, through the CLI | 5–6 ms | 155 MB | |
| Browser (onnxruntime-web, 1 thread) | ~80 ms | 155 MB |
int8 takes the same decision as fp32 on 99.41% of 59,733 dev words. report.json
uv tool install "git+https://github.com/NightPrinceY/haseen-v3"
haseen download # once, 156 MB; later runs are offline
haseen "أنا أبغى أروح الاجتماع بكرة بس سوف أتأخر شوية" -t egy
haseen serve --port 8000 # POST /detect {"text", "target"} · GET /health
docker run --rm --network none -p 8000:8000 nightprincey/haseen3:3.0.0
Fresh clone, empty cache: install, download, then the README example and the network-off run give the documented output.
The package reproduces the test numbers (mean 0.869); the browser demo matches Python on random parity cases; the Docker image is non-root, health-checked, and carries no token.
A dialect-adherence eval per turn, red flags when an agent drifts, a caller-dialect estimate from the transcript, and a check that a simulated caller speaks its dialect.
| When | Decision | Evidence |
|---|
| What | Where |
|---|---|
| Code, CLI, server, Dockerfile, tests, pipeline, results files | github.com/NightPrinceY/haseen-v3 (tag v3.0.0) |
| Model (int8 ONNX, default) / PyTorch weights | HaSeen-3-ONNX · HaSeen-3 |
| Test set / training data | HaSeen-3-Bench · HaSeen-3-Mix · public agent set: data/eval_public |
| Browser demo | HaSeen-3-Playground |
| Server image | docker.io/nightprincey/haseen3:3.0.0 |
| In Tuner: integration code, Tuner-style view, live-workspace screenshots | Tuner view · tuner.py · examples/tuner |
| Pre-registration, decisions, results | PREREG_V3.md · RESULTS.md |
| Research trail: plan, papers, data survey, architecture study, pilot, audits, spend log | research/ · docs/sources/ |
| Builds 1 and 2 kept as they were: guidelines, gold-building trail, all runs, results, reports | history/ |
| Every Build-3 run: config, training logs, dev curve, metrics, policy, known answers, export report | experiments/build3/ |
| The task and the scope questions | brief/ |
Tuner's evals are LLM judges, and its rules can test call metadata (call.metadata.<key>). So HaSeen plugs in with no change on Tuner's side: it checks every agent turn, writes the result into the call's metadata, and one rule turns a FAIL into a red flag. I tried it on my own Tuner workspace on 20 Oct.
call.metadata.haseen_verdict = FAIL → red flag dialect_drift), and five Arabic demo calls sent with haseen tuner demo_calls.json --send (HTTP 201 each).hallucination_detected is on all five because the demo agent's prompt has no menu or prices to ground against; it comes from the demo setup, not from HaSeen.
| Key | Example | Used by |
|---|---|---|
| haseen_verdict | FAIL | the red-flag rule |
| haseen_score | 3 | 1–5, the same scale as Tuner's SCORE evals |
| haseen_adherence | 0.959 | share of the agent's Arabic words that fit the target |
| haseen_drift_to | MSA, Gulf | contains rules → drift_to_* labels |
| haseen_words | سوف → MSA (use هـ + الفعل) | what to fix |
| haseen_report | one paragraph | an LLM eval input (Inputs Used › Metadata) |
| metadata.haseen (per turn) | {adherence, deviations[], latency_ms} | the per-turn view |
annotate_call(body, "egy") then send_call(body), or haseen tuner calls.json --send. Works for Custom API, Dograh and any bridge. Tried live.
DialectGuard checks each agent turn as it is spoken (about 5 ms on CPU) and keeps one metadata dict that the Tuner plugin reads when it sends the call. An on_drift hook can log, re-prompt or hand off.
One CPU container (nightprincey/haseen3) next to the analysis worker: a "Dialect adherence" voice metric and eval, like latency. No GPU, no external API.
src/haseen/tuner.py · examples/tuner · demo/tuner · demo conversations written for the test (made-up companies); every result on them is the model's.