File size: 21,952 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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
"""Canvas model: the whole บท is generated at once, rhyme and line as one decision (user requirement).

Architecture
  * one token = one orthographic syllable (pythainlp syllable_tokenize); a poem is a fixed grid of
    8 lines x 10 cells = 80 (user spec; 7-10 syllables per line, the rest <pad>), preceded by the title
  * every cell is embedded as syllable + rime + tone (phoneme channels) + line + position + segment
  * a bidirectional Transformer encoder over title + grid: every cell sees every other cell, so a
    rhyme word is chosen with its own line AND the lines it rhymes with in view
  * trained as a masked LM on 31k human poems with a random mask ratio (Mask-Predict style)

Decoding (iterative refinement, T steps)
  * all cells start masked; each step the model predicts all cells at once
  * each rhyme link (a, b) is ONE variable: the rime family maximising p_a(family) * p_b(family) is
    chosen and both cells are restricted to it; tone classes are restricted at line-final cells
  * the most confident cells are committed, the rest re-masked; repeat. Nothing is fitted afterwards.

Usage: canvas_mlm.py train --epochs 20 --out runs/CANVAS_MLM
       canvas_mlm.py decode --model runs/CANVAS_MLM --prompts data/prompts/test.jsonl --out O.jsonl
"""
import os, re, sys, json, math, time, random, argparse, collections
sys.path.insert(0, ".")
import mlx.core as mx, mlx.nn as nn, mlx.optimizers as optim
from klon8 import prosody as P
from pythainlp.tokenize import syllable_tokenize

L, C, TL = 8, 10, 12                     # lines, cells per line (user spec: 8 x 10 = 80 cells), title length
PAD, MASK, UNK, SEP = 0, 1, 2, 3
EVEN, CHECKED = (0, 3), (1, 2, 4)
# rhyme GROUPS (cells that must share one rime family): w2 rhymes with w3 and x4, w6 with w4, w7 and x8.
# Deciding per link would restrict a shared cell to two different families (empty intersection).
GROUPS = [[(1, -1), (2, 2)], [(2, -1), (3, -1), (4, 2)], [(4, -1), (6, -1), (7, -1), (8, 2)], [(5, -1), (6, 2)]]
TONE_AT = {2: CHECKED, 6: CHECKED, 3: EVEN, 4: EVEN, 7: EVEN, 8: EVEN}
_PH = {}
RIME_OF = TONE_OF = None


def phon(syl):
    """(rime family key, tone) of a syllable string, cached; (None, None) if g2p fails."""
    if syl not in _PH:
        g = P.g2p_line(syl)
        _PH[syl] = ((P._MERGED_RIME.get(g[-1].rime, g[-1].rime), g[-1].tone) if g else (None, None))
    return _PH[syl]


def syls_of(text, limit=None):
    out = [s for s in syllable_tokenize(text) if re.search(r"[ก-๙]", s)]
    return out[:limit] if limit else out


class Vocab:
    def __init__(self, tokens, min_freq=3):
        cnt = collections.Counter(tokens)
        self.itos = ["<pad>", "<mask>", "<unk>", "<sep>"] + sorted(t for t, c in cnt.items() if c >= min_freq)
        self.stoi = {t: i for i, t in enumerate(self.itos)}
        rimes = {}
        self.rime, self.tone, self.fam = [0] * len(self.itos), [5] * len(self.itos), {}
        for i, t in enumerate(self.itos):
            if i < 4: continue
            r, tn = phon(t)
            if r is None: continue
            rimes.setdefault(r, len(rimes) + 1)
            self.rime[i], self.tone[i] = rimes[r], tn
            self.fam.setdefault(rimes[r], []).append(i)
        self.n_rime = len(rimes) + 1

    def enc(self, t):
        return self.stoi.get(t, UNK)


def load_poems(path, vocab_min=3):
    rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()]
    poems = []
    for r in rows:
        lines = r["poem"].split("\n")
        if len(lines) != 8: continue
        grid = [syls_of(ln) for ln in lines]
        if all(7 <= len(g) <= C for g in grid):
            poems.append({"title": syls_of(r.get("title", "") or "", TL), "grid": grid})
    return poems


def build_arrays(poems, V):
    """token ids (N, TL+L*C), plus static line/pos/segment ids."""
    N = len(poems); T = TL + L * C
    tok = [[PAD] * T for _ in range(N)]
    for n, p in enumerate(poems):
        for j, s in enumerate(p["title"]): tok[n][j] = V.enc(s)
        for i, line in enumerate(p["grid"]):
            for j, s in enumerate(line): tok[n][TL + i * C + j] = V.enc(s)
    line_id = [0] * TL + [1 + i for i in range(L) for _ in range(C)]
    pos_id = list(range(TL)) + [j for _ in range(L) for j in range(C)]
    seg_id = [0] * TL + [1] * (L * C)
    return mx.array(tok), mx.array(line_id), mx.array(pos_id), mx.array(seg_id)


class Canvas(nn.Module):
    def __init__(self, V, d=256, h=8, layers=6):
        super().__init__()
        n = len(V.itos)
        self.tok = nn.Embedding(n, d); self.rime = nn.Embedding(V.n_rime, d); self.tone = nn.Embedding(6, d)
        self.line = nn.Embedding(L + 1, d); self.pos = nn.Embedding(max(TL, C), d); self.seg = nn.Embedding(2, d)
        self.enc = nn.TransformerEncoder(layers, d, h, mlp_dims=4 * d, dropout=0.1)
        self.norm = nn.LayerNorm(d); self.out = nn.Linear(d, n)

    def __call__(self, tok, line_id, pos_id, seg_id):
        known = (tok != MASK)
        # phoneme lookups are fixed tables, kept outside the module so they are not parameters
        r = mx.where(known, RIME_OF[tok], 0); t = mx.where(known, TONE_OF[tok], 5)
        x = self.tok(tok) + self.rime(r) + self.tone(t) + self.line(line_id)[None] + self.pos(pos_id)[None] + self.seg(seg_id)[None]
        x = self.enc(x, None)
        return self.out(self.norm(x))


def mask_batch(tok, seg_id, rng):
    """Mask a random share (15-90%) of poem cells that are not <pad>; title never masked."""
    B, T = tok.shape
    ratio = mx.array([rng.uniform(0.15, 0.9) for _ in range(B)])[:, None]
    u = mx.random.uniform(shape=(B, T))
    maskable = (seg_id[None, :] == 1) & (tok != PAD)
    m = maskable & (u < ratio)
    return mx.where(m, MASK, tok), m


def train(a):
    mx.set_default_device(mx.cpu if a.cpu else mx.gpu)
    rng = random.Random(0); mx.random.seed(0)
    poems = load_poems(a.data)
    rng.shuffle(poems)
    V = Vocab([s for p in poems for line in p["grid"] for s in line] + [s for p in poems for s in p["title"]])
    os.makedirs(a.out, exist_ok=True)
    json.dump({"itos": V.itos}, open(os.path.join(a.out, "vocab.json"), "w", encoding="utf-8"), ensure_ascii=False)
    valid, train_p = poems[:500], poems[500:]
    tok, line_id, pos_id, seg_id = build_arrays(train_p, V)
    vtok, _, _, _ = build_arrays(valid, V)
    global RIME_OF, TONE_OF
    RIME_OF, TONE_OF = mx.array(V.rime), mx.array(V.tone)
    model = Canvas(V, d=a.dim, h=8, layers=a.layers)
    mx.eval(model.parameters())
    n_par = sum(v.size for _, v in nn.utils.tree_flatten(model.parameters()))
    print(f"poems {len(poems)} (train {len(train_p)}), vocab {len(V.itos)}, rime families {V.n_rime-1}, params {n_par/1e6:.1f}M", flush=True)
    opt = optim.AdamW(learning_rate=a.lr, weight_decay=0.01)

    def loss_fn(model, x, y, m):
        logits = model(x, line_id, pos_id, seg_id)
        ce = nn.losses.cross_entropy(logits.reshape(-1, logits.shape[-1]), y.reshape(-1), reduction="none").reshape(y.shape)
        return (ce * m).sum() / mx.maximum(m.sum(), 1)
    step = nn.value_and_grad(model, loss_fn)
    B = a.batch; t0 = time.time(); it = 0
    for ep in range(a.epochs):
        perm = mx.array(rng.sample(range(tok.shape[0]), tok.shape[0]))
        model.train()
        for i in range(0, tok.shape[0] - B + 1, B):
            y = tok[perm[i:i + B]]
            x, m = mask_batch(y, seg_id, rng)
            loss, grads = step(model, x, y, m.astype(mx.float32))
            opt.update(model, grads); mx.eval(model.parameters(), opt.state); it += 1
            if it % 50 == 0:
                print(f"ep {ep} it {it} loss {loss.item():.3f} {time.time()-t0:.0f}s", flush=True)
        model.eval()
        x, m = mask_batch(vtok, seg_id, random.Random(1))
        vl = loss_fn(model, x, vtok, m.astype(mx.float32)).item()
        print(f"== epoch {ep} valid masked-loss {vl:.3f}", flush=True)
        mx.save_safetensors(os.path.join(a.out, "model.safetensors"), dict(nn.utils.tree_flatten(model.parameters())))
        json.dump({"dim": a.dim, "layers": a.layers}, open(os.path.join(a.out, "config.json"), "w"))


STOP_SYL = set("ที่ ก็ จะ ได้ ไป มา ให้ อยู่ แล้ว และ กับ ว่า ไม่ นี้ นั้น ใน ของ เป็น คน ๆ".split())


def clauses_of(prose, n=4):
    """Cut the prose into n clauses (spaces/punctuation, else equal word chunks), one per บาท."""
    parts = [x for x in re.split(r"\s+|[,;.!?]", prose) if x.strip()]
    if len(parts) < n:
        sy = syls_of(prose)
        if len(sy) <= 8: return [prose.strip()] * n  # a short topic steers every บาท whole
        k = max(1, len(sy) // n)
        parts = ["".join(sy[i:i + k]) for i in range(0, len(sy), k)]
    while len(parts) > n:
        i = min(range(len(parts) - 1), key=lambda j: len(parts[j]) + len(parts[j + 1]))
        parts[i:i + 2] = [parts[i] + parts[i + 1]]
    return parts


def decode_one(model, V, title_syls, line_id, pos_id, seg_id, T=12, temp=0.8, rng=None, line_len=8, fam_temp=0.5, anchors=None, lengths=None, topic=None, topic_boost=0.0, schedule="stanza"):
    """anchors: {(line, cell): syllable} fixed from an LLM draft (topic content); lengths: syllables per line."""
    n = len(V.itos)
    lens = lengths or [line_len] * L
    tok = [PAD] * (TL + L * C)
    for j, s in enumerate(title_syls[:TL]): tok[j] = V.enc(s)
    cells = [(i, j) for i in range(L) for j in range(lens[i])]
    idx = {(i, j): TL + i * C + j for i in range(L) for j in range(C)}
    for (i, j) in cells: tok[idx[(i, j)]] = MASK
    committed = set()
    for cell, syl in (anchors or {}).items():
        if cell in idx and cell[1] < lens[cell[0]] and V.enc(syl) != UNK:
            tok[idx[cell]] = V.enc(syl); committed.add(idx[cell])
    # vocabulary masks: never emit specials or <pad> inside a line; tone classes at line-final cells
    base_bad = mx.array([1.0 if k < 4 else 0.0 for k in range(n)]) * -1e9
    tone_mask = {}
    for ln, allowed in TONE_AT.items():
        tone_mask[ln] = mx.array([0.0 if V.tone[k] in allowed and k >= 4 else -1e9 for k in range(n)])
    fam_vec = {f: mx.array([0.0 if k in set(ks) else -1e9 for k in range(n)]) for f, ks in V.fam.items()}
    groups = [[(l - 1, lens[l - 1] - 1 if c == -1 else c) for (l, c) in g] for g in GROUPS]
    group_family = {}
    # topical steering: each บาท (2 lines) is nudged toward the syllables of its own prompt clause
    topic_vec = None
    if topic and topic_boost:
        topic_vec = mx.zeros((L, n))
        rows_ = []
        for bt, clause in enumerate(topic[:4]):
            ids = [V.enc(sy) for sy in syls_of(clause) if sy not in STOP_SYL and V.enc(sy) != UNK and len(sy) > 1]
            v = [0.0] * n
            for k in ids: v[k] = topic_boost
            rows_ += [v, v]
        while len(rows_) < L: rows_.append([0.0] * n)
        topic_vec = mx.array(rows_[:L])
    for t in range(T):
        x = mx.array([tok])
        logits = model(x, line_id, pos_id, seg_id)[0].astype(mx.float32) + base_bad[None, :]
        for ln, tm in tone_mask.items():
            k = idx[(ln - 1, lens[ln - 1] - 1)]
            logits[k] = logits[k] + tm
        if topic_vec is not None:
            # a prompt syllable earns its bonus once: after it is committed anywhere, the bonus is
            # withdrawn (otherwise the boost outweighs the repetition penalty: แม่แม่แม่แม่)
            tv = topic_vec
            if committed:
                usedk = mx.array(sorted({tok[k] for k in committed if tok[k] >= 4}))
                tv = mx.array(topic_vec); tv[:, usedk] = 0.0
            for i in range(L):
                for j in range(lens[i]):
                    logits[idx[(i, j)]] = logits[idx[(i, j)]] + tv[i]
        lp = nn.log_softmax(logits, axis=-1)
        for gi, cells_g in enumerate(groups):
            if gi in group_family: continue
            done = [idx[c] for c in cells_g if idx[c] in committed]
            if done:
                f = V.rime[tok[done[0]]]
                if f: group_family[gi] = f
                continue
            scores, fams = [], []
            used_f = collections.Counter(group_family.values())
            for f, ks in V.fam.items():
                if len(ks) < 20: continue
                ks_a = mx.array(ks)
                sc = sum(float(mx.logsumexp(lp[idx[c]][ks_a]).item()) for c in cells_g) - 1.0 * used_f.get(f, 0)
                scores.append(sc); fams.append(f)
            if fams:
                z = mx.array(scores) / max(fam_temp, 1e-6)
                group_family[gi] = fams[int(mx.random.categorical(z).item())] if fam_temp > 0 else fams[max(range(len(fams)), key=lambda i: scores[i])]
        for gi, cells_g in enumerate(groups):
            f = group_family.get(gi)
            if f and f in fam_vec:
                for cell in cells_g:
                    if idx[cell] not in committed:
                        logits[idx[cell]] = logits[idx[cell]] + fam_vec[f]
        # no repeated syllable inside a rhyme group
        for cells_g in groups:
            for cell in cells_g:
                if idx[cell] in committed: continue
                for other in cells_g:
                    if other != cell and idx[other] in committed:
                        logits[idx[cell], tok[idx[other]]] = -1e9
        if committed:
            rep = mx.array(sorted({tok[k] for k in committed if tok[k] >= 4}))
            logits[:, rep] = logits[:, rep] - 2.0                  # repetition inside the poem
        probs = mx.softmax(logits / temp, axis=-1)
        conf = mx.max(probs, axis=-1).tolist()
        # commit the most confident open cells: rhyme cells first in the first pass
        if schedule == "line":
            # วรรค-recurrent: only the current line is open; it is decided jointly (all its cells) over
            # T_line steps with every earlier line fixed, then the next line
            cur_line = min(L - 1, t // max(1, T // L))
            open_cells = [idx[c] for c in cells if idx[c] not in committed and c[0] <= cur_line]
            per_line = max(1, T // L)
            target_done = sum(lens[i] for i in range(cur_line + 1)) * min(1.0, ((t % per_line) + 1) / per_line)
            n_commit = max(1, int(math.ceil(target_done)) - len([k for k in committed if k >= TL]))
        else:
            open_cells = [idx[c] for c in cells if idx[c] not in committed]
            n_commit = max(1, math.ceil(len(cells) * (t + 1) / T) - len(committed))
        order = sorted(open_cells, key=lambda k: -conf[k])
        if t == 0:
            rh = [idx[c] for g in groups for c in g if idx[c] not in committed]
            order = list(dict.fromkeys(rh + order))
        for k in order[:n_commit]:
            choice = int(mx.random.categorical(mx.log(probs[k] + 1e-12) / 1.0).item()) if temp > 0 else int(mx.argmax(probs[k]).item())
            tok[k] = choice; committed.add(k)
        for k in open_cells:
            if k not in committed: tok[k] = MASK
    lines = ["".join(V.itos[tok[idx[(i, j)]]] for j in range(lens[i]) if tok[idx[(i, j)]] >= 4) for i in range(L)]
    return "\n".join(lines)


def decode(a):
    mx.set_default_device(mx.cpu if a.cpu else mx.gpu)
    V = Vocab.__new__(Vocab)
    V.itos = json.load(open(os.path.join(a.model, "vocab.json"), encoding="utf-8"))["itos"]
    V.stoi = {t: i for i, t in enumerate(V.itos)}
    rimes = {}; V.rime, V.tone, V.fam = [0] * len(V.itos), [5] * len(V.itos), {}
    for i, t in enumerate(V.itos):
        if i < 4: continue
        r, tn = phon(t)
        if r is None: continue
        rimes.setdefault(r, len(rimes) + 1); V.rime[i], V.tone[i] = rimes[r], tn
        V.fam.setdefault(rimes[r], []).append(i)
    V.n_rime = len(rimes) + 1
    cfg = json.load(open(os.path.join(a.model, "config.json")))
    global RIME_OF, TONE_OF
    RIME_OF, TONE_OF = mx.array(V.rime), mx.array(V.tone)
    model = Canvas(V, d=cfg["dim"], h=8, layers=cfg["layers"])
    model.load_weights(os.path.join(a.model, "model.safetensors"))
    model.eval(); mx.eval(model.parameters())
    _, line_id, pos_id, seg_id = build_arrays([{"title": [], "grid": [[] for _ in range(L)]}], V)
    rows = [json.loads(l) for l in open(a.prompts, encoding="utf-8") if l.strip()]
    if a.limit: rows = rows[:a.limit]
    drafts = {}
    if a.draft:
        for r in map(json.loads, open(a.draft, encoding="utf-8")):
            if r.get("poem") and r["id"] not in drafts:
                drafts[r["id"]] = r["poem"]
    os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
    mx.random.seed(a.seed)
    with open(a.out, "w", encoding="utf-8") as f:
        for i, r in enumerate(rows, 1):
            title = syls_of(re.split(r"\s+|[,;.!?]", r["text"])[0], TL)
            anchors, lengths = None, None
            if r["id"] in drafts:
                dl = [syls_of(ln) for ln in drafts[r["id"]].split("\n")][:L]
                if len(dl) == L and all(1 <= len(x) <= C for x in dl):
                    lengths = [min(max(len(x), 7), C) for x in dl]
                    prompt_syl = set(syls_of(r["text"]))
                    rhyme_cells = {(l - 1, lengths[l - 1] - 1 if c == -1 else c) for g in GROUPS for (l, c) in g}
                    anchors = {}
                    for li_, line in enumerate(dl):
                        for j, syl in enumerate(line[:lengths[li_]]):
                            if syl in prompt_syl and (li_, j) not in rhyme_cells and len(syl) > 1:
                                anchors[(li_, j)] = syl
                    if a.free_window is not None:            # keep the whole draft except a window around each rhyme cell
                        free = set()
                        for (li_, cj) in rhyme_cells:
                            for dj in range(-a.free_window, a.free_window + 1):
                                free.add((li_, cj + dj))
                        for li_, line in enumerate(dl):
                            for j, syl in enumerate(line[:lengths[li_]]):
                                if (li_, j) not in free and (li_, j) not in rhyme_cells:
                                    anchors[(li_, j)] = syl
                    if a.keep_frac:                          # also keep a share of the draft's other cells
                        pool = [(li_, j, syl) for li_, line in enumerate(dl) for j, syl in enumerate(line[:lengths[li_]])
                                if (li_, j) not in anchors and (li_, j) not in rhyme_cells]
                        random.seed(a.seed + i); random.shuffle(pool)
                        for (li_, j, syl) in pool[: int(a.keep_frac * len(pool))]:
                            anchors[(li_, j)] = syl
            best = None
            for attempt in range(a.retries):                       # joint decoding is cheap: retry until the checker agrees
                poem = decode_one(model, V, title, line_id, pos_id, seg_id, T=a.steps, temp=a.temp, fam_temp=a.fam_temp, anchors=anchors, lengths=lengths,
                                  topic=clauses_of(r["text"]) if a.topic_boost else None, topic_boost=a.topic_boost, schedule=a.schedule)
                s = P.score(poem, n_bot_expected=2)
                key = (s["all_pass"], s["ext_rhyme"] + s["tone"] + 0.5 * s["internal"])
                if best is None or key > best[0]:
                    best = (key, poem, s)
                if s["all_pass"]:
                    break
            _, poem, s = best
            f.write(json.dumps({"id": r["id"], "prompt": r["text"], "sample": 0, "poem": poem,
                                "all_pass": s["all_pass"], "structure": s["structure"], "ext_rhyme": s["ext_rhyme"],
                                "tone": s["tone"], "internal": s["internal"]}, ensure_ascii=False) + "\n"); f.flush()
            print(f"{i}/{len(rows)} all_pass={s['all_pass']} rhyme={s['ext_rhyme']:.2f}", file=sys.stderr, flush=True)
    P.save_cache()


if __name__ == "__main__":
    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd")
    t = sub.add_parser("train"); t.add_argument("--data", default="data/modern_klon8.jsonl"); t.add_argument("--out", default="runs/CANVAS_MLM")
    t.add_argument("--epochs", type=int, default=20); t.add_argument("--batch", type=int, default=64); t.add_argument("--lr", type=float, default=5e-4)
    t.add_argument("--dim", type=int, default=256); t.add_argument("--layers", type=int, default=6); t.add_argument("--cpu", action="store_true")
    d = sub.add_parser("decode"); d.add_argument("--model", default="runs/CANVAS_MLM"); d.add_argument("--prompts", required=True)
    d.add_argument("--out", required=True); d.add_argument("--limit", type=int, default=0); d.add_argument("--steps", type=int, default=12)
    d.add_argument("--temp", type=float, default=0.8); d.add_argument("--seed", type=int, default=0); d.add_argument("--cpu", action="store_true")
    d.add_argument("--fam-temp", type=float, default=0.5, help="temperature for sampling each rhyme link's rime family (0 = argmax)")
    d.add_argument("--retries", type=int, default=4, help="re-decode a prompt until the checker passes (keeps the best attempt)")
    d.add_argument("--draft", default=None, help="jsonl of LLM poems (id, poem): their prompt-word syllables become fixed cells")
    d.add_argument("--keep-frac", type=float, default=0.0, help="also keep this share of the draft's other non-rhyme cells")
    d.add_argument("--free-window", type=int, default=None, help="anchor the whole draft except +-W cells around each rhyme cell")
    d.add_argument("--schedule", default="stanza", choices=["stanza", "line"], help="line = decide one วรรค at a time with earlier วรรค fixed (recurrent)")
    d.add_argument("--topic-boost", type=float, default=0.0, help="logit bonus for each บาท's own prompt-clause syllables (spreads the topic over all 8 lines)")
    a = ap.parse_args()
    train(a) if a.cmd == "train" else decode(a)