Download src/python/nand_latin.py from Snapkitty/symbolic-morphology: direct link, hf CLI and curl.
- Browser
- Download file 18.5 kB
-
https://huggingface.co/Snapkitty/symbolic-morphology/resolve/main/src/python/nand_latin.py
- Command line
-
hf download hf://Snapkitty/symbolic-morphology/src/python/nand_latin.py
-
curl -L -o nand_latin.py https://huggingface.co/Snapkitty/symbolic-morphology/resolve/main/src/python/nand_latin.py
18.5 kB
| #!/usr/bin/env python3 | |
| # Symbolic Morphology Engine — Latin verb morphology from raw letters to Boolean grammar | |
| # Copyright (C) 2026 Ahmad Ali Parr | |
| # | |
| # This program is free software: you can redistribute it and/or modify | |
| # it under the terms of the GNU Affero General Public License as published by | |
| # the Free Software Foundation, either version 3 of the License, or | |
| # (at your option) any later version. | |
| # | |
| # This program is distributed in the hope that it will be useful, | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| # GNU Affero General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU Affero General Public License | |
| # along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| # -*- coding: utf-8 -*- | |
| """ | |
| nand_latin.py -- a Latin morphology learner with a single primitive: NAND. | |
| """ | |
| import math | |
| import random | |
| import time | |
| # ============================================================================ | |
| # LEVEL 0 -- THE ONLY PRIMITIVE | |
| # ============================================================================ | |
| def NAND(a, b): | |
| """a, b in {0,1} -> {0,1}. The single irreducible leaf of the graph.""" | |
| return 0 if (a == 1 and b == 1) else 1 | |
| def NOT(a): return NAND(a, a) | |
| def AND(a, b): return NOT(NAND(a, b)) | |
| def OR(a, b): return NAND(NOT(a), NOT(b)) | |
| def XOR(a, b): | |
| t = NAND(a, b) | |
| return NAND(NAND(a, t), NAND(b, t)) | |
| def MUX(sel, a, b): | |
| """sel=1 -> a, sel=0 -> b.""" | |
| return OR(AND(sel, a), AND(NOT(sel), b)) | |
| # ============================================================================ | |
| # LEVEL 1 -- BIT-PARALLEL GATES | |
| # ============================================================================ | |
| W = 16 | |
| F = 8 | |
| _M = (1 << W) - 1 | |
| def _mask(w): return (1 << w) - 1 | |
| def NAND_W(a, b, w=W): | |
| return (~(a & b)) & _mask(w) | |
| def NOT_W(a, w=W): return NAND_W(a, a, w) | |
| def AND_W(a, b, w=W): return NOT_W(NAND_W(a, b, w), w) | |
| def OR_W(a, b, w=W): return NAND_W(NOT_W(a, w), NOT_W(b, w), w) | |
| def XOR_W(a, b, w=W): | |
| t = NAND_W(a, b, w) | |
| return NAND_W(NAND_W(a, t, w), NAND_W(b, t, w), w) | |
| def MUX_W(sel, a, b, w=W): | |
| m = _mask(w) | |
| s = (-sel) & m | |
| return ((a & s) | (b & (~s) & m)) & m | |
| # ============================================================================ | |
| # LEVEL 2 -- ARITHMETIC, BUILT FROM THE GATES ABOVE | |
| # ============================================================================ | |
| def ADD_W(a, b, w=W): | |
| """Kogge-Stone carry-lookahead adder.""" | |
| m = _mask(w) | |
| g = AND_W(a, b, w) | |
| p = XOR_W(a, b, w) | |
| d = 1 | |
| while d < w: | |
| g = OR_W(g, AND_W(p, (g << d) & m, w), w) | |
| p = AND_W(p, (p << d) & m, w) | |
| d <<= 1 | |
| carry = (g << 1) & m | |
| return XOR_W(XOR_W(a, b, w), carry, w) | |
| def NEG_W(a, w=W): | |
| """two's complement: -a = ~a + 1""" | |
| return ADD_W(NOT_W(a, w), 1, w) | |
| def SUB_W(a, b, w=W): | |
| return ADD_W(a, NEG_W(b, w), w) | |
| def UMUL_W(a, b, w=W): | |
| """Unsigned w x w -> 2w shift-add multiplier, built from ADD_W.""" | |
| m2 = (1 << (2 * w)) - 1 | |
| res = 0 | |
| i = 0 | |
| bb = b | |
| while bb: | |
| if bb & 1: | |
| res = ADD_W(res, (a << i) & m2, 2 * w) | |
| bb >>= 1 | |
| i += 1 | |
| return res | |
| # ---- fixed-point Q(16-8).8 ------------------------------------------------- | |
| def FP(x): | |
| """float -> Q(16.8) two's-complement int.""" | |
| v = int(round(x * (1 << F))) | |
| lim = 1 << (W - 1) | |
| if v >= lim: v = lim - 1 | |
| if v < -lim: v = -lim | |
| return v & _M | |
| def to_signed(a): | |
| return a - (1 << W) if (a >> (W - 1)) & 1 else a | |
| def to_float(a): | |
| return to_signed(a) / float(1 << F) | |
| def SAR_W(a, n): | |
| """arithmetic shift right (signed semantics).""" | |
| return (to_signed(a) >> n) & _M | |
| def MUL_W(a, b): | |
| """signed Q(16.8) multiply.""" | |
| sa = to_signed(a) | |
| sb = to_signed(b) | |
| neg = (sa < 0) != (sb < 0) | |
| ua = -sa if sa < 0 else sa | |
| ub = -sb if sb < 0 else sb | |
| p = (UMUL_W(ua, ub) >> F) & _M | |
| return NEG_W(p) if neg else p | |
| # ============================================================================ | |
| # LEVEL 3 -- TRANSCENDENTALS (comparators + MUX interpolation, from NAND) | |
| # ============================================================================ | |
| _TX = [-4.0, -2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 4.0] | |
| _TY = [math.tanh(x) for x in _TX] | |
| _TXf = [FP(x) for x in _TX] | |
| _TYf = [FP(y) for y in _TY] | |
| _TS = [FP((_TY[i + 1] - _TY[i]) / (_TX[i + 1] - _TX[i])) | |
| for i in range(len(_TX) - 1)] | |
| def SLT_W(a, b): | |
| """signed a < b -- sign bit of (a - b).""" | |
| return (SUB_W(a, b) >> (W - 1)) & 1 | |
| def tanh_fp(x): | |
| if to_signed(x) < to_signed(_TXf[0]): x = _TXf[0] | |
| elif to_signed(x) > to_signed(_TXf[-1]): x = _TXf[-1] | |
| idx = 0 | |
| for i in range(len(_TXf) - 1): | |
| if to_signed(x) >= to_signed(_TXf[i]): | |
| idx = i | |
| dx = SUB_W(x, _TXf[idx]) | |
| return ADD_W(_TYf[idx], MUL_W(_TS[idx], dx)) | |
| _HALF = FP(0.5) | |
| def sigmoid_fp(z): | |
| """sigmoid(z) = 0.5 + 0.5 * tanh(z/2)""" | |
| return ADD_W(_HALF, SAR_W(tanh_fp(SAR_W(z, 1)), 1)) | |
| # ============================================================================ | |
| # LEVEL 4 -- SYMBOLIC OUTPUT SPACE (10 Boolean grammatical bits) | |
| # ============================================================================ | |
| FEATURES = [ | |
| "PERSON_B0", "PERSON_B1", | |
| "NUMBER", | |
| "TENSE_B0", "TENSE_B1", | |
| "MOOD_B0", "MOOD_B1", | |
| "VOICE", | |
| "CONJ_B0", "CONJ_B1", | |
| ] | |
| K = len(FEATURES) | |
| _PERSON = {1: (0, 0), 2: (0, 1), 3: (1, 0)} | |
| _TENSE = {'PRESENT': (0, 0), 'IMPERFECT': (0, 1), | |
| 'FUTURE': (1, 0), 'PERFECT': (1, 1)} | |
| _MOOD = {'INDICATIVE': (0, 0), 'SUBJUNCTIVE': (0, 1), 'IMPERATIVE': (1, 0)} | |
| _CONJ = {1: (0, 0), 2: (0, 1), 3: (1, 0), 4: (1, 1)} | |
| def target_bits(person, number, tense, mood, voice, conj): | |
| pb = _PERSON[person] | |
| tb = _TENSE[tense] | |
| mb = _MOOD[mood] | |
| cb = _CONJ[conj] | |
| return [ | |
| pb[0], pb[1], | |
| 1 if number == 'PL' else 0, | |
| tb[0], tb[1], | |
| mb[0], mb[1], | |
| 1 if voice == 'PASSIVE' else 0, | |
| cb[0], cb[1], | |
| ] | |
| def decode(p): | |
| b = [1 if to_float(v) >= 0.5 else 0 for v in p] | |
| person = {(0, 0): 1, (0, 1): 2, (1, 0): 3}.get((b[0], b[1]), '?') | |
| number = 'PL' if b[2] else 'SG' | |
| tense = {(0, 0): 'PRES', (0, 1): 'IMPF', | |
| (1, 0): 'FUT', (1, 1): 'PERF'}.get((b[3], b[4]), '?') | |
| mood = {(0, 0): 'IND', (0, 1): 'SUBJ', | |
| (1, 0): 'IMP'}.get((b[5], b[6]), '?') | |
| voice = 'PASS' if b[7] else 'ACT' | |
| conj = {(0, 0): 1, (0, 1): 2, (1, 0): 3, (1, 1): 4}.get((b[8], b[9]), '?') | |
| return person, number, tense, mood, voice, conj | |
| # ============================================================================ | |
| # LEVEL 5 -- CORPUS | |
| # ============================================================================ | |
| PARADIGMS = { | |
| 1: ("am", {"PRESENT": ["o", "as", "at", "amus", "atis", "ant"], | |
| "IMPERFECT": ["abam", "abas", "abat", "abamus", "abatis", "abant"]}), | |
| 2: ("mon", {"PRESENT": ["eo", "es", "et", "emus", "etis", "ent"], | |
| "IMPERFECT": ["ebam", "ebas", "ebat", "ebamus", "ebatis", "ebant"]}), | |
| 3: ("reg", {"PRESENT": ["o", "is", "it", "imus", "itis", "unt"], | |
| "IMPERFECT": ["ebam", "ebas", "ebat", "ebamus", "ebatis", "ebant"]}), | |
| 4: ("aud", {"PRESENT": ["io", "is", "it", "imus", "itis", "iunt"], | |
| "IMPERFECT": ["iebam","iebas","iebat","iebamus","iebatis","iebant"]}), | |
| } | |
| def pn(i): | |
| return (i % 3) + 1, ('SG' if i < 3 else 'PL') | |
| def build_corpus(): | |
| corpus = [] | |
| for conj, (stem, tenses) in PARADIGMS.items(): | |
| for tense, endings in tenses.items(): | |
| for i, end in enumerate(endings): | |
| p, n = pn(i) | |
| w = stem + end | |
| corpus.append({ | |
| "word": w, | |
| "y": target_bits(p, n, tense, 'INDICATIVE', 'ACTIVE', conj), | |
| "person": p, "number": n, | |
| "tense": tense, | |
| "mood": 'INDICATIVE', "voice": 'ACTIVE', "conj": conj, | |
| }) | |
| return corpus | |
| # ============================================================================ | |
| # LEVEL 6 -- MODEL (embeddings + Elman RNN + linear output) | |
| # ============================================================================ | |
| def rand_fp(rng, scale): | |
| return FP(rng.uniform(-scale, scale)) | |
| def init_model(vocab, D=3, H=4, seed=7): | |
| rng = random.Random(seed) | |
| V = len(vocab) | |
| def mat(r, c, s): return [[rand_fp(rng, s) for _ in range(c)] for _ in range(r)] | |
| def vec(n, s): return [rand_fp(rng, s) for _ in range(n)] | |
| return { | |
| 'E': mat(V, D, 0.8), | |
| 'Wxh': mat(H, D, 0.8), | |
| 'Whh': mat(H, H, 0.5), | |
| 'bh': vec(H, 0.15), | |
| 'Why': mat(K, H, 0.8), | |
| 'by': vec(K, 0.15), | |
| 'vocab': vocab, 'D': D, 'H': H, 'K': K, | |
| } | |
| def forward(m, word): | |
| E, Wxh, Whh, bh = m['E'], m['Wxh'], m['Whh'], m['bh'] | |
| Why, by = m['Why'], m['by'] | |
| H, D, K, vocab = m['H'], m['D'], m['K'], m['vocab'] | |
| h = [0] * H | |
| cache = [] | |
| for ch in word: | |
| ci = vocab[ch] | |
| x = E[ci] | |
| pre = [] | |
| for j in range(H): | |
| s = bh[j] | |
| for d in range(D): s = ADD_W(s, MUL_W(Wxh[j][d], x[d])) | |
| for k in range(H): s = ADD_W(s, MUL_W(Whh[j][k], h[k])) | |
| pre.append(s) | |
| h_new = [tanh_fp(p) for p in pre] | |
| cache.append((ci, x, h, h_new)) | |
| h = h_new | |
| logits = [] | |
| for k in range(K): | |
| s = by[k] | |
| for j in range(H): | |
| s = ADD_W(s, MUL_W(Why[k][j], h[j])) | |
| logits.append(s) | |
| return h, logits, cache | |
| def predict(m, word): | |
| _, logits, _ = forward(m, word) | |
| return [sigmoid_fp(z) for z in logits] | |
| # ============================================================================ | |
| # LEVEL 7 -- HAND-ROLLED BACKPROPAGATION THROUGH TIME | |
| # ============================================================================ | |
| def backward(m, word, y): | |
| H, D, K = m['H'], m['D'], m['K'] | |
| h_final, logits, cache = forward(m, word) | |
| p = [sigmoid_fp(z) for z in logits] | |
| invK = FP(1.0 / K) | |
| dlogit = [] | |
| for k in range(K): | |
| diff = SUB_W(p[k], FP(float(y[k]))) | |
| dlogit.append(MUL_W(diff, invK)) | |
| V = len(m['vocab']) | |
| gE = [[0] * D for _ in range(V)] | |
| gWxh = [[0] * D for _ in range(H)] | |
| gWhh = [[0] * H for _ in range(H)] | |
| gbh = [0] * H | |
| gWhy = [[0] * H for _ in range(K)] | |
| gby = [0] * K | |
| for k in range(K): | |
| dk = dlogit[k] | |
| for j in range(H): | |
| gWhy[k][j] = ADD_W(gWhy[k][j], MUL_W(dk, h_final[j])) | |
| gby[k] = ADD_W(gby[k], dk) | |
| dh = [0] * H | |
| for j in range(H): | |
| acc = 0 | |
| for k in range(K): | |
| acc = ADD_W(acc, MUL_W(m['Why'][k][j], dlogit[k])) | |
| dh[j] = acc | |
| for t in range(len(cache) - 1, -1, -1): | |
| ci, x, h_prev, h_new = cache[t] | |
| dz = [] | |
| for j in range(H): | |
| t2 = MUL_W(h_new[j], h_new[j]) | |
| dz.append(MUL_W(dh[j], SUB_W(FP(1.0), t2))) | |
| for j in range(H): | |
| dj = dz[j] | |
| for d in range(D): | |
| gWxh[j][d] = ADD_W(gWxh[j][d], MUL_W(dj, x[d])) | |
| for kk in range(H): | |
| gWhh[j][kk] = ADD_W(gWhh[j][kk], MUL_W(dj, h_prev[kk])) | |
| gbh[j] = ADD_W(gbh[j], dj) | |
| new_dh = [0] * H | |
| for kk in range(H): | |
| acc = 0 | |
| for j in range(H): | |
| acc = ADD_W(acc, MUL_W(m['Whh'][j][kk], dz[j])) | |
| new_dh[kk] = acc | |
| dh = new_dh | |
| for d in range(D): | |
| acc = 0 | |
| for j in range(H): | |
| acc = ADD_W(acc, MUL_W(m['Wxh'][j][d], dz[j])) | |
| gE[ci][d] = ADD_W(gE[ci][d], acc) | |
| return p, (gE, gWxh, gWhh, gbh, gWhy, gby) | |
| def sgd_step(m, grads, lr_fp): | |
| gE, gWxh, gWhh, gbh, gWhy, gby = grads | |
| def upd(P, G): | |
| for i in range(len(P)): | |
| if isinstance(P[i], list): | |
| for j in range(len(P[i])): | |
| P[i][j] = SUB_W(P[i][j], MUL_W(lr_fp, G[i][j])) | |
| else: | |
| P[i] = SUB_W(P[i], MUL_W(lr_fp, G[i])) | |
| upd(m['E'], gE) | |
| upd(m['Wxh'], gWxh) | |
| upd(m['Whh'], gWhh) | |
| upd(m['bh'], gbh) | |
| upd(m['Why'], gWhy) | |
| upd(m['by'], gby) | |
| # ============================================================================ | |
| # LEVEL 8 -- LOSS, TRAINING, EVALUATION, DISPLAY | |
| # ============================================================================ | |
| def loss_of(p, y): | |
| s = 0.0 | |
| for k in range(K): | |
| pk = max(1e-7, min(1 - 1e-7, to_float(p[k]))) | |
| s += -(y[k] * math.log(pk) + (1 - y[k]) * math.log(1 - pk)) | |
| return s / K | |
| def train(m, corpus, epochs, lr_start=1.0, lr_end=0.05, | |
| log_every=5, seed=1): | |
| rng = random.Random(seed) | |
| data = list(corpus) | |
| hist = [] | |
| t0 = time.time() | |
| for ep in range(1, epochs + 1): | |
| rng.shuffle(data) | |
| f = (ep - 1) / max(1, epochs - 1) | |
| lr = lr_start * (1 - f) + lr_end * f | |
| lr_fp = FP(lr) | |
| tot = 0.0 | |
| for ex in data: | |
| p, grads = backward(m, ex['word'], ex['y']) | |
| tot += loss_of(p, ex['y']) | |
| sgd_step(m, grads, lr_fp) | |
| avg = tot / len(data) | |
| hist.append(avg) | |
| if ep == 1 or ep % log_every == 0 or ep == epochs: | |
| print(" epoch %3d | lr=%.3f | mean BCE=%.5f | %.1fs" | |
| % (ep, lr, avg, time.time() - t0)) | |
| return hist | |
| def evaluate(m, data): | |
| if not data: | |
| return {"n": 0} | |
| exact = bit_ok = bit_tot = 0 | |
| L = 0.0 | |
| crisp = 0 | |
| cells = 0 | |
| for ex in data: | |
| p = predict(m, ex['word']) | |
| L += loss_of(p, ex['y']) | |
| ok = True | |
| for k in range(K): | |
| v = to_float(p[k]) | |
| b = 1 if v >= 0.5 else 0 | |
| if b == int(ex['y'][k]): bit_ok += 1 | |
| else: ok = False | |
| bit_tot += 1 | |
| cells += 1 | |
| if abs(v - 0.5) > 0.45: crisp += 1 | |
| if ok: exact += 1 | |
| return {"n": len(data), | |
| "exact": exact / len(data), | |
| "bit": bit_ok / bit_tot, | |
| "loss": L / len(data), | |
| "crisp": crisp / cells} | |
| def show(m, word, target=None, title=None): | |
| p = predict(m, word) | |
| if title: | |
| print("\n" + "=" * 76) | |
| print(title) | |
| print("=" * 76) | |
| print("INPUT: %s" % word.upper()) | |
| print() | |
| print(" %-12s %-10s %-7s%s" % ("FEATURE", "RAW", "BOOL", | |
| " TARGET ERR" if target is not None else "")) | |
| print(" " + "-" * (44 if target is not None else 30)) | |
| for k, f in enumerate(FEATURES): | |
| v = to_float(p[k]) | |
| line = " %-12s %-10.4f %-7s" % (f, v, "TRUE" if v >= 0.5 else "FALSE") | |
| if target is not None: | |
| tv = "TRUE" if target[k] >= 0.5 else "FALSE" | |
| line += " %-6s %+.4f" % (tv, v - target[k]) | |
| print(line) | |
| if target is not None: | |
| L = loss_of(p, target) | |
| print("\n LOSS (mean BCE) : %.6f" % L) | |
| _, grads = backward(m, word, target) | |
| s = 0.0 | |
| for G in grads: | |
| for row in G: | |
| if isinstance(row, list): | |
| for v in row: s += to_float(v) ** 2 | |
| else: | |
| s += to_float(row) ** 2 | |
| print(" GRADIENT ||g||_2 : %.6f" % math.sqrt(s)) | |
| p_, n_, t_, mo, vo, cj = decode(p) | |
| print("\n READ AS: person=%s number=%s tense=%s mood=%s voice=%s conj=%s" | |
| % (p_, n_, t_, mo, vo, cj)) | |
| # ============================================================================ | |
| # MAIN | |
| # ============================================================================ | |
| def main(): | |
| t0 = time.time() | |
| print("=" * 76) | |
| print("NAND-RECURSIVE LATIN MORPHOLOGY LEARNER") | |
| print("Every computation bottoms out at a single NAND gate.") | |
| print("=" * 76) | |
| corpus = build_corpus() | |
| vocab = {c: i for i, c in enumerate(sorted(set("".join(e['word'] for e in corpus))))} | |
| print("\nCORPUS") | |
| print(" examples : %d" % len(corpus)) | |
| print(" vocab : %s" % "".join(sorted(vocab))) | |
| print(" features : %d Boolean grammatical bits" % K) | |
| rng = random.Random(23) | |
| words = sorted({e['word'] for e in corpus}) | |
| unseen_words = set(rng.sample(words, max(2, len(words) // 6))) | |
| train_data = [e for e in corpus if e['word'] not in unseen_words] | |
| test_data = [e for e in corpus if e['word'] in unseen_words] | |
| print(" train : %d" % len(train_data)) | |
| print(" test : %d (unseen inflected forms)" % len(test_data)) | |
| m = init_model(vocab, D=3, H=4, seed=7) | |
| npar = sum(len(r) if isinstance(r[0], list) else 1 | |
| for r in m.values() if isinstance(r, list)) | |
| print("\nMODEL") | |
| print(" D=%d H=%d K=%d params=%d fixed-point Q(16.8)" | |
| % (m['D'], m['H'], m['K'], npar)) | |
| print("\nTRAINING") | |
| print("-" * 76) | |
| hist = train(m, train_data, epochs=40, lr_start=1.0, lr_end=0.05, log_every=5) | |
| print("-" * 76) | |
| print(" final train BCE : %.6f" % hist[-1]) | |
| print(" wall clock : %.1f s" % (time.time() - t0)) | |
| by_word = {e['word']: e for e in corpus} | |
| for w, ttl in [("amo", "EXAMPLE 1 (1sg present indicative active, 1st conj)"), | |
| ("amat", "EXAMPLE 2 (3sg present indicative active, 1st conj)"), | |
| ("regebat", "EXAMPLE 3 (3sg imperfect indicative active, 3rd conj)"), | |
| ("audiunt", "EXAMPLE 4 (3pl present indicative active, 4th conj)")]: | |
| show(m, w, by_word[w]['y'], ttl) | |
| print("\n" + "=" * 76) | |
| print("UNSEEN INFLECTED FORMS") | |
| print("=" * 76) | |
| for e in test_data: | |
| show(m, e['word'], e['y']) | |
| print("\n" + "=" * 76) | |
| print("EVALUATION") | |
| print("=" * 76) | |
| for name, data in [("seen (train)", train_data), ("unseen words", test_data)]: | |
| r = evaluate(m, data) | |
| print(" %-16s n=%2d | exact=%5.1f%% | bit-acc=%5.1f%% | BCE=%.5f | crisp=%5.1f%%" | |
| % (name, r['n'], 100 * r['exact'], 100 * r['bit'], | |
| r['loss'], 100 * r['crisp'])) | |
| print("\n" + "=" * 76) | |
| print("BOOLEAN CONVERGENCE") | |
| print("=" * 76) | |
| buckets = [0] * 10 | |
| tot = 0 | |
| for e in corpus: | |
| p = predict(m, e['word']) | |
| for v in p: | |
| d = abs(to_float(v) - 0.5) | |
| buckets[min(9, int(d * 20))] += 1 | |
| tot += 1 | |
| for i, c in enumerate(buckets): | |
| bar = "#" * int(60 * c / max(1, tot)) | |
| print(" %.2f-%.2f : %5.2f%% %s" | |
| % (i * 0.05, (i + 1) * 0.05, 100 * c / tot, bar)) | |
| print("\nDONE in %.1f s" % (time.time() - t0)) | |
| if __name__ == "__main__": | |
| main() | |