Meter coaching: flag lines that break the stanza's pattern

When >=60% of a stanza's lines share a syllable count (+-1), outliers
get a wavy underline and the readout names the target count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 01:33:27 -04:00
parent a58dbf6706
commit 7eaf815e5b
3 changed files with 49 additions and 4 deletions
+20 -2
View File
@@ -540,13 +540,31 @@ def analyze(draft: Draft):
"slant": t["slant"] or groups_out[t["gid"]]["slant"]}
for t in [*tokens, *phrases] if t["gid"] is not None
]
meter = []
meter, meter_by_line = [], {}
for i, line in enumerate(lines):
if sids[i] is None:
continue
m = line_meter(line)
if m:
meter.append({"l": i, **m})
entry = {"l": i, **m, "off": False}
meter.append(entry)
meter_by_line[i] = entry
# meter coaching: when a stanza has a clear syllable pattern, flag
# the lines that break it
for s, lns in stanza_lines.items():
entries = [meter_by_line[i] for i in lns if i in meter_by_line]
if len(entries) < 3:
continue
counts = [e["syl"] for e in entries]
# mode with +-1 tolerance: the count that covers the most lines
mode = max(set(counts), key=lambda c: sum(abs(v - c) <= 1 for v in counts))
covered = sum(abs(v - mode) <= 1 for v in counts)
if covered / len(entries) >= 0.6:
for e in entries:
e["off"] = abs(e["syl"] - mode) > 1
for e in entries:
e["target"] = mode
return {"lines": lines, "tokens": toks_out,
"groups": groups_out, "stanzas": stanzas, "meter": meter}
+12 -2
View File
@@ -124,6 +124,11 @@
.rh.slant.end {
box-shadow: inset 0 -2px 0 0 color-mix(in srgb, var(--c) 45%, transparent);
}
.offbeat {
text-decoration: underline wavy color-mix(in srgb, var(--r6) 45%, transparent);
text-decoration-skip-ink: none;
text-underline-offset: 7px;
}
.toolbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
@@ -494,7 +499,8 @@ function render(){
pos = t.e;
});
h += esc(line.slice(pos));
html += h + '\n';
const m = fresh && analysis.meter ? analysis.meter.find(x=>x.l===i) : null;
html += (m && m.off ? `<span class="offbeat">${h}</span>` : h) + '\n';
});
highlight.innerHTML = html;
highlight.scrollTop = editor.scrollTop;
@@ -522,7 +528,11 @@ function buildReadout(){
const parts = [];
const ln = caretLine();
const m = analysis && analysis.meter ? analysis.meter.find(x=>x.l===ln) : null;
if(m) parts.push(`${m.syl} syl` + (m.label ? ` · ${m.label}` : ''));
if(m){
let p = `${m.syl} syl` + (m.label ? ` · ${m.label}` : '');
if(m.off) p += ` <span class="offline">· breaks stanza meter (~${m.target} syl)</span>`;
parts.push(p);
}
const st = caretStanza();
if(st && st.scheme) parts.push(`scheme <b>${st.scheme.toUpperCase().split('').join(' ')}</b>`);
schemeReadout.innerHTML = parts.join(' &nbsp;&nbsp; ');
+17
View File
@@ -222,3 +222,20 @@ def test_trailing_schwa_trimmed_militia_commissioner():
text = ("Swagger down pat, call my shit Patricia\n"
"Young Money militia, and I am the commissioner")
group_with(text, "patricia", "militia", "commissioner")
def test_meter_coaching_flags_outlier_line():
text = ("I walk the lonely road tonight\n"
"I hold the heavy stone of light\n"
"I call the fading stars to fight\n"
"and everybody wonders where the time has gone")
res = analyze(Draft(text=text))
flagged = [m["l"] for m in res["meter"] if m["off"]]
assert flagged == [3]
assert res["meter"][3]["target"] == 8
def test_meter_coaching_needs_a_pattern_to_break():
# two lines of wildly different lengths: no dominant pattern, no flags
res = analyze(Draft(text="short line here\na very much longer line that runs on and on"))
assert not any(m["off"] for m in res["meter"])