File size: 15,132 Bytes
7d3eb8f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""Two-layer canvas data (user idea 2026-09-29): a SOUND layer of pronounced syllables and a WORD layer of written
words spanning 1+ sound cells.

User: "There got to be two layers. Syllable layer that are pronounciation of วรรค such as ไฉน => ฉะ | ไหน,
สลาย => สะ | หลาย, อภัย => อะ | ไพ. Rhyme cleanness should focus on this layer. Naturalness and misspell layer
should be judged on other layer."

Units. A วรรค is cut into written words by newmm and each word is read by TLTK g2p word by word - exactly the path
klon8.prosody.g2p_line takes, so the sound cells ARE the syllables the form checker counts, rhymes and tones.
One cell = one pronounced syllable. Every cell carries
  sound: the TLTK syllable string (e.g. cha1, naj4) -> onset / rime family / tone, exact, in context
  word:  the written word it belongs to, and its index k inside that word (0..n-1)
The model's token at a cell is the pair (word, k) - "word-slot" - so a word is never cut into written pieces:
ไฉน is two cells (ไฉน,0)(ไฉน,1) with sounds cha1 | naj4, never ไฉ | น.

ๆ repeats the whole preceding word (ดีๆ -> ดี ดี); ฯ / ๏ are dropped (fixbase_data.prep_line, same choice as FIXED).

Commands (CPU only, no GPU):
  twolayer_data.py measure DATA [--n N]       numbers for the design (fragments, inventories, coverage, drops)
  twolayer_data.py build DATA OUT_DIR [--words 30000]   poems.jsonl + lexicon.json for the prototype
  twolayer_data.py test                       round trip / label / hand cases
"""
import os, sys, re, json, collections, functools, argparse
sys.path[:0] = [".", "scripts"]
from klon8 import prosody as P

C, TL, L = 10, 12, 4                     # cells per วรรค, title cells, วรรค per บท (same grid as the canvas)
THAI = re.compile(r"[ก-๙]")


def prep_line(line):
    """ฯ / ๏ are not pronounced (FIXED's stated choice, fixbase_data.prep_line)."""
    return line.replace("ฯลฯ", "").replace("ฯ", "").replace("๏", "")


def rime_key(s):
    return P._MERGED_RIME.get(s.rime, s.rime)


@functools.lru_cache(maxsize=300000)
def word_sounds(w):
    """TLTK syllables of one written word, parsed; () if any piece does not parse (the word is unusable)."""
    raw = P.g2p_word(w)
    if not raw: return ()
    out = []
    for ps in raw.split("~"):
        s = P._parse_syl(w, ps)
        if s is None: return ()
        out.append(s)
    return tuple(out)


def line_words(line):
    """newmm words of one วรรค, Thai only, with ๆ expanded to a copy of the previous WORD (ดีๆ -> ดี ดี)."""
    from pythainlp.tokenize import word_tokenize
    line = prep_line(line)
    clean = P._CLEAN.sub(" ", line)
    words = []
    for w in word_tokenize(re.sub(r"\s+", " ", clean).strip(), engine="newmm"):
        w = w.strip()
        if not w: continue
        parts = w.split("ๆ")
        for j, part in enumerate(parts):
            if j and words: words.append(words[-1])
            if part and THAI.search(part): words.append(part)
    return words


def line_cells(line):
    """[(word, k, n, Syl)] one per pronounced syllable, or None if a word has no parseable reading."""
    cells = []
    for w in line_words(line):
        ss = word_sounds(w)
        if not ss: return None
        for k, s in enumerate(ss): cells.append((w, k, len(ss), s))
    return cells


def checker_count(words):
    """What klon8.prosody counts for the JOINED words (the model writes words with no spaces; newmm may re-cut)."""
    return len(P.g2p_line("".join(words)))


def checker_phons(words):
    return [s.phon for s in P.g2p_line("".join(words))]


def poem_record(row, title_clean_fn=None):
    """One บท -> {"title": [cells], "grid": [[cells] x 4]} with cell = [word, k, n, phon], or (None, reason)."""
    lines = row["poem"].split("\n")
    if len(lines) != L: return None, "not4"
    grid = []
    for ln in lines:
        cs = line_cells(ln)
        if cs is None: return None, "g2p_unparsed"
        if not 7 <= len(cs) <= C: return None, "count"
        words = [w for (w, k, n, s) in cs if k == 0]
        # the checker re-cuts the JOINED words with newmm; its syllables must be ours exactly (not just their number:
        # ฝาก|ว่า rejoined reads ฝา|กว่า - same count, different sounds; test 2026-09-29)
        if checker_phons(words) != [s.phon for (w, k, n, s) in cs]: return None, "rejoin"
        grid.append([[w, k, n, s.phon] for (w, k, n, s) in cs])
    t = row.get("title", "") or ""
    if title_clean_fn: t = title_clean_fn(t)
    tc = line_cells(t) or []
    # title: whole words only, up to TL sound cells (a word that would cross cell 12 is dropped, not cut)
    title = []
    for c in tc:
        if c[1] == 0 and len(title) + c[2] > TL: break
        title.append([c[0], c[1], c[2], c[3].phon])
    return {"title": title, "grid": grid}, "ok"


# ------------------------------------------------------------------ measurement

def measure(path, n_max=0):
    import syl_align_v2 as A2
    from pythainlp.tokenize import word_tokenize
    rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()]
    if n_max: rows = rows[:n_max]
    st = collections.Counter()
    word_tok = collections.Counter(); end_word = collections.Counter(); phon_tok = collections.Counter()
    reasons = collections.Counter()
    a2_keep = 0
    for r in rows:
        lines = r["poem"].split("\n")
        if len(lines) != L: reasons["not4"] += 1; continue
        # ---- the FIXED base's cells (aligned2 + ๆ/ฯ), grouped by newmm word, as fixbase load_poems builds them
        a2_ok = True; a2_lines = []
        for ln in lines:
            ln2 = prep_line(ln)
            per_word = []
            for w in word_tokenize(ln2, engine="newmm"):
                if not re.search(r"[ก-๙]", w): continue
                got = A2.split_word(w) or (w,)
                per_word.append((w, got))
            cells = [p for _, g in per_word for p in g]
            if "ๆ" in ln2 or not (7 <= len(cells) <= C) or len(cells) != len(P.g2p_line("".join(cells))):
                a2_ok = False; break
            a2_lines.append(per_word)
        if a2_ok:
            a2_keep += 1
            for per_word in a2_lines:
                last_multi = False
                for wi, (w, pieces) in enumerate(per_word):
                    ws = word_sounds(w)
                    for k, pc in enumerate(pieces):
                        st["a2_cells"] += 1
                        multi = len(pieces) > 1
                        st["a2_in_multi"] += multi
                        # misread: the piece read ALONE (how a per-type label sees it) != its in-context syllable
                        alone = P.g2p_line(pc)
                        if ws and len(ws) == len(pieces):
                            st["a2_cmp"] += 1
                            ctx = ws[k]
                            bad = (len(alone) != 1 or alone[0].phon != ctx.phon)
                            st["a2_misread"] += bad
                            st["a2_misread_rt"] += (len(alone) != 1 or (rime_key(alone[0]), alone[0].tone) != (rime_key(ctx), ctx.tone))
                    if wi == len(per_word) - 1:
                        st["a2_final"] += 1; st["a2_final_multi"] += len(pieces) > 1
        # ---- two-layer cells
        rec, why = poem_record(r)
        reasons[why] += 1
        if rec is None: continue
        for g in rec["grid"]:
            for (w, k, n, ph) in g:
                st["tl_cells"] += 1; phon_tok[ph] += 1
                if k == 0: word_tok[w] += 1
            w, k, n, ph = g[-1]
            end_word[w] += 1; st["tl_final_multi"] += n > 1; st["tl_final"] += 1
    kept = reasons["ok"]
    out = {"rows": len(rows), "a2_kept_poems": a2_keep, "twolayer_kept_poems": kept, "drop_reasons": dict(reasons)}
    out["a2_cells_in_multisyllable_word"] = st["a2_in_multi"] / max(st["a2_cells"], 1)
    out["a2_cells_misread_alone_phon"] = st["a2_misread"] / max(st["a2_cmp"], 1)
    out["a2_cells_misread_alone_rime_tone"] = st["a2_misread_rt"] / max(st["a2_cmp"], 1)
    out["a2_linefinal_in_multisyllable_word"] = st["a2_final_multi"] / max(st["a2_final"], 1)
    out["tl_linefinal_multisyllable_word"] = st["tl_final_multi"] / max(st["tl_final"], 1)
    tot_p = sum(phon_tok.values())
    out["phon_types"] = len(phon_tok)
    for k in (1000, 2000, 3000):
        out[f"phon_cov_top{k}"] = sum(c for _, c in phon_tok.most_common(k)) / tot_p
    out["phon_types_ge3"] = sum(1 for c in phon_tok.values() if c >= 3)
    tot_w = sum(word_tok.values()); tot_e = sum(end_word.values())
    out["word_types"] = len(word_tok); out["word_tokens"] = tot_w
    out["word_types_ge2"] = sum(1 for c in word_tok.values() if c >= 2)
    out["word_types_ge3"] = sum(1 for c in word_tok.values() if c >= 3)
    for k in (20000, 30000, 50000):
        top = dict(word_tok.most_common(k))
        out[f"word_unk_tok_{k}"] = 1 - sum(top.values()) / tot_w
        out[f"end_word_unk_{k}"] = 1 - sum(c for w, c in end_word.items() if w in top) / tot_e
        # poems with >= 1 UNK word
    out["syl_per_word_tokens"] = st["tl_cells"] / max(tot_w, 1)
    nsyl = collections.Counter(len(word_sounds(w)) for w in word_tok.elements())
    out["word_len_dist_tokens"] = {k: v / tot_w for k, v in sorted(nsyl.items())}
    return out, word_tok, phon_tok


KMAX = 8          # longest word the model represents (syllables); the longest training-poem word has 8


def extra_words():
    """Words beyond the poems (user 2026-09-29: the pretrained LM knows far more words than the poems use):
    pythainlp thai_words + WangchanBERTa vocabulary pieces that are whole Thai words. A piece counts as a word when it is
    Thai only, starts with a consonant or a leading vowel, and newmm keeps it as one token. Returns {word: source}."""
    from pythainlp.corpus import thai_words
    from pythainlp.tokenize import word_tokenize
    from transformers import AutoTokenizer
    out = {w: "dict" for w in thai_words()}
    tk = AutoTokenizer.from_pretrained("airesearch/wangchanberta-base-att-spm-uncased")
    for p in tk.get_vocab():
        p = p.replace("▁", "")
        if p and re.fullmatch(r"[ก-๛]+", p) and re.match(r"[ก-ฮเแโใไ]", p) and word_tokenize(p, engine="newmm") == [p]:
            out[p] = "wc" if p not in out else "dict+wc"
    return out


def usable(w):
    """a word the sound layer can carry: Thai only, g2p reads every syllable, at most KMAX syllables, and newmm reads it
    back as itself (so the checker re-cuts it as one word when it stands alone)"""
    from pythainlp.tokenize import word_tokenize
    if not re.fullmatch(r"[ก-๛]+", w) or "ๆ" in w or "ฯ" in w: return False
    ss = word_sounds(w)
    return bool(ss) and len(ss) <= KMAX and word_tokenize(w, engine="newmm") == [w]


def build(path, out_dir, n_words=0, clean_titles=True, vocab="union"):
    import title_clean
    os.makedirs(out_dir, exist_ok=True)
    recs, reasons = [], collections.Counter()
    for l in open(path, encoding="utf-8"):
        if not l.strip(): continue
        r = json.loads(l)
        rec, why = poem_record(r, title_clean.clean_title if clean_titles else None)
        reasons[why] += 1
        if rec: rec["id"] = r.get("id"); recs.append(rec)
    cnt = collections.Counter(c[0] for rec in recs for g in rec["grid"] for c in g if c[1] == 0)
    # vocab=poems: the POEM words only. vocab=union (default, user 2026-09-29): poem words + thai_words + whole-word
    # WangchanBERTa pieces, filtered by usable(); poem words first (by count), then the rest sorted. A title word outside
    # the lexicon is read as UNK plus its exact sound channels.
    words = [w for w, _ in (cnt.most_common(n_words) if n_words else cnt.most_common())]
    src = {w: "poems" for w in words}
    if vocab == "union":
        ex = extra_words(); seen = set(words); n0 = len(words)
        for w in sorted(ex):
            if w not in seen and usable(w): words.append(w); seen.add(w); src[w] = ex[w]
        print(f"  lexicon: {n0:,} poem words + {len(words) - n0:,} unseen words "
              f"({collections.Counter(src[w] for w in words[n0:])})")
    lex = {w: [s.phon for s in word_sounds(w)] for w in words}
    with open(os.path.join(out_dir, "poems.jsonl"), "w", encoding="utf-8") as f:
        for rec in recs: f.write(json.dumps(rec, ensure_ascii=False) + "\n")
    json.dump({"words": words, "sounds": lex, "count": {w: cnt[w] for w in words}, "source": src},
              open(os.path.join(out_dir, "lexicon.json"), "w", encoding="utf-8"), ensure_ascii=False)
    print(f"  {len(recs):,} poems kept of {sum(reasons.values()):,} {dict(reasons)}; lexicon {len(words):,} words")
    return recs, words


# ------------------------------------------------------------------ tests

HAND = {"ไฉน": ["cha1", "naj4"], "สลาย": ["sa1", "laaj4"], "อภัย": ["?a1", "phaj0"], "ขออภัย": ["khOO4", "?a1", "phaj0"],
        "ทำงาน": ["tham0", "Naan0"], "สงบ": ["sa1", "Nop1"], "วันนี้": ["wan0", "nii3"]}


def test():
    fails = 0
    for w, want in HAND.items():
        got = [s.phon for s in word_sounds(w)]
        ok = got == want
        fails += not ok
        print(f"  {'OK ' if ok else 'BAD'} {w} -> {'|'.join(got)} (want {'|'.join(want)})")
    # round trip: word -> sound cells -> words -> text; checker agrees with the cell count
    for ln in ["ไฉนเลยใจสลายไม่อภัย", "ดีๆ ใจเราเฝ้ารอคอยเธอ", "แว่วแว่วมาว่าใครส่งใจหา"]:
        cs = line_cells(ln)
        words = [w for (w, k, n, s) in cs if k == 0]
        text = "".join(words)
        ok = (text == prep_line(ln).replace(" ", "").replace("ๆ", "") or "ๆ" in ln) and checker_count(words) == len(cs)
        fails += not ok
        print(f"  {'OK ' if ok else 'BAD'} {ln} -> {' | '.join(f'{w}:{s.phon}' for w, k, n, s in cs)}")
    print("PASS" if not fails else f"FAIL {fails}")
    return fails


if __name__ == "__main__":
    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd")
    m = sub.add_parser("measure"); m.add_argument("data"); m.add_argument("--n", type=int, default=0); m.add_argument("--out", default=None)
    b = sub.add_parser("build"); b.add_argument("data"); b.add_argument("out_dir"); b.add_argument("--words", type=int, default=0, help="cap on poem words (0 = all)")
    b.add_argument("--vocab", choices=["poems", "union"], default="union")
    sub.add_parser("test")
    a = ap.parse_args()
    if a.cmd == "measure":
        res, wt, pt = measure(a.data, a.n)
        print(json.dumps(res, ensure_ascii=False, indent=1))
        if a.out: json.dump({"summary": res, "word_count": dict(wt), "phon_count": dict(pt)}, open(a.out, "w", encoding="utf-8"), ensure_ascii=False)
    elif a.cmd == "build":
        build(a.data, a.out_dir, a.words, vocab=a.vocab)
    elif a.cmd == "test":
        sys.exit(1 if test() else 0)
    P.save_cache()