Coda-nesting fusion, s/z neutralization, wrapping draft tabs

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:05:45 -04:00
parent b2788e3620
commit f56d03eabf
3 changed files with 90 additions and 5 deletions
+71 -4
View File
@@ -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 = []
+1 -1
View File
@@ -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;
+18
View File
@@ -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")