File size: 6,619 Bytes
269a3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#!/usr/bin/env python3
"""Check the phonemes of static/assets/arabic_verbs.json against what the scorer uses.

Usage:  python scripts/compare_verbs.py              # summary + every difference
        python scripts/compare_verbs.py --all        # also list the forms that match
        python scripts/compare_verbs.py --no-vocab   # skip the model-vocab check (no tokenizer download)

For every past / future form (and its accepted_phonemes) it reports:
  bank     the same Arabic word in the phrase bank (phrases.py, workbook LEX, grammar VERBS) and whether its IPA
           agrees: "same" (identical), "same sounds" (identical once archify() symbols and base() are applied,
           i.e. the scorer would build the same target), or "differs"
  archi    how archify() rewrites the form (ق ك ث ذ ظ ج as Q K TH DH ZH J) and the letters it could not place
  vocab    phonemes the model has no symbol for (mapped to a fallback, or not expressible at all)
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
from collections import Counter
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from app.archi import archify, split_token, unresolved  # noqa: E402
from app.grammar import VERBS  # noqa: E402
from app.phones import base  # noqa: E402
from app.phrases import PHRASES  # noqa: E402
from app.workbook import LEX  # noqa: E402

VERBS_JSON = ROOT / "static/assets/arabic_verbs.json"
_MARKS = re.compile(r"[ً-ْٰـ]")  # harakat, shadda, sukun, dagger alif, tatweel


def loose(ar: str) -> str:
    """Arabic without diacritics and with the hamza seats of alif folded, for a lookup that tolerates spelling."""
    return _MARKS.sub("", ar).translate(str.maketrans("أإآ", "ااا")).replace("ى", "ي")


def bank_words() -> dict[str, set[str]]:
    """Arabic word -> every IPA the bank gives it (single words of each variant, LEX, grammar VERBS)."""
    out: dict[str, set[str]] = {}
    for p in PHRASES:
        for ar, ipa in p["variants"]:
            ars, ipas = ar.split(), [w.strip() for w in ipa.split("|")]
            if len(ars) == len(ipas):
                for a, i in zip(ars, ipas):
                    out.setdefault(a, set()).add(i)
    for key, (_tr, ipas) in LEX.items():
        out.setdefault(key.partition("#")[0], set()).update(ipas)
    for forms in VERBS.values():
        for _he, _tr, ar, ipas in forms:
            out.setdefault(ar, set()).update(ipas)
    return out


def sounds(ar: str, ipa: str) -> tuple[str, ...]:
    """The target as the scorer sees it: archify() symbols kept, other phonemes reduced to their base() sound."""
    out = []
    for tok in archify(ar, ipa).split():
        if tok == "|":
            continue
        sym, _members, ph = split_token(tok)
        out.append(sym if sym else base(ph))
    return tuple(out)


def load_vocab():
    from huggingface_hub import hf_hub_download

    from app.scorer import Vocab
    model_id = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
    path = Path(model_id) / "vocab.json" if Path(model_id).is_dir() else hf_hub_download(model_id, "vocab.json")
    return Vocab(json.loads(Path(path).read_text(encoding="utf-8")))


def vocab_issues(vocab, ar: str, ipa: str) -> list[str]:
    issues = []
    for tok in archify(ar, ipa).split():
        before = dict(vocab.downgraded)
        try:
            vocab.token(tok)
        except KeyError as e:
            issues.append(f"{tok}: {e}")
            continue
        issues += [f"{k}->{v}" for k, v in vocab.downgraded.items() if before.get(k) != v]
    return issues


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--all", action="store_true", help="also print forms that match the bank")
    ap.add_argument("--no-vocab", action="store_true", help="skip the model-vocab check")
    args = ap.parse_args()

    data = json.loads(VERBS_JSON.read_text(encoding="utf-8"))
    bank = bank_words()
    bank_loose: dict[str, set[str]] = {}
    for a, ipas in bank.items():
        bank_loose.setdefault(loose(a), set()).update(ipas)
    vocab = None if args.no_vocab else load_vocab()

    tally: Counter[str] = Counter()
    symbols: Counter[str] = Counter()
    for section, entry in data.items():
        for verb in entry["verbs"]:
            for tense in ("past", "future"):
                form = verb[tense]
                ar = form["arabic"]
                for ipa in [form["phonemes"], *form.get("accepted_phonemes", [])]:
                    lines = []
                    for tok in ipa.split():
                        if split_token(tok)[0]:
                            symbols[tok] += 1
                    known = bank.get(ar) or bank_loose.get(loose(ar))
                    if not known:
                        status = "not in bank"
                    elif ipa in known:
                        status = "same"
                    elif sounds(ar, ipa) in {sounds(ar, k) for k in known}:
                        status = "same sounds"
                    else:
                        status = "differs"
                        how = "" if ar in bank else " (matched without diacritics: may be another word)"
                        lines.append(f"    bank:  {' / '.join(sorted(known))}{how}")
                    tally[status] += 1
                    arch = archify(ar, ipa)
                    if arch != ipa:
                        lines.append(f"    archi: {arch}")
                    if missing := unresolved(ar, ipa):
                        tally["unresolved"] += 1
                        lines.append(f"    unresolved letters: {' '.join(l for _, l in missing)}")
                    if vocab is not None and (bad := vocab_issues(vocab, ar, ipa)):
                        tally["vocab"] += 1
                        lines.append(f"    vocab: {', '.join(bad)}")
                    if status in ("differs",) or missing or args.all or (vocab is not None and bad):
                        print(f"[{section}] {verb['hebrew']} {tense} {ar}  {ipa}  -> {status}")
                        print("\n".join(lines)) if lines else None

    print("\n== summary ==")
    for k in ("same", "same sounds", "differs", "not in bank", "unresolved", "vocab"):
        print(f"{k:12} {tally[k]}")
    if symbols:
        print("symbols written in the JSON itself (archify() leaves such a word as it is):",
              ", ".join(f"{s}×{n}" for s, n in symbols.most_common()))


if __name__ == "__main__":
    main()