"""Compact Devanagari lexicon + pruned backoff word-bigram LM (BhashaCore Track B/C assets), format v2. Word ids: words sorted by (unigram bucket = round(-2*ln count) i.e. frequency class, then alphabetical) so ids are roughly frequency-ordered (small ids for frequent words -> small LM deltas), while the list is still front-coded within each bucket. Unigram ln-prob is per bucket (implicit; no per-word bytes). lex.bin : header | per-bucket (n_words varint, ln-prob f16) | front-coded list (1 byte/char = codepoint-0x0900, last suffix byte has bit 7 set; 0x7F = empty suffix) lm.bin : header | successor count per history (varint; history N = sentence start) | delta-coded successor ids (varint) | 4-bit codebook ln p(w|v) (16 centroids, f32) | 4-bit codebook ln backoff for histories with successors (16 centroids) """ import gzip, math, struct import numpy as np HK = 1 << 21 # in-memory bigram key = history * HK + successor (lexicons up to 2M words; file format unchanged) def varints(vals): out = bytearray() for v in vals: v = int(v) while v >= 0x80: out.append((v & 0x7F) | 0x80); v >>= 7 out.append(v) return bytes(out) def read_varints(b, n, pos=0): out = [0] * n; i = 0 while i < n: v = sh = 0 while True: c = b[pos]; pos += 1; v |= (c & 0x7F) << sh; sh += 7 if c < 0x80: break out[i] = v; i += 1 return out, pos def kmeans1d(x, k=16, it=30): x = np.asarray(x, np.float64); c = np.quantile(x, (np.arange(k) + 0.5) / k) for _ in range(it): a = np.abs(x[:, None] - c[None]).argmin(1) for j in range(k): if (a == j).any(): c[j] = x[a == j].mean() c = np.sort(c); a = np.abs(x[:, None] - c[None]).argmin(1) if len(x) < 2_000_000 else None return c.astype(np.float32), a.astype(np.uint8) def pack4(a): a = np.r_[a, np.zeros(len(a) % 2, np.uint8)].astype(np.uint8); return (a[0::2] * 16 + a[1::2]).astype(np.uint8).tobytes() def unpack4(b, n): a = np.frombuffer(b, np.uint8); return np.stack([a >> 4, a & 15], 1).reshape(-1)[:n] def build(words_by_freq, counts_by_freq, bigram_pairs, bigram_counts, n_lex, maxbi, D=0.75, cmin=2, K=262144, alpha=False): """words_by_freq[i] has count id i+2 in bigram_pairs (0=boundary, 1=unk).""" N = n_lex; ws = words_by_freq[:N]; cs = counts_by_freq[:N] bk = [-int(round(2 * math.log(c))) for c in cs] if alpha: # format v4: one global alphabetical front-coded list + 16-level (4-bit) uniform log-frequency band per word order = sorted(range(N), key=lambda i: ws[i]) else: order = sorted(range(N), key=lambda i: (bk[i], ws[i])) nid = np.empty(N + 2, np.int64); nid[0] = N; nid[1] = -1 for a, i in enumerate(order): nid[i + 2] = a tot = float(sum(cs)) * 1.02 # ~2% mass reserved for OOV words buckets = [] for i in order: if not buckets or buckets[-1][0] != bk[i]: buckets.append([bk[i], 0, 0.0]) buckets[-1][1] += 1; buckets[-1][2] += cs[i] b_lp = [math.log(s / n / tot) for _, n, s in buckets] unk_lp = math.log(0.02 / 50000) puni = np.empty(N + 2); puni[0] = math.log(0.04); puni[1] = unk_lp; j = 0 if alpha: lc = np.log(np.array(cs, np.float64) / tot); NB = 16 # 16 uniform log-frequency bands (4 bits/word) ucb = np.linspace(lc.min(), lc.max(), NB).astype(np.float32) ua = np.abs(lc[:, None] - ucb[None].astype(np.float64)).argmin(1).astype(np.uint8) puni[2:] = ucb[ua] else: for (_, n, _), lp in zip(buckets, b_lp): for _ in range(n): puni[[order[j] + 2]] = lp; j += 1 v = bigram_pairs // K; w = bigram_pairs % K; c = bigram_counts.astype(np.float64) inlex = (v - 2 < N) & (w - 2 < N) hist_tot = np.bincount(np.where(v - 2 < N, v, 1), weights=c, minlength=N + 2) keep = inlex & (v != 1) & (w != 1) & (c >= cmin) pk_all = np.log(np.maximum(c - D, 0.1) / np.maximum(hist_tot[np.minimum(v, N + 1)], 1)) gain = np.where(keep, c * (pk_all - puni[np.minimum(w, N + 1)]), -np.inf) if keep.sum() > maxbi: thr = np.partition(gain, len(gain) - maxbi)[len(gain) - maxbi]; keep &= gain >= thr kv, kw, pk = v[keep], w[keep], pk_all[keep] av = nid[kv]; aw = nid[kw] # history boundary 0 -> N ; successor boundary 0 -> N (= ) o = np.lexsort((aw, av)); av, aw, pk = av[o], aw[o], pk[o] cb, qa = kmeans1d(pk); pkd = cb[qa] cnt = np.bincount(av, minlength=N + 1) first = np.r_[True, av[1:] != av[:-1]]; delta = np.where(first, aw, aw - np.r_[0, aw[:-1]] - 1) # backoff for histories with successors (dequantised probs; unigram of successors in new-id space) pu_new = np.empty(N + 1); pu_new[nid[np.arange(2, N + 2)]] = puni[2:]; pu_new[N] = puni[0] s_bi = np.bincount(av, weights=np.exp(pkd), minlength=N + 1); s_un = np.bincount(av, weights=np.exp(pu_new[aw]), minlength=N + 1) hs = np.nonzero(cnt)[0] bow = np.log(np.maximum(1 - s_bi[hs], 1e-4) / np.maximum(1 - s_un[hs], 1e-4)) cbb, qb = kmeans1d(bow) fc = bytearray(); prev = b"" for i in order: bb = bytes(ord(ch) - 0x0900 for ch in ws[i]); k = 0 while k < min(len(bb), len(prev), 127) and bb[k] == prev[k]: k += 1 suf = bytearray(bb[k:]) or bytearray([0x7F]); suf[-1] |= 0x80; fc.append(k); fc += suf; prev = bb bh = varints([n for _, n, _ in buckets]) + np.array(b_lp, np.float16).tobytes() if alpha: # v4: prefix-length stream and suffix stream stored separately (compresses ~8% better) P = bytearray(); S = bytearray(); prev = b"" for i in order: bb = bytes(ord(ch) - 0x0900 for ch in ws[i]); k = 0 while k < min(len(bb), len(prev), 127) and bb[k] == prev[k]: k += 1 suf = bytearray(bb[k:]) or bytearray([0x7F]); suf[-1] |= 0x80; P.append(k); S += suf; prev = bb lex = b"LX4\0" + struct.pack(".""" h = self.N if prev is None else prev if wid is not None: p = self.bi.get(h * HK + wid) if p is not None: return p return self.bow.get(h, 0.0) + self.unigram(wid) def write_assets(out_dir, n_lex, maxbi): import os root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) rows = [l.rstrip("\n").split("\t") for l in open(f"{root}/data/processed/trackB/hi_wordfreq.tsv", encoding="utf-8")] d = np.load(f"{root}/data/processed/trackB/lm/bigrams.npz") lex, lm, st = build([r[0] for r in rows], [int(r[1]) for r in rows], d["pairs"], d["counts"], n_lex, maxbi) os.makedirs(out_dir, exist_ok=True) open(f"{out_dir}/lex.bin", "wb").write(lex); open(f"{out_dir}/lm.bin", "wb").write(lm) st.update(lex_bytes=len(lex), lex_gz=len(gzip.compress(lex, 9)), lm_bytes=len(lm), lm_gz=len(gzip.compress(lm, 9))) return st