Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions data/ara-tiny-labels-snapshot.jsonl.gz.b64

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions models/ara-diac/ara-diac-1.0.README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
# ara-diac-1.0

Arabic diacritization (adds haraqat / tashkeel). Byte-level seq2seq
(ByT5-base): the tokenizer is raw UTF-8 bytes (pad=0, EOS=1) — no
vocab files. IMF v1 artifact; format spec:
interscript/interscript-ml docs/imf-v1.md.

- decoder: kv greedy (plain fallback included in the zip)
- metrics: Total DER 2.5793% / Morphological DER 1.5317% — greedy
decode, SadeedDiac-25, windowed zero-skip at 1400 bytes, 1,200
paragraphs —
rababa/docs/RESULTS.md#r6-verdict-table-sadeeddiac-25-2026-08-21
- out-of-domain (WikiNews-2024, multi-ref): WER 19.82 / DER 12.46
- trained from: rababa train_arabic_r6.py run-006-morph —
morphological aux-task (plain + "TAG: "-prefixed iʿrāb stream from
qalsadi labels), init from r5
(rababa-checkpoints:/rababa_arabic_byt5/run-006-morph/best)
- license: BSD-3-Clause

## Inference contract

- Plain input → diacritized output. The morph "TAG: " stream NEVER
appears at inference (it is a training-time supervision format only).
- Greedy decoding is the reference path: beam-4 was probed and is flat
(Total DER 2.5588 vs 2.5793) — beam buys nothing here.
- For long inputs, split at word boundaries into ≤1400-byte windows
(zero-skip) and stitch; generation cap 2x window size.

## Usage

Ruby (secryst gem, the Ruby binding of interscript-ml):

```ruby
require "secryst"
translator = Secryst::Translator.new(model: "ara-diac-1.0")
translator.translate("مكتبة")
```

TypeScript (@interscript/ml):

```ts
import { loadModel } from "@interscript/ml";
const model = await loadModel("ara-diac-1.0");
await model.translate("مكتبة");
```

Python (interscript_ml):

```python
from interscript_ml import load_model
model = load_model("ara-diac-1.0")
model.translate("مكتبة")
```
26 changes: 26 additions & 0 deletions models/ara-diac/ara-diac-1.0.metadata.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
format: imf-v1
id: ara-diac-1.0
task: diacritization
source_script: Arab
target: Arab
tokenizer: bytes
opset: 14
decoder: kv
precision: fp32
license: BSD-3-Clause
trained_from: >-
rababa train_arabic_r6.py run-006-morph (morphological aux-task:
plain + "TAG: "-prefixed iʿrāb stream from qalsadi labels, init from
r5); checkpoint rababa-checkpoints:/rababa_arabic_byt5/run-006-morph/best
metrics:
- name: der_total_greedy
value: 2.5793
protocol: >-
greedy decode (the v1 runtime path); SadeedDiac-25, windowed
zero-skip at 1400 bytes, 1,200 paragraphs; ByT5-base r6
source: rababa/docs/RESULTS.md#r6-verdict-table-sadeeddiac-25-2026-08-21
- name: der_morph_greedy
value: 1.5317
protocol: >-
same harness; Morphological DER (word-final case endings only)
source: rababa/docs/RESULTS.md#r6-verdict-table-sadeeddiac-25-2026-08-21
45 changes: 45 additions & 0 deletions models/heb-diac/heb-diac-1.1.README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# heb-diac-1.1

Hebrew diacritization (adds nikud). Byte-level seq2seq (ByT5-base):
the tokenizer is raw UTF-8 bytes (pad=0, EOS=1) — no vocab files.
IMF v1 artifact; format spec: interscript/interscript-ml docs/imf-v1.md.

- decoder: kv greedy (plain fallback included in the zip)
- metrics: greedy DER 16.44% — the v1 runtime path — and beam=4 DER
16.43% (reference quality): greedy ≈ beam-4 for this model, unlike
s43 —
rababa/docs/RESULTS.md#hebrew-diacritization
- replaces heb-diac-1.0 (s43: greedy 29.0 / beam-4 17.46) — the
runtime path improves from 29.0% to 16.44% DER
- trained from: rababa train_hebrew_s46.py — phonikud curriculum
(1.5M machine-labeled knesset weak-pretrain + 73.8K hewiki garnish,
then gold fine-tune; weak stage supplies a nikud prior, gold sets
the ceiling)
(rababa-checkpoints:/rababa_hebrew/run-s46-phonikud-plus/run-002-gold-ft/best)
- license: BSD-3-Clause

## Usage

Ruby (secryst gem, the Ruby binding of interscript-ml):

```ruby
require "secryst"
translator = Secryst::Translator.new(model: "heb-diac-1.1")
translator.translate("שלום")
```

TypeScript (@interscript/ml):

```ts
import { loadModel } from "@interscript/ml";
const model = await loadModel("heb-diac-1.1");
await model.translate("שלום");
```

Python (interscript_ml):

```python
from interscript_ml import load_model
model = load_model("heb-diac-1.1")
model.translate("שלום")
```
28 changes: 28 additions & 0 deletions models/heb-diac/heb-diac-1.1.metadata.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
format: imf-v1
id: heb-diac-1.1
task: diacritization
source_script: Hebr
target: Hebr
tokenizer: bytes
opset: 14
decoder: kv
precision: fp32
license: BSD-3-Clause
trained_from: >-
rababa train_hebrew_s46.py — phonikud curriculum (1.5M knesset
weak-pretrain + 73.8K hewiki garnish, then gold FT); checkpoint
rababa-checkpoints:/rababa_hebrew/run-s46-phonikud-plus/run-002-gold-ft/best
metrics:
- name: der_greedy
value: 16.44
protocol: >-
greedy decode (the v1 runtime path); Nakdimon test split,
5,095 examples; ByT5-base s46
source: rababa/docs/RESULTS.md#hebrew-diacritization
- name: der_beam4
value: 16.43
protocol: >-
beam=4 standard decode (reference quality); Nakdimon test
split, 5,095 examples; ByT5-base s46. Greedy ≈ beam-4 for this
model — the runtime path loses nothing.
source: rababa/docs/RESULTS.md#hebrew-diacritization
59 changes: 59 additions & 0 deletions prep_arabic_imf_test.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
"""Prepare arabic-sadeed-imf/test.jsonl on rababa-datasets for ara-diac parity.

Reads sadeed-hf/test.txt (single-column diacritized lines), derives
undiacritized src, writes {src, tgt} jsonl next to the other IMF test
sets. One-shot; idempotent via DONE marker.

Usage:
modal run prep_arabic_imf_test.py
"""

from __future__ import annotations

import re
from pathlib import Path

import modal

datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True)

DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]")
N_TEST = 4000

image = modal.Image.debian_slim(python_version="3.11")

app = modal.App("prep-arabic-imf-test", image=image)


@app.function(timeout=10 * 60, volumes={"/datasets": datasets_volume})
def prep() -> dict:
import json

datasets_volume.reload()
out_dir = Path("/datasets/arabic-sadeed-imf")
marker = out_dir / "DONE"
if marker.exists():
return {"status": "already-done"}
out_dir.mkdir(parents=True, exist_ok=True)

src_path = Path("/datasets/sadeed-hf/test.txt")
rows = []
for line in src_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
src = DIACRITICS_RE.sub("", line).strip()
if src and 2 <= len(src) <= 1200:
rows.append({"src": src, "tgt": line})
if len(rows) >= N_TEST:
break
(out_dir / "test.jsonl").write_text(
"\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8")
marker.touch()
datasets_volume.commit()
return {"rows": len(rows)}


@app.local_entrypoint()
def main():
print(prep.remote())
83 changes: 62 additions & 21 deletions src/gpu/modal_distill.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,6 +130,7 @@
"max_len": 1450,
"label_beams": "1",
"out": "rababa_arabic_distill_tiny/run-004",
"labels_file": "/root/interscript-ml/data/ara-tiny-labels-snapshot.jsonl.gz.b64",
"mode": "sequence",
"note": "client tier (~30MB int8); r6 teacher (2.5793 DER); gate <= 3.07",
},
Expand DownExpand Up@@ -747,19 +748,56 @@ def collate(batch):
# already-labeled srcs are skipped, the rest are appended.
out_root = Path(out_root_vol) / spec["out"]
out_root.mkdir(parents=True, exist_ok=True)
teacher_labels_path = out_root / spec.get("labels_file", "teacher_labels.jsonl")
labels_file = spec.get("labels_file", "teacher_labels.jsonl")
if labels_file.endswith(".b64"):
# non-ASCII text is re-encoded somewhere in the modal
# transfer layers (image COPY and volume put both mojibake'd a
# UTF-8 jsonl to 3 parseable srcs); ship labels gzip+base64 and
# decode to plain container-local bytes
import base64
import gzip

teacher_labels_path = Path("/tmp/labels_decoded.jsonl")
if not teacher_labels_path.exists():
raw = gzip.decompress(
base64.b64decode(Path(labels_file).read_text(encoding="ascii"))
)
teacher_labels_path.write_bytes(raw)
print(f"[{spec_id}] decoded {len(raw)} label bytes", flush=True)
else:
teacher_labels_path = (
Path(labels_file)
if labels_file.startswith("/")
else out_root / labels_file
)

def read_label_srcs() -> set[str]:
# volume replicas can serve a stale view of a large file; retry
# and keep the best parse rather than relabeling from zero
import time as _time

best: set[str] = set()
for _attempt in range(3):
got: set[str] = set()
for line in teacher_labels_path.read_text(
encoding="utf-8", errors="ignore"
).split("\n"):
if line.strip():
try:
got.add(json.loads(line)["src"])
except (json.JSONDecodeError, KeyError):
continue
if len(got) > len(best):
best = got
_time.sleep(20)
return best

done: set[str] = set()
if spec.get("labels_complete") and teacher_labels_path.exists():
print(f"[{spec_id}] labels trusted complete", flush=True)
done = {s_ for s_, _ in train_ds.rows}
elif teacher_labels_path.exists():
for line in teacher_labels_path.read_text(encoding="utf-8").splitlines():
if line.strip():
try:
done.add(json.loads(line)["src"])
except (json.JSONDecodeError, KeyError):
continue # torn last line from an eviction
done = read_label_srcs()
print(f"[{spec_id}] resuming labels: {len(done)} already done", flush=True)

todo = [(s, t) for s, t in train_ds.rows if s not in done]
Expand DownExpand Up@@ -832,7 +870,7 @@ def label_all(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
fh.write(
json.dumps(
{"src": src, "teacher": text},
ensure_ascii=False,
ensure_ascii=True,
)
+ "\n"
)
Expand DownExpand Up@@ -860,23 +898,21 @@ def label_all(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
else:
print(f"[{spec_id}] teacher labels already complete", flush=True)

# Step 2: student trains on teacher labels (teacher no longer
# needed on GPU — free it before the training loop)
teacher.to("cpu")
torch.cuda.empty_cache()
student.to("cuda")
student.gradient_checkpointing_enable()
# Step 2: student trains on teacher labels. The teacher stays on the
# GPU until the labels are confirmed usable — regeneration (below)
# still needs it.
label_cap = 2 * int(spec.get("max_len", 384))
teacher_labels = []
seen_labels: set[str] = set()

def accept_label(src: str, label: str) -> None:
src, label = src.strip(), label.strip()
if src and src not in seen_labels and label and len(label.encode()) <= 384:
if src and src not in seen_labels and label and len(label.encode()) <= label_cap:
seen_labels.add(src)
teacher_labels.append((src, label))

if not fresh_rows:
for line in teacher_labels_path.read_text(encoding="utf-8", errors="ignore").splitlines():
for line in teacher_labels_path.read_text(encoding="utf-8", errors="ignore").split("\n"):
if not line.strip():
continue
try:
Expand All@@ -885,10 +921,10 @@ def accept_label(src: str, label: str) -> None:
continue # torn line from a volume replication race
accept_label(row.get("src") or "", row.get("teacher") or "")
if len(teacher_labels) < 0.5 * len(train_ds.rows):
# stale replica of a complete file: regenerate rather than
# fail (relaunch-only loops forever on this path)
# the file is unusable from this container: regenerate rather
# than fail (relaunch-only loops forever on this path)
print(
f"[{spec_id}] labels view torn ({len(teacher_labels)} valid "
f"[{spec_id}] labels unusable ({len(teacher_labels)} valid "
f"pairs); regenerating all labels",
flush=True,
)
Expand All@@ -901,10 +937,15 @@ def accept_label(src: str, label: str) -> None:
print(f"[{spec_id}] trainable label pairs: {len(teacher_labels)}", flush=True)
if len(teacher_labels) < 0.5 * len(train_ds.rows):
raise RuntimeError(
f"labels view is torn even after regeneration: "
f"labels unusable even after regeneration: "
f"{len(teacher_labels)} valid pairs for {len(train_ds.rows)} srcs"
)

teacher.to("cpu")
torch.cuda.empty_cache()
student.to("cuda")
student.gradient_checkpointing_enable()

class TeacherPairs(Dataset):
def __len__(self):
return len(teacher_labels)
Expand DownExpand Up@@ -1200,7 +1241,7 @@ def distill_microkimi(spec_id: str, epochs: int = 3, calib_batches: int = 64,
raise RuntimeError("microkimi expects pre-generated trusted labels")
teacher_labels = []
seen: set[str] = set()
for line in labels_file.read_text(encoding="utf-8", errors="ignore").splitlines():
for line in labels_file.read_text(encoding="utf-8", errors="ignore").split("\n"):
if not line.strip():
continue
try:
Expand Down
15 changes: 12 additions & 3 deletions src/gpu/modal_export.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,22 @@
},
"heb-diac": {
"volume": "/volumes/rababa-checkpoints",
"checkpoint": "rababa_hebrew_byt5_s43/run-001/best",
"metadata": "models/heb-diac/heb-diac-1.0.metadata.yaml",
"readme": "models/heb-diac/heb-diac-1.0.README.md",
"checkpoint": "rababa_hebrew/run-s46-phonikud-plus/run-002-gold-ft/best",
"metadata": "models/heb-diac/heb-diac-1.1.metadata.yaml",
"readme": "models/heb-diac/heb-diac-1.1.README.md",
"test_volume": "/datasets/rababa",
"test_data": "nakdimon/test-imf.jsonl",
"probe": "שלום",
},
"ara-diac": {
"volume": "/volumes/rababa-checkpoints",
"checkpoint": "rababa_arabic_byt5/run-006-morph/best",
"metadata": "models/ara-diac/ara-diac-1.0.metadata.yaml",
"readme": "models/ara-diac/ara-diac-1.0.README.md",
"test_volume": "/datasets/rababa",
"test_data": "arabic-sadeed-imf/test.jsonl",
"probe": "مكتبة",
},
"urd-diac": {
"volume": "/volumes/urdu-diacrit-checkpoints",
"checkpoint": "urdu_diacrit/run-001/best",
Expand Down
Loading