File size: 60,727 Bytes
7d3eb8f 6174f93 7d3eb8f 6174f93 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 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 | """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)
@torch.no_grad()
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)
@torch.no_grad()
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())
@torch.no_grad()
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()
|