From f56d03eabfa449e060bafbb1ebcb9434012a5257 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 7 Jun 2026 12:05:45 -0400 Subject: [PATCH] Coda-nesting fusion, s/z neutralization, wrapping draft tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Single-vowel perfect families whose coda classes nest fuse into one chain: wrist (IH S T) hubs this (IH S) and shit (IH T), which ride as slant satellites — Logic's hook reads as one color - Final s/z voicing neutralizes in coda classes and weak-ending keys: vamonos rhymes dominoes - The drafts bar wraps instead of scrolling, so many drafts stay visible Co-Authored-By: Claude Opus 4.8 (1M context) --- app.py | 75 +++++++++++++++++++++++++++++++++++++++++--- static/index.html | 2 +- tests/test_rhymes.py | 18 +++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index ec3d964..de1e1fe 100644 --- a/app.py +++ b/app.py @@ -219,7 +219,8 @@ def founding_projections(key: str) -> dict[str, str]: # the founding rime's final syllable, for weak-ending joins for i in range(len(ph) - 1, -1, -1): if ph[i] in ARPA_VOWELS: - out["weak"] = "w:" + " ".join(ph[i:]) + out["weak"] = "w:" + " ".join( + [ph[i]] + [_coda_class(c) for c in ph[i + 1:]]) break elif key.startswith("v:"): # vowel tail out["slant"] = key @@ -288,8 +289,11 @@ NASALS = {"M", "N", "NG"} def _coda_class(c: str) -> str: - """damn/hand/plans and time/line ride one nasal class in delivery.""" - return "N" if c in NASALS else c + """damn/hand/plans ride one nasal class in delivery; final s/z + voicing neutralizes too (vamonos/dominoes).""" + if c in NASALS: + return "N" + return "S" if c == "Z" else c def vc_key(word: str) -> str | None: @@ -365,7 +369,9 @@ def weak_end_key(word: str) -> str | None: pl = ph.split() for i in range(len(pl) - 1, -1, -1): if pl[i][-1].isdigit(): - return "w:" + DIGITS.sub("", " ".join(pl[i:])) + syl = [DIGITS.sub("", p) for p in pl[i:]] + out = [syl[0]] + [_coda_class(c) for c in syl[1:]] + return "w:" + " ".join(out) return None @@ -908,6 +914,67 @@ def analyze(draft: Draft): tgt["toks"].extend(g["toks"]) raw_groups = fused + # fuse single-vowel perfect families whose coda classes NEST — the + # hook chain: wrist (IH S T) is the hub that pulls in this (IH S, a + # prefix) and shit (IH T, a suffix). Same-vowel families with nested + # codas read as one chain in delivery; absorbed members mark slant. + def _sv_parse(g): + key = g["key"] + if not key.startswith("p:"): + return None + ph = key[2:].split() + if not ph or ph[0] not in ARPA_VOWELS: + return None + if any(p in ARPA_VOWELS for p in ph[1:]): + return None + coda = tuple(_coda_class(c) for c in ph[1:]) + return (ph[0], coda) if coda else None + + sv = [(gi, p) for gi, p in ((gi, _sv_parse(g)) + for gi, g in enumerate(raw_groups)) if p] + parent = list(range(len(raw_groups))) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for ai in range(len(sv)): + for bi in range(ai + 1, len(sv)): + gi, (va, ca) = sv[ai] + gj, (vb, cb) = sv[bi] + if va != vb or ca == cb: + continue + s, l = (ca, cb) if len(ca) < len(cb) else (cb, ca) + if l[:len(s)] == s or l[len(l) - len(s):] == s: + parent[find(gi)] = find(gj) + + clusters = defaultdict(list) + for gi in range(len(raw_groups)): + clusters[find(gi)].append(gi) + if any(len(m) > 1 for m in clusters.values()): + def _coda_len(gi): + p = _sv_parse(raw_groups[gi]) + return len(p[1]) if p else 0 + fused2 = [] + for members in clusters.values(): + if len(members) == 1: + fused2.append(raw_groups[members[0]]) + continue + hub = max(members, + key=lambda gi: (_coda_len(gi), + len(raw_groups[gi]["toks"]))) + core = raw_groups[hub] + for gi in members: + if gi == hub: + continue + for t in raw_groups[gi]["toks"]: + t["slant"] = True + core["toks"].extend(raw_groups[gi]["toks"]) + fused2.append(core) + raw_groups = fused2 + # stable colors: order groups by first appearance raw_groups.sort(key=lambda g: min((t["line"], t["start"]) for t in g["toks"])) groups_out = [] diff --git a/static/index.html b/static/index.html index 325158f..f12f4e0 100644 --- a/static/index.html +++ b/static/index.html @@ -69,7 +69,7 @@ /* ---- editor side ---- */ .editor-col { display: flex; flex-direction: column; min-height: 0; gap: 10px; } - .drafts-bar { display: flex; gap: 6px; overflow-x: auto; flex: none; padding-bottom: 2px; } + .drafts-bar { display: flex; flex-wrap: wrap; gap: 6px; flex: none; padding-bottom: 2px; } .dtab { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 12px; max-width: 170px; diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py index 7b7d393..e57c847 100644 --- a/tests/test_rhymes.py +++ b/tests/test_rhymes.py @@ -567,3 +567,21 @@ def test_unanswered_endings_reported(): opens = {res["lines"][o["l"]][o["s"]:o["e"]] for o in res["open"]} assert "blue" in opens and "end" in opens assert "hat" not in opens and "cat" not in opens + + +def test_hook_coda_chain_fuses(): + # Logic's hook: wrist (IH S T) is the hub; this (IH S) and shit + # (IH T) ride it as slant satellites — one color, kinda-rhymes kept + text = ("Yeah I've been killin' this shit\n" + "Yeah I've been hard in the paint, not a single assist\n" + "Yeah I've been flickin' that wrist\n" + "Yeah I've been cookin' that shit, now they fuckin' with this") + group_with(text, "shit", "assist", "wrist", "this") + + +def test_vamonos_dominoes(): + # final s/z voicing neutralizes: -nos rhymes -noes + text = ("my members go quicker than vamonos\n" + "He dead, she dead, he in jail\n" + "Everyone fallin' like dominoes") + group_with(text, "vamonos", "dominoes")