From e134f33ec71afe65220a33a51590ac78d35f96ff Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Mon, 8 Jun 2026 03:16:08 -0400 Subject: [PATCH] Near-miss radar (#3): dead endings one phoneme from a rhyme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line ending that rhymes with nothing but sits one phoneme from another ending in the stanza (hand/bond — one vowel apart) gets a dotted gold underline: a tiny edit would lock it. Restricted to genuinely-open endings, so it never duplicates the slant/consonance detection (which already catches same-vowel near-rhymes). Co-Authored-By: Claude Opus 4.8 (1M context) --- app.py | 49 ++++++++++++++++++++++++++++++++++++++++++-- static/index.html | 8 +++++++- tests/test_rhymes.py | 8 ++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 2ab4744..e280712 100644 --- a/app.py +++ b/app.py @@ -1350,7 +1350,7 @@ def analyze(draft: Draft): for e in entries: e["target"] = mode - # per-word stress for the optional dots layer (2+ syllables only — + # per-word stress for the optional dots layer # per-word stress for the optional dots layer (2+ syllables only — # a dot under every monosyllable is noise, not information) stress_out = [] for t in tokens: @@ -1407,9 +1407,54 @@ def analyze(draft: Draft): if i not in end_gid and end_word_counts[t["word"].lower()] < 2: open_out.append({"l": i, "s": t["start"], "e": t["end"]}) + # near-miss radar: a DEAD line ending (rhymes with nothing) that is + # one phoneme from another ending in the stanza — a salvageable line + near_out = [] + open_ids = {(o["l"], o["s"]) for o in open_out} + ends_by_st = defaultdict(list) + for t in tokens: + if t["is_end"] and t["word"].lower() not in STOPWORDS: + ph = phones_for(t["word"]) + if ph: + ends_by_st[t["sid"]].append((t, _rime_from_phones(ph))) + + def _one_off(a1, b1): + pa, pb = a1.split(), b1.split() + if pa == pb: + return False + if abs(len(pa) - len(pb)) > 1: + return False + i = j = edits = 0 + while i < len(pa) and j < len(pb): + if pa[i] == pb[j]: + i += 1; j += 1 + else: + edits += 1 + if edits > 1: + return False + if len(pa) > len(pb): i += 1 + elif len(pb) > len(pa): j += 1 + else: i += 1; j += 1 + if (len(pa) - i) + (len(pb) - j) + edits != 1: + return False + return pa[0] == pb[0] or pa[-1] == pb[-1] # share nucleus or coda + + for ends in ends_by_st.values(): + for ta, ra in ends: + if (ta["line"], ta["start"]) not in open_ids: + continue # only flag endings that currently rhyme nothing + for tb, rb in ends: + if tb is ta or ta["word"].lower() == tb["word"].lower(): + continue + if _one_off(ra, rb): + near_out.append({"l": ta["line"], "s": ta["start"], + "e": ta["end"]}) + break + + return {"lines": lines, "tokens": toks_out, "groups": groups_out, "stanzas": stanzas, "meter": meter, "stress": stress_out, - "allit": allit_out, "open": open_out} + "allit": allit_out, "open": open_out, "near": near_out} # -------------------------------------------------------------------------- diff --git a/static/index.html b/static/index.html index cca2b0b..ac9da7e 100644 --- a/static/index.html +++ b/static/index.html @@ -575,12 +575,14 @@ function render(){ const tokByLine = {}; const allitByLine = {}; const openByLine = {}; + const nearByLine = {}; const groupInfo = {}; if(analysis){ analysis.groups.forEach(g=>{ groupInfo[g.id] = g; }); analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); }); if(analysis.allit) analysis.allit.forEach(t=>{ (allitByLine[t.l] ||= []).push(t); }); if(analysis.open) analysis.open.forEach(t=>{ (openByLine[t.l] ||= []).push(t); }); + if(analysis.near) analysis.near.forEach(t=>{ (nearByLine[t.l] ||= []).push(t); }); } const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`; let html = ''; @@ -604,6 +606,7 @@ function render(){ const phrases = toks.filter(t=>t.ph); const als = (allitToggle.checked && fresh) ? (allitByLine[i] || []) : []; const opens = (rhymeToggle.checked && fresh) ? (openByLine[i] || []) : []; + const nears = (rhymeToggle.checked && fresh) ? (nearByLine[i] || []) : []; let h = ''; if(!toks.length && !als.length && !opens.length){ h = esc(line); @@ -612,6 +615,7 @@ function render(){ toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); if(t.rs != null) cuts.add(t.rs); }); als.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); }); opens.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); }); + nears.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); }); const pts = [...cuts].sort((a,b)=>a-b); for(let k = 0; k < pts.length - 1; k++){ const a = pts[k], b = pts[k+1]; @@ -621,7 +625,8 @@ function render(){ const p = phrases.find(t=>t.s <= a && b <= t.e); const al = als.find(t=>t.s <= a && b <= t.e); const op = opens.find(t=>t.s <= a && b <= t.e); - if(!w && !p && !al && !op){ h += text; continue; } + const nr = nears.find(t=>t.s <= a && b <= t.e); + if(!w && !p && !al && !op && !nr){ h += text; continue; } // whole word fills dimly; the rhyming part gets a bright underline. // gray = an ending still waiting for its answer let style = ''; @@ -646,6 +651,7 @@ function render(){ style += `background:color-mix(in srgb, var(--ink-dim) 13%, transparent);`; } if(al) shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent)`); + if(nr) style += 'text-decoration:underline dotted color-mix(in srgb, var(--accent-2) 60%, transparent);text-underline-offset:3px;'; if(shadows.length) style += `box-shadow:${shadows.join(',')};`; h += `${text}`; } diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py index 1c86d9e..a2daf45 100644 --- a/tests/test_rhymes.py +++ b/tests/test_rhymes.py @@ -746,3 +746,11 @@ def test_rhyme_char_start(): from app import rhyme_char_start assert rhyme_char_start("tonight") == 3 # "ight" assert rhyme_char_start("write") == 2 # 'ite' (silent-e skipped) + + +def test_near_miss_radar(): + # two dead endings one vowel apart get flagged; true rhymes don't + text = ("I raise my hand\nlook at the bond\nthe city sleeps\nthe ocean wakes") + res = analyze(Draft(text=text)) + near = {res["lines"][t["l"]][t["s"]:t["e"]].lower() for t in res["near"]} + assert near == {"hand", "bond"}