"""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 ), 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 = ["", "", "", ""] + 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 ; 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 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)