Download src/scripts/twolayer_canvas.py from voraseth/klon8-base: direct link, hf CLI and curl.
- Browser
- Download file 60.7 kB
-
https://huggingface.co/voraseth/klon8-base/resolve/main/src/scripts/twolayer_canvas.py
- Command line
-
hf download hf://voraseth/klon8-base/src/scripts/twolayer_canvas.py
-
curl -L -o twolayer_canvas.py https://huggingface.co/voraseth/klon8-base/resolve/main/src/scripts/twolayer_canvas.py
60.7 kB
| """Two-layer canvas (user idea 2026-09-29): SOUND layer = one cell per pronounced syllable, WORD layer = whole written | |
| words spanning 1+ sound cells. One model, one loop. | |
| Grid: title 12 cells + 4 วรรค x 10 cells, as the current canvas. Each cell holds | |
| word-slot token (w, k): word w of the lexicon, its k-th pronounced syllable (w has n_w syllables) | |
| sound channels: onset / rime family / tone of that syllable (exact, in context: the checker's own g2p path) | |
| Embedding of (w, k) = word_emb[w] + kn_emb[(k, n_w)] (word rows initialised from WangchanBERTa pieces) | |
| Logit of (w, k) at h = z.word_emb[w] + z.kn_emb[(k, n_w)] + bias[w] with z = pretrained MLM head transform(h) | |
| So the output is a distribution over whole words placed at a position inside the word; the pretrained collocation | |
| knowledge lives in word_emb, which for 86% of word tokens is exactly one native WangchanBERTa piece. | |
| Training: whole-word masking (all cells of a word masked together, ratio U(0.15, 1.0) so the all-masked start of | |
| decoding is seen), CE on masked cells; optional: rime reveal at masked rhyme cells (the sound layer is decided first | |
| at decode), sound head (aux CE over pronounced syllables). | |
| Decode: iterative; each step commits ONE WHOLE WORD across its cells. Rhyme family and tone masks act on the sound | |
| of the cell exactly; a line-final cell only admits the LAST syllable of a word; a line-initial cell only the first. | |
| Once a group's family is chosen its rime is written into the sound layer of its open cells (input channel), then | |
| the words are chosen to fit it. | |
| twolayer_canvas.py train --data DIR --out RUN [--epochs E] [--limit N] (DIR from twolayer_data.py build) | |
| twolayer_canvas.py decode --model RUN --prompts P.jsonl --out O.jsonl | |
| twolayer_canvas.py test --data DIR (shapes, labels, overfit, decode smoke) | |
| """ | |
| import os, sys, json, math, random, argparse, collections, time | |
| sys.path[:0] = [".", "scripts"] | |
| import torch, torch.nn as nn, torch.nn.functional as F | |
| from klon8 import prosody as P | |
| WC = "airesearch/wangchanberta-base-att-spm-uncased" | |
| DEV = torch.device(os.environ.get("KLON8_DEVICE") or ("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")) | |
| C, TL, L = 10, 12, 4 | |
| T = TL + L * C | |
| PAD, MASK, UNK, SEP = 0, 1, 2, 3 # word ids AND token ids (specials have k=0, n=1) | |
| KMAX = 8 # longest word in the lexicon (8 syllables) | |
| EVEN, CHECKED = (0, 3), (1, 2, 4) | |
| TONE_AT = {1: CHECKED, 2: EVEN, 3: EVEN} # วรรค index (0-based) -> allowed final tones | |
| # rhyme groups, (line 0-based, col) with col -1 = line-final; the receiver (col 2) may move to col 4 (auto) | |
| GROUPS = [[(0, -1), (1, 2)], [(1, -1), (2, -1), (3, 2)]] | |
| ROLE = [1, 2, 3, 4] | |
| # ------------------------------------------------------------------ lexicon / tables | |
| class Lex: | |
| """Words, word-slot tokens and sound tables.""" | |
| def __init__(self, lex_json): | |
| d = json.load(open(lex_json, encoding="utf-8")) if isinstance(lex_json, str) else lex_json | |
| self.words = ["<pad>", "<mask>", "<unk>", "<sep>"] + list(d["words"]) | |
| self.sounds = d["sounds"]; self.count = d.get("count", {}) | |
| self.word_seen = torch.tensor([i >= 4 and self.count.get(w, 0) > 0 for i, w in enumerate(["<pad>", "<mask>", "<unk>", "<sep>"] + list(d["words"]))]) | |
| self.wid = {w: i for i, w in enumerate(self.words)} | |
| # sound inventory: every syllable of a lexicon word (data cells outside it -> id 0 "unknown") | |
| phons = sorted({p for w in d["words"] for p in self.sounds[w]}) | |
| self.phons = ["?"] + phons; self.pid = {p: i for i, p in enumerate(self.phons)} | |
| on, ri = {"?": 0}, {"?": 0} | |
| self.p_on, self.p_ri, self.p_to = [0], [0], [5] | |
| for p in phons: | |
| s = P._parse_syl("", p) | |
| self.p_on.append(on.setdefault(s.onset, len(on))) | |
| self.p_ri.append(ri.setdefault(P._MERGED_RIME.get(s.rime, s.rime), len(ri))) | |
| self.p_to.append(s.tone if 0 <= s.tone <= 4 else 5) # g2p can emit a stray tone digit ('sip8'); 5 = unknown | |
| self.rimes = ri; self.n_on, self.n_ri = len(on), len(ri) | |
| assert set(self.p_to) <= set(range(6)), 'tone ids must index to_emb (6 rows)' | |
| # tokens: specials 0..3, then for each word its n_w slots | |
| tw, tk, tn, ts = [0, 1, 2, 3], [0] * 4, [1] * 4, [0] * 4 | |
| self.first = [0, 1, 2, 3] | |
| for w in self.words[4:]: | |
| self.first.append(len(tw)); sy = self.sounds[w] | |
| for k, p in enumerate(sy): | |
| tw.append(self.wid[w]); tk.append(k); tn.append(len(sy)); ts.append(self.pid[p]) | |
| self.tok_word, self.tok_k, self.tok_n, self.tok_sound = map(torch.tensor, (tw, tk, tn, ts)) | |
| self.kn_of = lambda k, n: n * (n - 1) // 2 + k # (k, n) pairs, n <= KMAX | |
| self.tok_kn = torch.tensor([self.kn_of(k, n) for k, n in zip(tk, tn)]) | |
| self.n_kn = self.kn_of(KMAX - 1, KMAX) + 1 | |
| self.tok_ri = torch.tensor([self.p_ri[s] for s in ts]); self.tok_to = torch.tensor([self.p_to[s] for s in ts]) | |
| self.tok_on = torch.tensor([self.p_on[s] for s in ts]) | |
| self.tok_last = self.tok_k == self.tok_n - 1 | |
| self.NT = len(tw) | |
| def tok(self, w, k): | |
| i = self.wid.get(w, UNK) | |
| return i if i < 4 else self.first[i] + k | |
| def encode(rec, X): | |
| """poem record -> (tok[T], sound[T], word_span[T]) ; word_span = index of the word occurrence (for whole-word masking).""" | |
| tok = torch.full((T,), PAD, dtype=torch.long); snd = torch.zeros(T, dtype=torch.long) | |
| span = torch.full((T,), -1, dtype=torch.long); occ = -1 | |
| def put(base, cells): | |
| nonlocal occ | |
| for j, (w, k, n, ph) in enumerate(cells): | |
| if k == 0: occ += 1 | |
| tok[base + j] = X.tok(w, k); snd[base + j] = X.pid.get(ph, 0); span[base + j] = occ | |
| put(0, rec["title"]) | |
| for i, g in enumerate(rec["grid"]): put(TL + i * C, g) | |
| return tok, snd, span | |
| def rhyme_cells(rec): | |
| """cells whose rime is part of a rhyme link in the DATA (line-final of every วรรค; receiver 3rd/5th if it rhymes).""" | |
| # line-final cells of วรรค 1-3 (the ส่ง line's final is in no rhyme group of a one-บท poem: never revealed at decode) | |
| out = [TL + i * C + len(g) - 1 for i, g in enumerate(rec["grid"][:3])] | |
| def rk(ph): s = P._parse_syl("", ph); return P._MERGED_RIME.get(s.rime, s.rime) | |
| for snd_line, rcv_line in ((0, 1), (1, 3)): | |
| src = rk(rec["grid"][snd_line][-1][3]) | |
| for col in (2, 4): | |
| g = rec["grid"][rcv_line] | |
| if col < len(g) - 1 and rk(g[col][3]) == src: out.append(TL + rcv_line * C + col); break | |
| return out | |
| # ------------------------------------------------------------------ model | |
| _PIECES = {} | |
| def piece_ids(X, wc_tok): | |
| """WangchanBERTa piece ids of every lexicon word (the leading "▁" dropped), computed once per process""" | |
| key = (len(X.words), X.words[4] if len(X.words) > 4 else "", X.words[-1]) | |
| if key not in _PIECES: | |
| sp = wc_tok.convert_tokens_to_ids("▁") | |
| out = [[]] * 4 | |
| for w in X.words[4:]: | |
| ids = [t for t in wc_tok(w, add_special_tokens=False)["input_ids"] if t != sp] | |
| out.append(ids or [wc_tok.unk_token_id]) | |
| _PIECES[key] = out | |
| return _PIECES[key] | |
| class TwoLayer(nn.Module): | |
| def __init__(self, X, wc_tok=None, wc=None, lm_head=None, peer=False, d=768, body=None, ln_in=None, abs_pos=True, | |
| bias_mode="unigram", peer_in=True, peer_tied=False): | |
| super().__init__() | |
| self.peer_in, self.peer_tied = peer_in, peer_tied | |
| self.use_abs_pos = abs_pos | |
| self.X = X | |
| if wc is not None: | |
| body, ln_in, d = wc.encoder, wc.embeddings.LayerNorm, wc.config.hidden_size | |
| self.body, self.ln_in = body, ln_in | |
| W = len(X.words) | |
| self.word_emb = nn.Embedding(W, d) | |
| pre_bias = torch.zeros(W) | |
| if wc is not None: | |
| # every row (poem words AND unseen dictionary / WangchanBERTa words) from the pretrained pieces: the input row is | |
| # the mean of the pieces' embeddings (tied output), the output bias the mean of the pieces' pretrained MLM bias | |
| base = wc.embeddings.word_embeddings.weight.detach(); init = torch.zeros(W, d) | |
| pb = lm_head["bias"] if lm_head is not None else torch.zeros(base.shape[0]) | |
| sp = [wc_tok.pad_token_id, wc_tok.mask_token_id, wc_tok.unk_token_id, wc_tok.sep_token_id] | |
| pieces = piece_ids(X, wc_tok) # cached once per process (62k words) | |
| for i, w in enumerate(X.words): | |
| if i < 4: init[i] = base[sp[i]]; continue | |
| ids = pieces[i] | |
| init[i] = base[ids].mean(0); pre_bias[i] = pb[ids].mean() | |
| self.word_emb.weight.data.copy_(init) | |
| self.kn_emb = nn.Embedding(X.n_kn, d); nn.init.normal_(self.kn_emb.weight, std=0.02) | |
| # the body's own PRETRAINED absolute positions (RoBERTa offset 2) + token-type row: without them masked cells in a | |
| # run get near-identical inputs (one-batch overfit stalled at 0.53 with every cell of a masked run predicting | |
| # one word; the syllable canvas has the same omission - its fully-masked loss is 6.47 vs unigram 7.01) | |
| # and a leading <s> cell, always attended: the pretrained body uses <s> as its attention sink; with PAD masked | |
| # out and no <s>, poems with short titles collapsed (every masked cell the same hidden state, cos 1.0) | |
| self.abs_pos = nn.Parameter(torch.zeros(T, d)); self.bos = nn.Parameter(torch.zeros(d)) | |
| if wc is not None: | |
| pe, te = wc.embeddings.position_embeddings.weight.detach(), wc.embeddings.token_type_embeddings.weight.detach()[0] | |
| self.abs_pos.data.copy_(pe[3:3 + T] + te) | |
| self.bos.data.copy_(wc.embeddings.word_embeddings.weight.detach()[wc_tok.bos_token_id] + pe[2] + te) | |
| self.on_emb = nn.Embedding(X.n_on, d); self.ri_emb = nn.Embedding(X.n_ri, d); self.to_emb = nn.Embedding(6, d) | |
| self.line = nn.Embedding(L + 1, d); self.pos = nn.Embedding(max(TL, C), d); self.role = nn.Embedding(5, d) | |
| for e in (self.on_emb, self.ri_emb, self.to_emb, self.line, self.pos, self.role): nn.init.normal_(e.weight, std=0.02) | |
| self.head_dense = nn.Linear(d, d); self.head_ln = nn.LayerNorm(d, eps=1e-5) | |
| # output bias: "pretrained" (default) = WangchanBERTa's own MLM bias for the word's pieces, so a word never seen in | |
| # the poems keeps the prior the pretrained LM gives it; "unigram" = centred log poem frequency (FIX 1's choice for | |
| # the syllable canvas, where every row is a poem unit). Chosen by step-0 masked loss (twolayer_test.py --bias-check). | |
| # "hybrid": poem words get the unigram bias; unseen words get the pretrained bias mapped onto the unigram scale | |
| # by a least-squares line fitted on the poem words (so their prior follows WangchanBERTa, not a flat floor) | |
| cnt = torch.tensor([float(X.count.get(w, 0)) for w in X.words]) | |
| uni = torch.log((cnt + 1) / (cnt.sum() + W)) | |
| if bias_mode == "unigram": bias = uni | |
| elif bias_mode == "pretrained": bias = pre_bias.clone() | |
| else: | |
| seen = X.word_seen | |
| A = torch.stack([pre_bias[seen], torch.ones(int(seen.sum()))], 1) | |
| coef = torch.linalg.lstsq(A, uni[seen][:, None]).solution[:, 0] | |
| bias = torch.where(seen, uni, coef[0] * pre_bias + coef[1]) | |
| self.bias_fit = (float(coef[0]), float(coef[1])) | |
| bias = bias - bias[4:].mean() | |
| bias[:4] = -30.0 # specials are never targets: keep them out of the partition (review: +12 nats) | |
| self.word_bias = nn.Parameter(bias) | |
| # never-in-poems rows stay frozen (train --unseen freeze); ONE learnable scalar shifts all of them together, so the | |
| # model can set how much mass unseen words get without pushing each row down (review 2026-09-29) | |
| self.unseen_off = nn.Parameter(torch.zeros(())) | |
| unseen = (~X.word_seen).float(); unseen[:4] = 0.0 | |
| self.register_buffer("unseen_ind", unseen, persistent=False) | |
| if lm_head is not None: | |
| self.head_dense.weight.data.copy_(lm_head["dense.weight"]); self.head_dense.bias.data.copy_(lm_head["dense.bias"]) | |
| self.head_ln.weight.data.copy_(lm_head["layer_norm.weight"]); self.head_ln.bias.data.copy_(lm_head["layer_norm.bias"]) | |
| # SAME-LEVEL variant (user 2026-09-29 "maybe they both has to be at the same level"): the sound layer is a peer | |
| # stream with its own identity embedding (input) and its own head (output) over the pronounced syllables; rows | |
| # start from the mean WangchanBERTa embedding of the one-syllable lexicon words with that sound. The head is | |
| # UNTIED from the input embedding (review 2026-09-29: tied, the one-batch overfit stuck at ~3.1 nats on one | |
| # poem whose masked cells all collapsed to one hidden state, cos 1.0; untied it reached 0.009 in 150 steps) | |
| self.peer = bool(peer) | |
| if self.peer: | |
| self.snd_emb = nn.Embedding(len(X.phons), d) | |
| if peer_tied: self.snd_bias = nn.Parameter(torch.zeros(len(X.phons))) # only the (unused) tied variant has it | |
| with torch.no_grad(): | |
| self.snd_emb.weight.normal_(0, 0.02, generator=torch.Generator().manual_seed(21)); self.snd_emb.weight[0] = 0 | |
| acc = torch.zeros(len(X.phons), d); n_ = torch.zeros(len(X.phons)) | |
| for i, w in enumerate(X.words): | |
| if i >= 4 and len(X.sounds[w]) == 1: | |
| acc[X.pid[X.sounds[w][0]]] += self.word_emb.weight[i]; n_[X.pid[X.sounds[w][0]]] += 1 | |
| has = n_ > 0; has[0] = False | |
| self.snd_emb.weight[has] = acc[has] / n_[has, None] | |
| self.register_buffer("tok_sound", X.tok_sound.clone(), persistent=False) | |
| if not peer_tied: | |
| self.snd_out = nn.Linear(d, len(X.phons)) | |
| with torch.no_grad(): self.snd_out.weight.copy_(self.snd_emb.weight); self.snd_out.bias.zero_() | |
| # the INPUT identity channel starts at ZERO and grows (software-testing: gate new channels at zero): added | |
| # at full word-embedding scale it collapsed whole poems in the one-batch overfit (every masked cell one | |
| # hidden state, cos 1.000, on 2 of 8 poems; without the input channel: no collapse) | |
| with torch.no_grad(): self.snd_emb.weight.zero_() | |
| for n_, t_ in (("tok_word", X.tok_word), ("tok_kn", X.tok_kn), ("tok_on", X.tok_on), ("tok_ri", X.tok_ri), | |
| ("tok_to", X.tok_to), ("p_on", torch.tensor(X.p_on)), ("p_ri", torch.tensor(X.p_ri)), ("p_to", torch.tensor(X.p_to))): | |
| self.register_buffer(n_, t_, persistent=False) | |
| self.register_buffer("line_id", torch.tensor([0] * TL + [1 + i for i in range(L) for _ in range(C)]), persistent=False) | |
| self.register_buffer("pos_id", torch.tensor(list(range(TL)) + [j for _ in range(L) for j in range(C)]), persistent=False) | |
| self.register_buffer("role_id", torch.tensor([0] * TL + [ROLE[i] for i in range(L) for _ in range(C)]), persistent=False) | |
| def embed(self, tok, snd, ri_in): | |
| """tok: word-slot ids; snd: sound id of KNOWN cells (0 = unknown); ri_in: revealed rime at masked cells (0 = none).""" | |
| known = (tok != MASK) & (snd > 0) | |
| on = torch.where(known, self.p_on[snd], torch.zeros_like(snd)) | |
| ri = torch.where(known, self.p_ri[snd], ri_in) | |
| to = torch.where(known, self.p_to[snd], torch.full_like(snd, 5)) | |
| x = (self.word_emb(self.tok_word[tok]) + self.kn_emb(self.tok_kn[tok]) + self.on_emb(on) + self.ri_emb(ri) | |
| + (self.snd_emb(torch.where(known, snd, torch.zeros_like(snd))) if (self.peer and self.peer_in) else 0) | |
| + self.to_emb(to) + self.line(self.line_id)[None] + self.pos(self.pos_id)[None] + self.role(self.role_id)[None] | |
| + (self.abs_pos[None] if self.use_abs_pos else 0)) | |
| return self.ln_in(x) | |
| def hidden(self, tok, snd, ri_in): | |
| B = tok.shape[0] | |
| keep = torch.cat([torch.ones(B, 1, dtype=torch.bool, device=tok.device), tok != PAD], 1) | |
| ext = (~keep)[:, None, None, :].float() * -1e4 # PAD is never attended (audit: PAD sink) | |
| x = torch.cat([self.ln_in(self.bos)[None, None].expand(B, 1, -1), self.embed(tok, snd, ri_in)], 1) | |
| h = self.body(x, attention_mask=ext).last_hidden_state[:, 1:] | |
| return self.head_ln(F.gelu(self.head_dense(h))) | |
| def eff_bias(self): | |
| return self.word_bias + self.unseen_off * self.unseen_ind | |
| def token_logits(self, z): | |
| """z [..., d] -> logits over ALL word-slot tokens [..., NT]""" | |
| wl = z @ self.word_emb.weight.T + self.eff_bias() # [..., W] | |
| kl = z @ self.kn_emb.weight.T # [..., KN] | |
| return wl[..., self.tok_word] + kl[..., self.tok_kn] | |
| def token_nll(self, z, y): | |
| """exact cross-entropy over all word-slot tokens without building [N, NT]: logsumexp over tokens = | |
| logsumexp over words of (word logit + logsumexp of the slot logits k < n_w) - the slot term depends only on n_w""" | |
| wl = z @ self.word_emb.weight.T + self.eff_bias() # [N, W] | |
| kl = z @ self.kn_emb.weight.T # [N, KN] | |
| if not hasattr(self, "_n_of_word") or self._n_of_word.device != z.device: | |
| nw = torch.zeros(len(self.X.words), dtype=torch.long).index_reduce_(0, self.X.tok_word, self.X.tok_n, "amax", include_self=False) | |
| kn_lists = [[self.X.kn_of(k, n) for k in range(n)] for n in range(1, KMAX + 1)] | |
| self._n_of_word = (nw - 1).to(z.device) | |
| self._kn_pad = torch.tensor([l + [l[0]] * (KMAX - len(l)) for l in kn_lists], device=z.device) # [KMAX, KMAX] | |
| self._kn_valid = torch.tensor([[q < len(l) for q in range(KMAX)] for l in kn_lists], device=z.device) | |
| slot = kl[:, self._kn_pad].masked_fill(~self._kn_valid[None], float("-inf")).logsumexp(-1) # [N, KMAX] per word length | |
| logZ = (wl + slot[:, self._n_of_word]).logsumexp(-1) | |
| return (logZ - wl.gather(1, self.tok_word[y][:, None])[:, 0] - kl.gather(1, self.tok_kn[y][:, None])[:, 0]).mean() | |
| def sound_logits(self, z): | |
| if not self.peer_tied: return self.snd_out(z) | |
| return z @ self.snd_emb.weight.T + self.snd_bias | |
| def forward(self, tok, snd, ri_in): | |
| z = self.hidden(tok, snd, ri_in) | |
| lg = self.token_logits(z) | |
| if self.peer: | |
| # joint score of a consistent (sound, word-slot) pair, a product of experts: log p_word(w,k) + log p_sound(s(w,k)). | |
| # The sound is NOT counted twice: the two heads are separate experts over the same pair, and Decoder.lp | |
| # renormalises the sum over all consistent pairs (log_softmax), i.e. p(w,k) ∝ p_word(w,k) p_sound(s(w,k)). | |
| lg = F.log_softmax(lg, -1) + F.log_softmax(self.sound_logits(z), -1)[..., self.tok_sound] | |
| return lg, z | |
| SCHEMES = ("word", "cell", "wak", "all") | |
| MIX = (0.4, 0.2, 0.25, 0.15) # one model for every decode unit: each masking scheme is a state some unit meets | |
| def mask_words(tok, span, gen, lo=0.15, hi=1.0, mix=MIX): | |
| """Mixed masking of the poem cells, one scheme per poem (so one model serves all four decode units): | |
| word: whole words masked together, ratio ~ U(lo, hi) (word unit; refinement states of wak/bot) | |
| cell: cells masked independently, ratio ~ U(lo, hi) (syllable unit: part of a word committed) | |
| wak: วรรค i..4 fully masked for a random i, earlier วรรค visible (wak unit, line by line) | |
| all: every poem cell masked (first step of every unit; bot unit)""" | |
| B = tok.shape[0] | |
| ratio = torch.rand(B, 1, generator=gen) * (hi - lo) + lo | |
| u_w = torch.rand(B, 64, generator=gen) # one draw per word occurrence | |
| u_c = torch.rand(B, T, generator=gen) | |
| sch = torch.multinomial(torch.tensor(mix, dtype=torch.float), B, replacement=True, generator=gen) | |
| first = torch.randint(0, L, (B, 1), generator=gen) | |
| line_of = torch.tensor([-1] * TL + [i for i in range(L) for _ in range(C)])[None, :] | |
| real = (torch.arange(T)[None, :] >= TL) & (tok != PAD) | |
| mw = torch.gather(u_w, 1, span.clamp(min=0)) < ratio | |
| mc = u_c < ratio | |
| mk = line_of >= first | |
| m = torch.where((sch == 0)[:, None], mw, torch.where((sch == 1)[:, None], mc, torch.where((sch == 2)[:, None], mk, torch.ones_like(mw)))) | |
| return real & m | |
| def batch_loss(model, tok, snd, span, rcells, gen, reveal=0.0, sound_w=1.0, m=None): | |
| """random draws on the CPU generator (reproducible on any device); every tensor is moved to the model's device | |
| BEFORE it meets a model buffer (review 2026-09-29: CPU masks x MPS buffers crashed at step 0)""" | |
| dev = model.word_bias.device | |
| if m is None: m = mask_words(tok, span, gen) | |
| rv = rcells & m & (torch.rand(tok.shape, generator=gen) < reveal) if reveal else torch.zeros_like(m) | |
| tok, snd, m, rv = tok.to(dev), snd.to(dev), m.to(dev), rv.to(dev) | |
| x = torch.where(m, torch.full_like(tok, MASK), tok) | |
| s_in = torch.where(m, torch.zeros_like(snd), snd) | |
| ri_in = torch.where(rv, model.p_ri[snd], torch.zeros_like(snd)) | |
| z = model.hidden(x, s_in, ri_in) | |
| tgt = m & (tok >= 4) # UNK / specials not trained as targets | |
| loss = model.token_nll(z[tgt], tok[tgt]) | |
| out = {"mlm": float(loss)} | |
| if model.peer: # the sound stream's own loss at the same masked cells (both streams of a cell are masked together) | |
| sm = m & (snd > 0) | |
| ls = F.cross_entropy(model.sound_logits(z[sm]), snd[sm]); out["sound"] = float(ls) | |
| loss = loss + sound_w * ls | |
| return loss, out | |
| def load_recs(data_dir, X, limit=0): | |
| recs = [json.loads(l) for l in open(os.path.join(data_dir, "poems.jsonl"), encoding="utf-8")] | |
| if limit: recs = recs[:limit] | |
| enc = [encode(r, X) for r in recs] | |
| TOK = torch.stack([e[0] for e in enc]); SND = torch.stack([e[1] for e in enc]); SPAN = torch.stack([e[2] for e in enc]) | |
| RC = torch.zeros(len(recs), T, dtype=torch.bool) | |
| for n, r in enumerate(recs): RC[n, rhyme_cells(r)] = True | |
| return recs, TOK, SND, SPAN, RC | |
| def build_model(X, peer=False, abs_pos=True, bias_mode="unigram", **kw): | |
| from transformers import AutoTokenizer, AutoModel, AutoModelForMaskedLM | |
| wc_tok = AutoTokenizer.from_pretrained(WC); wc = AutoModel.from_pretrained(WC) | |
| full = AutoModelForMaskedLM.from_pretrained(WC).state_dict() | |
| lmh = {k[len("lm_head."):]: v.detach().clone() for k, v in full.items() if k.startswith("lm_head.") and not k.startswith("lm_head.decoder")} | |
| del full | |
| return TwoLayer(X, wc_tok, wc, lm_head=lmh, peer=peer, abs_pos=abs_pos, bias_mode=bias_mode, **kw) | |
| def eval_metrics(model, X, TOK, SND, SPAN, RC, idx, batch=32): | |
| """validation: mixed-masking loss; probability mass on words never seen in the poems (must not collapse to ~0); | |
| rime-use check: loss at masked rhyme cells of all-masked poems with the rime revealed vs hidden (the sound layer is | |
| set first at decode; if revealing it does not lower the loss, the channel is unused)""" | |
| model.eval(); vg = torch.Generator().manual_seed(1); dev = model.word_bias.device | |
| unseen_tok = (~X.word_seen[X.tok_word]).to(dev); unseen_tok[:4] = False | |
| tot = n = 0; mass = []; rv = {0: [], 1: []} | |
| for s_ in range(0, len(idx), batch): | |
| b = idx[s_:s_ + batch]; tok, snd, span, rc = TOK[b], SND[b], SPAN[b], RC[b] | |
| l, _ = batch_loss(model, tok, snd, span, rc, vg); tot += float(l) * len(b); n += len(b) | |
| m = mask_words(tok, span, vg, mix=(0, 0, 0, 1)) # all masked | |
| tok, snd, m, rc = tok.to(dev), snd.to(dev), m.to(dev), rc.to(dev) | |
| x = torch.where(m, torch.full_like(tok, MASK), tok); s_in = torch.where(m, torch.zeros_like(snd), snd) | |
| tgt = m & (tok >= 4) & rc | |
| for r in (0, 1): | |
| ri = torch.where(tgt & (r == 1), model.p_ri[snd], torch.zeros_like(snd)) | |
| z = model.hidden(x, s_in, ri) | |
| lg = model.token_logits(z[tgt]).float() | |
| rv[r].append(F.cross_entropy(lg, tok[tgt], reduction="none").cpu()) | |
| if r == 0: mass.append(F.softmax(lg, -1)[:, unseen_tok].sum(-1).cpu()) | |
| rv0, rv1 = torch.cat(rv[0]).mean(), torch.cat(rv[1]).mean() | |
| return {"valid": tot / max(n, 1), "unseen_mass": float(torch.cat(mass).mean()) if mass else 0.0, | |
| "rhyme_loss_hidden": float(rv0), "rhyme_loss_revealed": float(rv1)} | |
| def make_opt(model, lr, snd_lr_mult=20.0): | |
| """AdamW groups: body/embeddings (wd 0.01); biases/positions/offset (no decay); the peer model's sound INPUT channel | |
| in its own group at snd_lr_mult x lr, no decay (re-review 2026-09-29: zero-initialised at the body's lr it grew to ~1% of | |
| the word rows' norm in a one-batch run - nearly dead; the user's idea needs sound used as an input peer)""" | |
| no_decay = {"word_bias", "bos", "abs_pos", "unseen_off"}; own = {"snd_emb.weight"} | |
| P_ = dict(model.named_parameters()) | |
| groups = [{"params": [p for n_, p in P_.items() if n_ not in no_decay | own], "weight_decay": 0.01, "lr": lr}, | |
| {"params": [p for n_, p in P_.items() if n_ in no_decay], "weight_decay": 0.0, "lr": lr}] | |
| if "snd_emb.weight" in P_: groups.append({"params": [P_["snd_emb.weight"]], "weight_decay": 0.0, "lr": lr * snd_lr_mult}) | |
| return torch.optim.AdamW(groups, lr=lr) | |
| def sound_input_use(model, X, TOK, SND, SPAN, RC, idx): | |
| """peer model: sound-input row norm / word row norm (rows in use) and the valid-loss change when it is zeroed""" | |
| used = torch.unique(SND[idx][SND[idx] > 0]); dev = model.word_bias.device | |
| ratio = float(model.snd_emb.weight[used.to(dev)].norm(dim=-1).mean() / model.word_emb.weight[4:].norm(dim=-1).mean()) | |
| full = eval_metrics(model, X, TOK, SND, SPAN, RC, idx)["valid"] | |
| keep = model.snd_emb.weight.clone(); model.snd_emb.weight.zero_() | |
| zero = eval_metrics(model, X, TOK, SND, SPAN, RC, idx)["valid"] | |
| model.snd_emb.weight.copy_(keep) | |
| return ratio, zero - full | |
| def train(a): | |
| random.seed(0); torch.manual_seed(0) | |
| X = Lex(os.path.join(a.data, "lexicon.json")) | |
| recs, TOK, SND, SPAN, RC = load_recs(a.data, X, a.limit) | |
| perm = torch.randperm(len(recs), generator=torch.Generator().manual_seed(0)) | |
| nv = min(a.valid, len(recs) // 5); vi, ti = perm[:nv], perm[nv:] | |
| model = build_model(X, peer=a.peer, bias_mode=a.bias).to(DEV) | |
| unseen = ~X.word_seen; unseen[:4] = False | |
| print(f"lexicon {len(X.words) - 4:,} words ({int(unseen.sum()):,} never in the poems), {X.NT:,} word-slot tokens; " | |
| f"poems {len(recs):,} (train {len(ti):,}, valid {nv}); bias {a.bias}; unseen rows {a.unseen}", flush=True) | |
| unseen_d = unseen.to(DEV) | |
| keep_e = model.word_emb.weight.detach()[unseen_d].clone(); keep_b = model.word_bias.detach()[unseen_d].clone() | |
| keep_e_d, keep_b_d = keep_e, keep_b | |
| opt = make_opt(model, a.lr, a.snd_lr_mult) | |
| steps = a.epochs * (len(ti) // a.batch) | |
| sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=[g["lr"] for g in opt.param_groups], total_steps=max(steps, 1), pct_start=0.06) | |
| gen = torch.Generator().manual_seed(0); os.makedirs(a.out, exist_ok=True); t0 = time.time() | |
| json.dump({"lexicon": os.path.abspath(os.path.join(a.data, "lexicon.json")), "peer": a.peer, "peer_tied": False, "bias": a.bias, | |
| "unseen": a.unseen}, open(os.path.join(a.out, "config.json"), "w")) | |
| e0 = eval_metrics(model, X, TOK, SND, SPAN, RC, vi[:256]); print(f"== init {e0}", flush=True) | |
| for ep in range(a.epochs): | |
| model.train(); order = ti[torch.randperm(len(ti), generator=gen)] | |
| for it in range(len(ti) // a.batch): | |
| b = order[it * a.batch:(it + 1) * a.batch] | |
| loss, o = batch_loss(model, TOK[b], SND[b], SPAN[b], RC[b], gen, a.reveal, a.sound_w) | |
| if a.unseen == "anchor": | |
| reg = ((model.word_emb.weight[unseen_d] - keep_e.to(DEV)) ** 2).sum(-1).mean() + ((model.word_bias[unseen_d] - keep_b.to(DEV)) ** 2).mean() | |
| loss = loss + a.anchor_w * reg; o["anchor"] = float(reg) | |
| opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0); opt.step(); sched.step() | |
| if a.unseen == "freeze": # rows of never-seen words stay at their pretrained init (no push-down) | |
| with torch.no_grad(): | |
| model.word_emb.weight[unseen_d] = keep_e_d; model.word_bias[unseen_d] = keep_b_d | |
| if it % 50 == 0: | |
| mem = f" mps {torch.mps.driver_allocated_memory() / 1e9:.1f}GB" if DEV.type == "mps" else "" | |
| print(f"ep {ep} it {it}/{len(ti) // a.batch} {o} {time.time() - t0:.0f}s{mem}", flush=True) | |
| if a.max_steps and ep * (len(ti) // a.batch) + it + 1 >= a.max_steps: break | |
| e = eval_metrics(model, X, TOK, SND, SPAN, RC, vi) | |
| off = float(model.unseen_off) | |
| alarm = ("HIGH" if e["unseen_mass"] > 0.05 else "LOW" if e["unseen_mass"] < 0.001 else "ok") # human 0.0054 | |
| bad = [k for k, v in e.items() if not math.isfinite(v)] + ([] if math.isfinite(off) else ["unseen_off"]) | |
| print(f"== epoch {ep} valid masked-loss {e['valid']:.3f} unseen-mass {e['unseen_mass']:.4f} ({alarm}) unseen-offset " | |
| f"{off:+.3f} rhyme-loss hidden {e['rhyme_loss_hidden']:.3f} revealed {e['rhyme_loss_revealed']:.3f}", flush=True) | |
| if bad: print(f"STOPPING: non-finite {bad}", flush=True); sys.exit(1) | |
| if model.peer: | |
| ratio, delta = sound_input_use(model, X, TOK, SND, SPAN, RC, vi[:256]) | |
| low = ep >= 3 and ratio < 0.10 # prereg: LOW if the sound input is < 10% of the word rows by epoch 4 | |
| print(f"== sound-input epoch {ep}: row norm / word row norm {ratio:.3f}, zero-ablation valid-loss delta " | |
| f"{delta:+.4f} nats{' (LOW: sound input < 10% of word norm)' if low else ''}", flush=True) | |
| torch.save(model.state_dict(), os.path.join(a.out, "model.pt")); print(f" saved model.pt (epoch {ep})", flush=True) | |
| if a.max_steps and (ep + 1) * (len(ti) // a.batch) >= a.max_steps: break | |
| # ------------------------------------------------------------------ decoding | |
| # PRONOUNCED syllables per วรรค in human บท (the fixed base's --wak-lens sample table, canvas_bot.WAK_LEN_DIST) | |
| WAK_LEN_DIST = {0: {7: 16, 8: 764, 9: 213, 10: 7}, 1: {7: 6, 8: 782, 9: 207, 10: 6}, | |
| 2: {7: 10, 8: 740, 9: 240, 10: 10}, 3: {7: 7, 8: 714, 9: 267, 10: 11}} | |
| def pick_lens(rng): | |
| out = [] | |
| for i in range(L): | |
| d = WAK_LEN_DIST[i]; r = rng.random() * sum(d.values()) | |
| for k, v in sorted(d.items()): | |
| r -= v | |
| if r <= 0: out.append(k); break | |
| else: out.append(8) | |
| return out | |
| def title_input(X, text): | |
| import twolayer_data as TD, title_clean | |
| cells = TD.line_cells(title_clean.clean_title(text)) or [] | |
| tok = torch.full((T,), PAD, dtype=torch.long); snd = torch.zeros(T, dtype=torch.long); n = 0; words = [] | |
| for (w, k, nw, s) in cells: | |
| if k == 0 and n + nw > TL: break | |
| tok[n] = X.tok(w, k); snd[n] = X.pid.get(s.phon, 0); n += 1 | |
| if k == 0: words.append(w) | |
| return tok, snd, words | |
| class Decoder: | |
| """Iterative decoding on the two-layer canvas with a selectable COMMIT UNIT (user 2026-09-29: "try 'em all"): | |
| syllable one sound cell per step; the word layer still binds it: a cell committed as (w, k) forces the other | |
| cells of w's span to (w, k') - so a syllable is always part of a real word | |
| word one whole word per step, across its sound cells (placement = mean log-prob over its cells) | |
| wak one วรรค per step, all its cells jointly: an exact sample of a whole-word tiling of the line (forward- | |
| filtering backward-sampling over the per-cell log-probs), then Mask-Predict refinement inside the line | |
| bot all 4 วรรค jointly in one step (a tiling per line from ONE forward pass), then refinement over the stanza | |
| Rhyme / tone / count act on the SOUND layer in every unit: count = number of cells; tone masks at line-final cells; | |
| a line-final cell admits only a word's LAST syllable, a line-initial cell only a FIRST; a rhyme cell admits only | |
| tokens whose sound has the group's rime family. How the family is set: | |
| syllable / word: family chosen (after fam_delay steps) from the model's family mass on the group's cells, as the | |
| current canvas does; rhyme cells are held until then; the chosen rime is written into the sound layer | |
| (ri_in) of the group's open cells before any word is chosen for them | |
| wak: the SENDER's line-final word is sampled inside the line's joint tiling with no family mask (only its | |
| tone class); that word fixes the family; receivers are later lines, tiled under the family mask, the | |
| receiving position (3rd or 5th) chosen by the tiling's log-partition under each | |
| bot: ONE forward pass from the all-masked canvas; the 4 lines are tiled in order from it, so each sender line | |
| fixes its group's family (as wak) before its receivers are tiled - no family off the blank canvas | |
| Refinement (wak, bot): R rounds; each re-masks the lowest-scoring fraction of words (score = mean log-prob of the word | |
| when it was drawn) and re-tiles the gaps; families stay fixed, so re-drawn rhyme words keep the rhyme.""" | |
| def __init__(self, model, X, title_text, lens=None, unit="word", temp=0.8, fam_temp=0.5, fam_delay=4, topic_boost=1.5, | |
| rep_pen=2.0, rhyme_pos="auto", refine=3, gen=None, trace=None): | |
| self.m, self.X, self.unit, self.temp, self.fam_temp, self.fam_delay = model, X, unit, temp, fam_temp, fam_delay | |
| self.rep_pen, self.rhyme_pos, self.refine, self.trace = rep_pen, rhyme_pos, refine, trace | |
| self.gen = gen or torch.Generator().manual_seed(0) | |
| self.lens = lens or [8] * L | |
| self.tok, self.snd, twords = title_input(X, title_text) | |
| self.ri_in = torch.zeros(T, dtype=torch.long) | |
| for i in range(L): | |
| for j in range(self.lens[i]): self.tok[self.idx(i, j)] = MASK | |
| self.committed, self.forced, self.fam, self.gpos = set(), {}, {}, {} | |
| self.wscore = {} # (line, start) -> (word id, mean log-prob when drawn) | |
| NT = X.NT; self.NT = NT | |
| self.base = torch.zeros(NT); self.base[:4] = -1e9 | |
| self.tone_ok = {i: torch.tensor([(t in allowed) for t in X.tok_to.tolist()]) for i, allowed in TONE_AT.items()} | |
| topic = torch.zeros(NT) | |
| if topic_boost: | |
| for w in twords: | |
| wi = X.wid.get(w, UNK) | |
| if wi >= 4 and len(w) > 1: topic[X.first[wi]: X.first[wi] + len(X.sounds[w])] = topic_boost | |
| self.topic_rows = [topic.clone() for _ in range(L)] | |
| res = lambda g, alt: [(l, self.lens[l] - 1 if c == -1 else (4 if alt and 4 < self.lens[l] - 1 else c)) for (l, c) in g] | |
| self.groups = [res(g, False) for g in GROUPS]; self.groups_alt = [res(g, True) for g in GROUPS] | |
| self.n_fwd = 0 | |
| idx = staticmethod(lambda i, j: TL + i * C + j) | |
| def gcells(self, gi): | |
| if gi in self.gpos: return (self.groups_alt if self.gpos[gi] else self.groups)[gi] | |
| return list(dict.fromkeys(self.groups[gi] + (self.groups_alt[gi] if self.rhyme_pos == "auto" else []))) | |
| def fam_cells(self, gi): | |
| """cells the family mask / rime channel applies to now: an undecided receiver (3rd or 5th) is left out""" | |
| if gi in self.gpos or self.rhyme_pos != "auto": return self.gcells(gi) | |
| return [c for c in self.groups[gi][:-1]] | |
| def used_words(self, exclude=()): | |
| return {int(self.X.tok_word[self.tok[k]]) for k in self.committed if k not in exclude and int(self.tok[k]) >= 4} | |
| # ---------------------------------------------------------------- scores | |
| def lp(self, hold=True): | |
| """masked log-probs [T, NT] for the current state""" | |
| X, tk, tn, tw = self.X, self.X.tok_k, self.X.tok_n, self.X.tok_word | |
| self.n_fwd += 1 | |
| dev = self.m.word_bias.device # the model's own device, so model.to(...) after loading works | |
| logits, _ = self.m(self.tok[None].to(dev), self.snd[None].to(dev), self.ri_in[None].to(dev)) | |
| lg = logits[0].float().cpu() + self.base[None, :] | |
| for i in range(L): | |
| lg[self.idx(i, 0)] += torch.where(tk == 0, 0.0, -1e9) | |
| lg[self.idx(i, self.lens[i] - 1)] += torch.where(tk == tn - 1, 0.0, -1e9) | |
| if i in self.tone_ok: lg[self.idx(i, self.lens[i] - 1)] += torch.where(self.tone_ok[i], 0.0, -1e9) | |
| lg[TL + i * C: TL + i * C + self.lens[i]] += self.topic_rows[i] | |
| uw = self.used_words() | |
| if uw: lg[TL:] += torch.isin(tw, torch.tensor(sorted(uw))).float() * -self.rep_pen | |
| for k, t in self.forced.items(): | |
| keep = lg[k, t].clone(); lg[k] = -1e9; lg[k, t] = keep | |
| lp = F.log_softmax(lg, -1) # peer model: renormalises the product log p_word + log p_sound (see forward) | |
| self.apply_fams(lp) | |
| for i in range(L): self.ban_group_words(lp, i) | |
| return lp | |
| def topk_cut(self, lp, line=None, K=512): | |
| """the SAME candidate cut in every unit (review 2026-09-29): at each open poem cell only its K best tokens (after | |
| every mask) stay admissible; applied right before a commit or a tiling, never before a family choice""" | |
| rows = [self.idx(i, j) for i in range(L) for j in range(self.lens[i]) | |
| if self.idx(i, j) not in self.committed and (line is None or i == line)] | |
| if not rows: return lp | |
| r = torch.tensor(rows); sub = lp[r] | |
| kth = torch.topk(sub, min(K, self.NT), dim=-1).values[:, -1:] | |
| lp[r] = torch.where(sub >= kth, sub, torch.full_like(sub, -1e9)) | |
| return lp | |
| def apply_fams(self, lp, line=None): | |
| """family / early-receiver / same-word-in-group masks for every chosen family (idempotent), optionally one line""" | |
| tw = self.X.tok_word | |
| for gi in self.fam: | |
| cg = self.fam_cells(gi) | |
| for c in cg: | |
| k = self.idx(*c) | |
| if k in self.committed or (line is not None and c[0] != line): continue | |
| lp[k] += torch.where(self.X.tok_ri == self.fam[gi], 0.0, -1e9) | |
| if line is None or self.groups[gi][-1][0] == line: self.ban_early_receiver(lp, gi) | |
| return lp | |
| def choose_families(self, lp, groups=None): | |
| X = self.X | |
| for gi in (groups if groups is not None else range(len(GROUPS))): | |
| if gi in self.fam: continue | |
| done = [c for c in self.gcells(gi) if self.idx(*c) in self.committed] | |
| a, b = self.groups[gi][-1], self.groups_alt[gi][-1] | |
| if done: | |
| # a committed cell fixes the family (wak unit: the sender's line was tiled freely); the receiving position | |
| # is fixed only if a receiver cell is committed - otherwise tile_lines picks it by the partition | |
| self.fam[gi] = int(X.tok_ri[self.tok[self.idx(*done[0])]]) | |
| if a in done: self.gpos[gi] = 0 | |
| elif b in done and b != a: self.gpos[gi] = 1 | |
| else: | |
| fams = [f for f in range(1, X.n_ri) if f not in self.fam.values()] | |
| ft = torch.tensor(fams); sc = torch.zeros(len(fams)) | |
| for c in self.groups[gi]: | |
| mass = torch.zeros(X.n_ri).index_add_(0, X.tok_ri, lp[self.idx(*c)].exp()) | |
| sc += torch.log(mass[ft] + 1e-30) | |
| pr = F.softmax(sc / len(self.groups[gi]) / max(self.fam_temp, 1e-6), 0) | |
| self.fam[gi] = fams[int(torch.multinomial(pr, 1, generator=self.gen))] | |
| if self.rhyme_pos == "auto" and gi not in self.gpos: | |
| rm = lambda c: float(lp[self.idx(*c)][X.tok_ri == self.fam[gi]].exp().sum()) | |
| self.gpos[gi] = 1 if (a != b and rm(b) > rm(a)) else 0 | |
| for c in self.fam_cells(gi): # the sound layer is set first | |
| if self.idx(*c) not in self.committed: self.ri_in[self.idx(*c)] = self.fam[gi] | |
| def held(self): | |
| h = set() | |
| for gi in range(len(GROUPS)): | |
| if gi not in self.fam: h |= {self.idx(*c) for c in self.gcells(gi)} | |
| return h | |
| # ---------------------------------------------------------------- commits | |
| def put_word(self, i, s, t_first, score=None): | |
| """commit the whole word whose FIRST slot token is t_first at cells s.. of line i""" | |
| X = self.X; w = int(X.tok_word[t_first]); n = int(X.tok_n[t_first]) | |
| for q in range(n): | |
| k = self.idx(i, s + q); self.tok[k] = t_first + q; self.snd[k] = int(X.tok_sound[t_first + q]) | |
| self.committed.add(k); self.ri_in[k] = 0; self.forced.pop(k, None) | |
| self.topic_rows[i][X.tok_word == w] = 0.0 | |
| self.wscore[(i, s)] = (w, score if score is not None else 0.0) | |
| if self.trace is not None: self.trace.append((self.unit, i, s, X.words[w])) | |
| def span_scores(self, lp, i, j, held, own_only=False): | |
| """for the tokens (w,k) still admissible at this cell (topk_cut left <= 512): admissible over the word's whole span? | |
| and the SUM of log-probs over its open cells (own cell only for the syllable unit). Returns full [NT] vectors | |
| (sum, number of open cells), -1e9 where inadmissible.""" | |
| X = self.X | |
| a_ = j | |
| while a_ - 1 >= 0 and (self.idx(i, a_ - 1) not in self.committed or own_only): a_ -= 1 | |
| b_ = j | |
| while b_ + 1 < self.lens[i] and (self.idx(i, b_ + 1) not in self.committed or own_only): b_ += 1 | |
| c0 = self.idx(i, j) | |
| ids = (lp[c0] > -1e8).nonzero()[:, 0] | |
| out = torch.full((self.NT,), -1e9); nout = torch.ones(self.NT) | |
| if not len(ids): return out, nout | |
| tk, tn = X.tok_k[ids], X.tok_n[ids] | |
| s = j - tk; e = s + tn - 1 | |
| ok = (s >= a_) & (e <= b_) | |
| tot = lp[c0][ids].clone(); cnt = torch.ones(len(ids)) | |
| for d in range(-(KMAX - 1), KMAX): | |
| if d == 0: continue | |
| jj = j + d | |
| inw = ok & (tk + d >= 0) & (tk + d < tn) | |
| if not bool(inw.any()): continue | |
| if not (0 <= jj < self.lens[i]): ok = ok & ~inw; continue | |
| ck = self.idx(i, jj); tt = (ids + d).clamp(0, self.NT - 1) | |
| if ck in self.committed: # syllable unit: must match what is there | |
| ok = ok & ~(inw & (tt != int(self.tok[ck]))) | |
| continue | |
| v = lp[ck][tt] | |
| ok = ok & ~(inw & ((v < -1e8) | (ck in held))) | |
| tot = tot + torch.where(inw, v, torch.zeros_like(v)); cnt = cnt + inw.float() | |
| sc = lp[c0][ids] if own_only else tot | |
| out[ids] = torch.where(ok, sc, torch.full_like(sc, -1e9)); nout[ids] = 1.0 if own_only else cnt | |
| return out, nout | |
| def step_cell_or_word(self, t): | |
| lp = self.lp() | |
| if t >= self.fam_delay: | |
| self.choose_families(lp); self.apply_fams(lp) # masks for families chosen this step | |
| for i in range(L): self.ban_group_words(lp, i) | |
| self.topk_cut(lp) | |
| held = self.held() | |
| best = None | |
| for i in range(L): | |
| for j in range(self.lens[i]): | |
| k = self.idx(i, j) | |
| if k in self.committed or k in held: continue | |
| sc, n_open = self.span_scores(lp, i, j, held, own_only=(self.unit == "syllable")) | |
| # ORDER by the best per-cell mean (confidence, length-neutral); SAMPLE by the sum = the joint log-prob of | |
| # the cells committed, the same quantity the wak/bot tiling samples by (review: make them consistent) | |
| conf = float((sc / n_open).max()) | |
| if conf > -1e8 and (best is None or conf > best[0]): best = (conf, i, j, sc, n_open) | |
| if best is None: return False | |
| _, i, j, sc, n_open = best | |
| t_ = int(torch.multinomial(F.softmax(sc / self.temp, 0), 1, generator=self.gen)) | |
| X = self.X; k_, n_ = int(X.tok_k[t_]), int(X.tok_n[t_]); s = j - k_; first = t_ - k_ | |
| if self.unit == "word": | |
| self.put_word(i, s, first, float(sc[t_] / n_open[t_])) | |
| else: | |
| kk = self.idx(i, j); self.tok[kk] = t_; self.snd[kk] = int(X.tok_sound[t_]); self.committed.add(kk) | |
| self.ri_in[kk] = 0; self.forced.pop(kk, None) | |
| for q in range(n_): | |
| c = self.idx(i, s + q) | |
| if c not in self.committed: self.forced[c] = first + q | |
| if all(self.idx(i, s + q) in self.committed for q in range(n_)): | |
| self.wscore[(i, s)] = (int(X.tok_word[t_]), float(sc[t_] / n_open[t_])) | |
| if self.trace is not None: self.trace.append(("syllable", i, j, X.words[int(X.tok_word[t_])], k_)) | |
| return True | |
| # ---------------------------------------------------------------- joint tiling (wak / bot) | |
| def word_end_scores(self, lp, i, j, a_): | |
| """[W] sum of log-probs of each word ending at cell j of line i with its start >= a_ (else -inf), and n per word""" | |
| X = self.X; tk, tn = X.tok_k, X.tok_n | |
| cell = j - (tn - 1) + tk # where each token would sit | |
| ok = (cell >= a_) & (cell <= j) | |
| v = torch.full((self.NT,), 0.0) | |
| cols = cell.clamp(a_, j) | |
| rows = torch.stack([lp[self.idx(i, c)] for c in range(a_, j + 1)]) # [j-a_+1, NT] | |
| v = rows[(cols - a_), torch.arange(self.NT)] | |
| v = torch.where(ok, v, torch.full_like(v, -1e9)) | |
| W = len(X.words) | |
| ws = torch.zeros(W).index_add_(0, X.tok_word, v) # sum over the word's slots | |
| ws[:4] = -1e9 | |
| nw = torch.zeros(W, dtype=torch.long).index_reduce_(0, X.tok_word, X.tok_n, "amax", include_self=False) | |
| # a word whose span does not fit gets a -1e9 from at least one slot; clip so sums stay "impossible" | |
| ws = torch.where(ws < -1e8, torch.full_like(ws, -1e9), ws) | |
| return ws, nw | |
| def tile_run(self, lp, i, a_, b_): | |
| """forward-filtering backward-sampling of a whole-word tiling of open cells a_..b_ of line i at temperature temp. | |
| returns (log partition, [(start, first_token, mean log-prob)])""" | |
| X = self.X | |
| ends = {} | |
| for j in range(a_, b_ + 1): ends[j] = self.word_end_scores(lp, i, j, a_) | |
| alpha = {a_ - 1: 0.0} | |
| for j in range(a_, b_ + 1): | |
| ws, nw = ends[j] | |
| terms = [] | |
| for n in range(1, min(KMAX, j - a_ + 1) + 1): | |
| if (j - n) not in alpha or alpha[j - n] < -1e8: continue | |
| sel = ws[nw == n] | |
| if sel.numel(): terms.append(alpha[j - n] + torch.logsumexp(sel / self.temp, 0)) | |
| alpha[j] = float(torch.logsumexp(torch.stack(terms), 0)) if terms else -1e9 | |
| if alpha[b_] < -1e8: return -1e9, None | |
| out, j = [], b_ | |
| while j >= a_: | |
| ws, nw = ends[j] | |
| cand = torch.full_like(ws, -1e9) | |
| for n in range(1, min(KMAX, j - a_ + 1) + 1): | |
| if alpha.get(j - n, -1e9) < -1e8: continue | |
| m_ = nw == n | |
| cand = torch.where(m_, alpha[j - n] + ws / self.temp, cand) | |
| w = int(torch.multinomial(F.softmax(cand, 0), 1, generator=self.gen)); n = int(nw[w]) | |
| out.append((j - n + 1, X.first[w], float(ws[w]) / n)); j -= n | |
| return alpha[b_], out[::-1] | |
| def ban_early_receiver(self, lp, gi, alt=None): | |
| """receiver at the 5th syllable: the 3rd may not carry the family's rime, else the checker lands the rhyme on the | |
| 3rd (ชิงสัมผัส, or ซ้ำคำสัมผัส when it is the sender's word) - smoke 2026-09-29, wak unit""" | |
| alt = self.gpos.get(gi) if alt is None else alt | |
| a, b = self.groups[gi][-1], self.groups_alt[gi][-1] | |
| if alt == 1 and a != b and self.idx(*a) not in self.committed: | |
| lp[self.idx(*a)] += torch.where(self.X.tok_ri == self.fam[gi], -1e9, 0.0) | |
| def ban_group_words(self, lp, i): | |
| """no word twice inside a rhyme group (ซ้ำคำสัมผัส): cells of line i in a group cannot take a word another cell of | |
| that group already holds - re-applied line by line, since bot tiles all lines from ONE forward pass""" | |
| tw = self.X.tok_word | |
| for gi in range(len(GROUPS)): | |
| cg = self.gcells(gi) | |
| words = {int(tw[self.tok[self.idx(*o)]]) for o in cg if self.idx(*o) in self.committed} | |
| if not words: continue | |
| bad = torch.isin(tw, torch.tensor(sorted(words))) | |
| for c in cg: | |
| if c[0] == i and self.idx(*c) not in self.committed: lp[self.idx(*c)] += torch.where(bad, -1e9, 0.0) | |
| def tile_lines(self, lines): | |
| """one forward pass, then a joint tiling of every open run in the given lines""" | |
| lp = self.lp(); placed = set() | |
| for i in lines: | |
| self.apply_fams(lp, i); self.ban_group_words(lp, i) | |
| rows = slice(TL + i * C, TL + i * C + self.lens[i]) | |
| if placed: # the repetition penalty lp() gave committed words, now also for words placed earlier in this pass | |
| lp[rows] += torch.isin(self.X.tok_word, torch.tensor(sorted(placed))).float() * -self.rep_pen | |
| for gi in range(len(GROUPS)): | |
| snd_c = self.groups[gi][0] | |
| if gi not in self.fam and snd_c[0] == i and self.fam: | |
| # a new sender picks a family no other group uses (humans: 3.87 of 4 families distinct) | |
| lp[self.idx(*snd_c)] += torch.isin(self.X.tok_ri, torch.tensor(sorted(set(self.fam.values())))).float() * -1e9 | |
| runs, j = [], 0 | |
| while j < self.lens[i]: | |
| if self.idx(i, j) in self.committed: j += 1; continue | |
| a_ = j | |
| while j + 1 < self.lens[i] and self.idx(i, j + 1) not in self.committed: j += 1 | |
| runs.append((a_, j)); j += 1 | |
| # receiving position (3rd or 5th) still open for a group with a family: pick by the tiling's partition | |
| for gi in list(self.fam): | |
| if gi in self.gpos or self.rhyme_pos != "auto": continue | |
| a, b = self.groups[gi][-1], self.groups_alt[gi][-1] | |
| if a[0] != i: continue | |
| best = None | |
| for alt, c in ((0, a), (1, b)): | |
| lp2 = lp.clone(); k = self.idx(*c) | |
| lp2[k] += torch.where(self.X.tok_ri == self.fam[gi], 0.0, -1e9) | |
| self.ban_early_receiver(lp2, gi, alt); self.topk_cut(lp2, i) | |
| Z = sum(self.tile_run(lp2, i, a_, b_)[0] for a_, b_ in runs) | |
| if best is None or Z > best[0]: best = (Z, alt) | |
| self.gpos[gi] = best[1] | |
| k = self.idx(*self.gcells(gi)[-1]); lp[k] += torch.where(self.X.tok_ri == self.fam[gi], 0.0, -1e9) | |
| self.ban_early_receiver(lp, gi) | |
| self.topk_cut(lp, i) # after every mask of this line | |
| for a_, b_ in runs: | |
| Z, words = self.tile_run(lp, i, a_, b_) | |
| if words is None: return False | |
| for s, first, sc in words: self.put_word(i, s, first, sc); placed.add(int(self.X.tok_word[first])) | |
| # a sender line just tiled fixes its group's family (wak unit) | |
| for gi in range(len(GROUPS)): | |
| if gi not in self.fam and any(self.idx(*c) in self.committed for c in self.gcells(gi)): | |
| self.choose_families(lp, [gi]) | |
| return True | |
| def remask(self, lines, frac): | |
| """re-open the lowest-scoring fraction of the words in these lines (whole words)""" | |
| cand = sorted((sc, i, s, w) for (i, s), (w, sc) in self.wscore.items() if i in lines) | |
| n = int(len(cand) * frac) | |
| for sc, i, s, w in cand[:n]: | |
| for q in range(len(self.X.sounds[self.X.words[w]])): | |
| k = self.idx(i, s + q); self.committed.discard(k); self.tok[k] = MASK; self.snd[k] = 0 | |
| for gi in self.fam: # a re-opened rhyme cell shows its family again | |
| if (i, s + q) in self.fam_cells(gi): self.ri_in[k] = self.fam[gi] | |
| del self.wscore[(i, s)] | |
| return n | |
| def refine_lines(self, lines): | |
| for r in range(1, self.refine + 1): | |
| frac = 0.5 * (self.refine - r + 1) / (self.refine + 1) # e.g. R=3: 0.375, 0.25, 0.125 | |
| if self.remask(lines, frac) and not self.tile_lines(lines): return False | |
| return True | |
| # ---------------------------------------------------------------- run | |
| def run(self): | |
| ok = True | |
| if self.unit in ("syllable", "word"): | |
| for t in range(200): | |
| if not any(self.idx(i, j) not in self.committed for i in range(L) for j in range(self.lens[i])): break | |
| if not self.step_cell_or_word(t) and t >= self.fam_delay: ok = False; break | |
| elif self.unit == "wak": | |
| for i in range(L): | |
| ok = ok and self.tile_lines([i]) and self.refine_lines([i]) | |
| elif self.unit == "bot": | |
| # ONE forward pass; the lines are tiled in order from it, so each sender line fixes its group's family before | |
| # the receiving lines are tiled (review: no family chosen off the all-masked canvas) | |
| ok = self.tile_lines(list(range(L))) and self.refine_lines(list(range(L))) | |
| return self.text(), ok | |
| def text(self): | |
| X = self.X; lines = [] | |
| for i in range(L): | |
| ws, j = [], 0 | |
| while j < self.lens[i]: | |
| t_ = int(self.tok[self.idx(i, j)]) | |
| if t_ < 4: ws.append("?"); j += 1; continue | |
| ws.append(X.words[int(X.tok_word[t_])]); j += int(X.tok_n[t_]) - int(X.tok_k[t_]) | |
| lines.append("".join(ws)) | |
| return lines | |
| def placed(self): | |
| return sorted((i, s, self.X.words[w]) for (i, s), (w, _) in self.wscore.items()) | |
| def decode_one(model, X, title_text, unit="word", retries=4, lens=None, **kw): | |
| """decode with retries until klon8.prosody reads back exactly the sound cells (the checker re-cuts the joined words)""" | |
| for r in range(retries + 1): | |
| D = Decoder(model, X, title_text, lens=lens, unit=unit, **kw) | |
| lines, ok = D.run() | |
| want = [[X.phons[int(X.tok_sound[int(D.tok[D.idx(i, j)])])] for j in range(D.lens[i])] for i in range(L)] | |
| got = [[s.phon for s in P.g2p_line(l)] for l in lines] | |
| if ok and got == want: return lines, D, r | |
| kw["gen"] = D.gen # continue the same RNG stream | |
| return lines, D, r | |
| def resolve_lexicon(path): | |
| """the model config stores the lexicon as an absolute path of the machine it was trained on. Used as is when it exists | |
| (every existing job); otherwise $KLON8_LEXICON, then the same path relative to this repository (the part after the last | |
| '/klon8/'), then runs/TWOLAYER/data/lexicon.json""" | |
| if os.path.exists(path): return path | |
| repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| cands = [os.environ.get("KLON8_LEXICON", "")] | |
| if "/klon8/" in path: cands.append(os.path.join(repo, path.rsplit("/klon8/", 1)[1])) | |
| if not os.path.isabs(path): cands.append(os.path.join(repo, path)) | |
| cands.append(os.path.join(repo, "runs", "TWOLAYER", "data", "lexicon.json")) | |
| for c in cands: | |
| if c and os.path.exists(c): return c | |
| raise FileNotFoundError(f"lexicon not found: {path} (tried {[c for c in cands if c]}); set KLON8_LEXICON") | |
| def load(run): | |
| cfg = json.load(open(os.path.join(run, "config.json"))) | |
| X = Lex(resolve_lexicon(cfg["lexicon"])); model = build_model(X, peer=cfg.get("peer", False), bias_mode=cfg.get("bias", "unigram"), | |
| peer_tied=cfg.get("peer_tied", False)) | |
| model.load_state_dict(torch.load(os.path.join(run, "model.pt"), map_location="cpu")); return model.to(DEV).eval(), X | |
| if __name__ == "__main__": | |
| ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd") | |
| t = sub.add_parser("train"); t.add_argument("--data", required=True); t.add_argument("--out", required=True) | |
| t.add_argument("--epochs", type=int, default=12); t.add_argument("--batch", type=int, default=32); t.add_argument("--lr", type=float, default=3e-5) | |
| t.add_argument("--valid", type=int, default=1000); t.add_argument("--limit", type=int, default=0) | |
| t.add_argument("--reveal", type=float, default=0.5, help="prob. of showing the rime at a masked rhyme cell (0 = off)") | |
| t.add_argument("--snd-lr-mult", type=float, default=20.0, help="peer model: lr multiplier of the sound input channel's own group") | |
| t.add_argument("--peer", action="store_true", help="SAME-LEVEL model: sound stream with its own embedding and head, joint (sound, word) scoring") | |
| t.add_argument("--sound-w", type=float, default=1.0, help="weight of the sound-head loss (--peer only)") | |
| t.add_argument("--bias", choices=["hybrid", "pretrained", "unigram"], default="unigram", help="output bias init (see TwoLayer); unigram won the pre-committed step-0 loss rule (11.00 vs hybrid 11.73, pretrained 12.08)") | |
| t.add_argument("--unseen", choices=["train", "freeze", "anchor"], default="freeze", help="rows of words never seen in the poems: trained freely, frozen at the pretrained init, or L2-anchored to it") | |
| t.add_argument("--anchor-w", type=float, default=0.1); t.add_argument("--max-steps", type=int, default=0, help="stop after N steps (smoke tests)") | |
| d = sub.add_parser("decode"); d.add_argument("--model", required=True); d.add_argument("--prompts", required=True); d.add_argument("--out", required=True) | |
| d.add_argument("--seed", type=int, default=0); d.add_argument("--temp", type=float, default=0.8); d.add_argument("--limit", type=int, default=0) | |
| d.add_argument("--unit", choices=["syllable", "word", "wak", "bot"], default="word", help="commit unit per decoding step") | |
| d.add_argument("--refine", type=int, default=3, help="Mask-Predict refinement rounds for --unit wak/bot") | |
| d.add_argument("--topic-boost", type=float, default=1.5, help="bonus on the title's words (= the fixed base's effective short-topic boost)") | |
| a = ap.parse_args() | |
| if a.cmd == "train": train(a) | |
| elif a.cmd == "decode": | |
| model, X = load(a.model); rows = [json.loads(l) for l in open(a.prompts, encoding="utf-8")] | |
| if a.limit: rows = rows[:a.limit] | |
| rng_len = random.Random(a.seed) | |
| with open(a.out + ".tmp", "w", encoding="utf-8") as f: | |
| for n, r in enumerate(rows): | |
| text = r.get("text") or r.get("prompt") or r.get("title") or r.get("topic") # dev49 prompts use "text" | |
| assert text, f"prompt row without text: {r}" | |
| want = pick_lens(rng_len) | |
| lines, D, tries = decode_one(model, X, text, unit=a.unit, temp=a.temp, lens=want, refine=a.refine, | |
| topic_boost=a.topic_boost, gen=torch.Generator().manual_seed(a.seed * 100003 + n)) | |
| poem = "\n".join(lines); sc = P.score(poem, n_bot_expected=1) | |
| got = [len(P.g2p_line(x)) for x in lines] | |
| f.write(json.dumps({"id": r.get("id"), "prompt": text, "sample": 0, "poem": poem, "unit": a.unit, "syl_target": want, | |
| "syl": got, "retries": tries, "forward_passes": D.n_fwd, "all_pass": sc["all_pass"], | |
| "structure": sc["structure"], "ext_rhyme": sc["ext_rhyme"], "tone": sc["tone"], | |
| "internal": sc["internal"]}, ensure_ascii=False) + "\n"); f.flush() | |
| print(f"{n}/{len(rows)} all_pass={sc['all_pass']} syl={got} want={want} retries={tries}", file=sys.stderr, flush=True) | |
| os.replace(a.out + ".tmp", a.out) | |
| P.save_cache() | |