symbolic-morphology / src /python /nand_latin.py
SNAPKITTYWEST's picture
Mirror symbolic-morphology from GitHub (d23a58c)
c772837 verified
Raw History Blame Contribute Delete
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()