mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-06-11 17:08:33 +00:00
Near-miss radar (#3): dead endings one phoneme from a rhyme
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
+7
-1
@@ -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 += `<span class="hseg" style="${style}">${text}</span>`;
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user