calliope-ts 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +32 -20
  2. package/dist/caesura.d.ts +33 -0
  3. package/dist/caesura.d.ts.map +1 -0
  4. package/dist/caesura.js +202 -0
  5. package/dist/calliope/boundaries.d.ts +19 -0
  6. package/dist/calliope/boundaries.d.ts.map +1 -0
  7. package/dist/calliope/boundaries.js +182 -0
  8. package/dist/calliope/bracketing.d.ts +11 -0
  9. package/dist/calliope/bracketing.d.ts.map +1 -0
  10. package/dist/calliope/bracketing.js +416 -0
  11. package/dist/calliope/deps.d.ts +4 -0
  12. package/dist/calliope/deps.d.ts.map +1 -0
  13. package/dist/calliope/deps.js +181 -0
  14. package/dist/calliope/engine.d.ts +3 -0
  15. package/dist/calliope/engine.d.ts.map +1 -0
  16. package/dist/calliope/engine.js +71 -0
  17. package/dist/calliope/feats.d.ts +9 -0
  18. package/dist/calliope/feats.d.ts.map +1 -0
  19. package/dist/calliope/feats.js +45 -0
  20. package/dist/calliope/names.d.ts +7 -0
  21. package/dist/calliope/names.d.ts.map +1 -0
  22. package/dist/calliope/names.js +42 -0
  23. package/dist/calliope/postag.d.ts +8 -0
  24. package/dist/calliope/postag.d.ts.map +1 -0
  25. package/dist/calliope/postag.js +250 -0
  26. package/dist/calliope/prosodic.d.ts +3 -0
  27. package/dist/calliope/prosodic.d.ts.map +1 -0
  28. package/dist/calliope/prosodic.js +275 -0
  29. package/dist/calliope/relstress.d.ts +4 -0
  30. package/dist/calliope/relstress.d.ts.map +1 -0
  31. package/dist/calliope/relstress.js +688 -0
  32. package/dist/calliope/stressrules.d.ts +28 -0
  33. package/dist/calliope/stressrules.d.ts.map +1 -0
  34. package/dist/calliope/stressrules.js +147 -0
  35. package/dist/calliope/syntax.d.ts +38 -0
  36. package/dist/calliope/syntax.d.ts.map +1 -0
  37. package/dist/calliope/syntax.js +234 -0
  38. package/dist/calliope/udpos.d.ts +13 -0
  39. package/dist/calliope/udpos.d.ts.map +1 -0
  40. package/dist/calliope/udpos.js +156 -0
  41. package/dist/clio/caesura.d.ts +27 -0
  42. package/dist/clio/caesura.d.ts.map +1 -0
  43. package/dist/clio/caesura.js +148 -0
  44. package/dist/clio/depfix.d.ts +13 -0
  45. package/dist/clio/depfix.d.ts.map +1 -0
  46. package/dist/clio/depfix.js +84 -0
  47. package/dist/clio/display.d.ts +32 -0
  48. package/dist/clio/display.d.ts.map +1 -0
  49. package/dist/clio/display.js +976 -0
  50. package/dist/clio/engine.d.ts +3 -0
  51. package/dist/clio/engine.d.ts.map +1 -0
  52. package/dist/clio/engine.js +28 -0
  53. package/dist/clio/parser.d.ts +10 -0
  54. package/dist/clio/parser.d.ts.map +1 -0
  55. package/dist/clio/parser.js +696 -0
  56. package/dist/clio/phonological.d.ts +41 -0
  57. package/dist/clio/phonological.d.ts.map +1 -0
  58. package/dist/clio/phonological.js +788 -0
  59. package/dist/clio/phrasestress.d.ts +6 -0
  60. package/dist/clio/phrasestress.d.ts.map +1 -0
  61. package/dist/clio/phrasestress.js +106 -0
  62. package/dist/clio/pipeline.d.ts +11 -0
  63. package/dist/clio/pipeline.d.ts.map +1 -0
  64. package/dist/clio/pipeline.js +147 -0
  65. package/dist/clio/rhyme.d.ts +65 -0
  66. package/dist/clio/rhyme.d.ts.map +1 -0
  67. package/dist/clio/rhyme.js +761 -0
  68. package/dist/clio/scandroid.d.ts +17 -0
  69. package/dist/clio/scandroid.d.ts.map +1 -0
  70. package/dist/clio/scandroid.js +435 -0
  71. package/dist/clio/scansion.d.ts +46 -0
  72. package/dist/clio/scansion.d.ts.map +1 -0
  73. package/dist/clio/scansion.js +1086 -0
  74. package/dist/clio/semantics.d.ts +44 -0
  75. package/dist/clio/semantics.d.ts.map +1 -0
  76. package/dist/clio/semantics.js +139 -0
  77. package/dist/clio/stress.d.ts +83 -0
  78. package/dist/clio/stress.d.ts.map +1 -0
  79. package/dist/clio/stress.js +1737 -0
  80. package/dist/clio/tagfix.d.ts +6 -0
  81. package/dist/clio/tagfix.d.ts.map +1 -0
  82. package/dist/clio/tagfix.js +101 -0
  83. package/dist/display.d.ts +0 -6
  84. package/dist/display.d.ts.map +1 -1
  85. package/dist/display.js +322 -141
  86. package/dist/engine.d.ts +9 -0
  87. package/dist/engine.d.ts.map +1 -0
  88. package/dist/engine.js +12 -0
  89. package/dist/index.d.ts +4 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +169 -26
  92. package/dist/parser.d.ts +3 -3
  93. package/dist/parser.d.ts.map +1 -1
  94. package/dist/parser.js +320 -563
  95. package/dist/phonological.d.ts +1 -1
  96. package/dist/phonological.d.ts.map +1 -1
  97. package/dist/phonological.js +73 -4
  98. package/dist/phrasestress.d.ts +6 -0
  99. package/dist/phrasestress.d.ts.map +1 -0
  100. package/dist/phrasestress.js +106 -0
  101. package/dist/rhyme.d.ts +40 -1
  102. package/dist/rhyme.d.ts.map +1 -1
  103. package/dist/rhyme.js +435 -7
  104. package/dist/scansion.d.ts +9 -0
  105. package/dist/scansion.d.ts.map +1 -1
  106. package/dist/scansion.js +145 -18
  107. package/dist/semantics.d.ts +44 -0
  108. package/dist/semantics.d.ts.map +1 -0
  109. package/dist/semantics.js +139 -0
  110. package/dist/stress.d.ts +127 -4
  111. package/dist/stress.d.ts.map +1 -1
  112. package/dist/stress.js +834 -61
  113. package/dist/types.d.ts +15 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/package.json +5 -3
  116. package/src/caesura.ts +201 -0
  117. package/src/calliope/boundaries.ts +190 -0
  118. package/src/calliope/bracketing.ts +390 -0
  119. package/src/calliope/deps.ts +160 -0
  120. package/src/calliope/engine.ts +77 -0
  121. package/src/calliope/feats.ts +46 -0
  122. package/src/calliope/names.ts +44 -0
  123. package/src/calliope/postag.ts +253 -0
  124. package/src/calliope/prosodic.ts +262 -0
  125. package/src/calliope/relstress.ts +645 -0
  126. package/src/calliope/stressrules.ts +147 -0
  127. package/src/calliope/syntax.ts +218 -0
  128. package/src/calliope/udpos.ts +152 -0
  129. package/src/calliope_src_contents.md +19049 -0
  130. package/src/clio/caesura.ts +145 -0
  131. package/src/clio/depfix.ts +88 -0
  132. package/src/clio/display.ts +1042 -0
  133. package/src/clio/engine.ts +38 -0
  134. package/src/clio/parser.ts +845 -0
  135. package/src/clio/phonological.ts +849 -0
  136. package/src/clio/phrasestress.ts +108 -0
  137. package/src/clio/pipeline.ts +154 -0
  138. package/src/clio/rhyme.ts +740 -0
  139. package/src/clio/scandroid.ts +434 -0
  140. package/src/clio/scansion.ts +1130 -0
  141. package/src/clio/semantics.ts +134 -0
  142. package/src/clio/stress.ts +1731 -0
  143. package/src/clio/tagfix.ts +104 -0
  144. package/src/display.ts +321 -137
  145. package/src/engine.ts +22 -0
  146. package/src/index.ts +176 -32
  147. package/src/parser.ts +346 -682
  148. package/src/phonological.ts +72 -4
  149. package/src/phrasestress.ts +108 -0
  150. package/src/rhyme.ts +428 -8
  151. package/src/scansion.ts +136 -15
  152. package/src/semantics.ts +134 -0
  153. package/src/stress.ts +794 -64
  154. package/src/types.ts +39 -4
  155. package/tests/DataForHayesLinesOnly.txt +364 -0
  156. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  157. package/tests/basic.test.ts +443 -37
  158. package/tests/bench-hayes.mjs +72 -0
  159. package/tests/wagner-stress.test.ts +188 -0
  160. package/vitest.config.ts +0 -15
@@ -0,0 +1,740 @@
1
+ // rhyme.ts — Rhyme-pair classification, stanza rhyme-scheme detection, and
2
+ // poetic FORM identification.
3
+ //
4
+ // Rhyme typology follows the maintainer's LYRICAL app (meter_exemplars.ts
5
+ // RHYME_TYPES) so the two toolkits stay cross-compatible: perfect / rich /
6
+ // family / assonant / consonant / augmented / diminished / wrenched / eye /
7
+ // identical, with the structural qualifiers masculine / feminine / dactylic.
8
+ // Phonology comes from the augmented CMU dictionary (nounsing-pro); the
9
+ // orthographic (eye/wrenched) tier is a deliberately guarded fallback.
10
+ //
11
+ // FORM is a stanza/poem-level verdict (this is where "ballad" lives — a
12
+ // quatrain with ballad rhyme AND alternating 4·3 beats — NOT in the rhythm
13
+ // pass). Form names align with LYRICAL's FORM_REGISTRY where they overlap
14
+ // (Couplet, Triplet, Quatrain, Limerick, Petrarchan/Shakespearean Sonnet…).
15
+
16
+ import * as nounsing from 'nounsing-pro';
17
+ import { ClsWord, LineResult, PhonologicalScansionDetail, StressLevel } from '../types.js';
18
+ import { isPunctuation } from './parser.js';
19
+ import { ictusProfile } from './scansion.js';
20
+ import { preCaesuralWords } from './caesura.js';
21
+
22
+ export type RhymeTypeName =
23
+ | 'identical' | 'rich' | 'perfect' | 'family'
24
+ | 'assonant' | 'consonant' | 'augmented' | 'diminished'
25
+ | 'wrenched' | 'eye';
26
+
27
+ export interface RhymePair {
28
+ type: RhymeTypeName;
29
+ /** masculine (stress on final syllable) / feminine (penult) / dactylic (antepenult) */
30
+ structure?: 'masculine' | 'feminine' | 'dactylic';
31
+ }
32
+
33
+ const VOWEL_RE = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
34
+ const PLOSIVES = new Set(['B', 'D', 'G', 'P', 'T', 'K']);
35
+ const FRICATIVES = new Set(['V', 'DH', 'Z', 'ZH', 'JH', 'F', 'TH', 'S', 'SH', 'CH', 'HH']);
36
+ const NASALS = new Set(['M', 'N', 'NG']);
37
+
38
+ const isVowelPhone = (p: string) => VOWEL_RE.test(p);
39
+ const base = (p: string) => p.replace(/[0-9]/g, '');
40
+ const sameFamily = (a: string, b: string) =>
41
+ (PLOSIVES.has(a) && PLOSIVES.has(b)) || (FRICATIVES.has(a) && FRICATIVES.has(b)) || (NASALS.has(a) && NASALS.has(b));
42
+
43
+ function phonesOf(word: string): string[] | null {
44
+ const clean = word.toLowerCase().replace(/[^a-z']/g, '');
45
+ if (!clean) return null;
46
+ try {
47
+ const ph = nounsing.firstPhonesForWord(clean);
48
+ if (typeof ph === 'string' && ph.length > 0) return ph.split(' ');
49
+ } catch { /* OOV */ }
50
+ return null;
51
+ }
52
+
53
+ /** Index of the LAST stressed (1/2) vowel; falls back to the last vowel. */
54
+ function lastStressedIdx(ph: string[]): number {
55
+ let lastVowel = -1;
56
+ for (let i = ph.length - 1; i >= 0; i--) {
57
+ if (!isVowelPhone(ph[i])) continue;
58
+ if (lastVowel < 0) lastVowel = i;
59
+ if (/[12]$/.test(ph[i])) return i;
60
+ }
61
+ return lastVowel;
62
+ }
63
+
64
+ /** masculine/feminine/dactylic from how many vowels FOLLOW the rhyming vowel. */
65
+ function structureOf(ph: string[], idx: number): RhymePair['structure'] {
66
+ let after = 0;
67
+ for (let i = idx + 1; i < ph.length; i++) if (isVowelPhone(ph[i])) after++;
68
+ return after === 0 ? 'masculine' : after === 1 ? 'feminine' : 'dactylic';
69
+ }
70
+
71
+ /** Guarded orthographic tier: shared ending ≥3 chars, matching final phone if
72
+ * known, and never on a shared bare "-ing" (mass false positives). */
73
+ function orthographicTier(a: string, b: string, pa: string[] | null, pb: string[] | null): RhymeTypeName | null {
74
+ const wa = a.toLowerCase().replace(/[^a-z]/g, '');
75
+ const wb = b.toLowerCase().replace(/[^a-z]/g, '');
76
+ if (wa.length < 3 || wb.length < 3) return null;
77
+ let common = 0;
78
+ while (common < Math.min(wa.length, wb.length) && wa[wa.length - 1 - common] === wb[wb.length - 1 - common]) common++;
79
+ if (common < 3) return null;
80
+ if (wa.slice(-3) === 'ing' && wb.slice(-3) === 'ing' && common <= 4) return null;
81
+ if (pa && pb && base(pa[pa.length - 1]) !== base(pb[pb.length - 1])) return null;
82
+ // Wrenched when the shared ending is an UNSTRESSED suffix of a polysyllable
83
+ // (temperate/date, manifestation/convention); plain eye-rhyme otherwise.
84
+ const polyUnstressed = (ph: string[] | null) => {
85
+ if (!ph) return false;
86
+ const idx = lastStressedIdx(ph);
87
+ return idx >= 0 && structureOf(ph, idx) !== 'masculine';
88
+ };
89
+ return polyUnstressed(pa) !== polyUnstressed(pb) ? 'wrenched' : 'eye';
90
+ }
91
+
92
+ /** Classify the rhyme relation between two line-end words (or null). */
93
+ export function classifyRhymePair(a: string, b: string): RhymePair | null {
94
+ const wa = a.toLowerCase().replace(/[^a-z']/g, '');
95
+ const wb = b.toLowerCase().replace(/[^a-z']/g, '');
96
+ if (!wa || !wb) return null;
97
+ const pa = phonesOf(a);
98
+ const pb = phonesOf(b);
99
+ if (wa === wb) return { type: 'identical', structure: pa ? structureOf(pa, lastStressedIdx(pa)) : undefined };
100
+ if (!pa || !pb) {
101
+ const t = orthographicTier(a, b, pa, pb);
102
+ return t ? { type: t } : null;
103
+ }
104
+ const ia = lastStressedIdx(pa);
105
+ const ib = lastStressedIdx(pb);
106
+ if (ia < 0 || ib < 0) return null;
107
+ const ra = pa.slice(ia);
108
+ const rb = pb.slice(ib);
109
+ const structure = structureOf(pa, ia);
110
+ const sameStructure = structure === structureOf(pb, ib);
111
+
112
+ const norm = (seg: string[]) => seg.map(base);
113
+ const na = norm(ra);
114
+ const nb = norm(rb);
115
+ const partsEqual = na.length === nb.length && na.every((p, i) => p === nb[i]);
116
+
117
+ if (partsEqual && sameStructure) {
118
+ const onsetA = ia > 0 ? base(pa[ia - 1]) : '';
119
+ const onsetB = ib > 0 ? base(pb[ib - 1]) : '';
120
+ return { type: onsetA === onsetB ? 'rich' : 'perfect', structure };
121
+ }
122
+
123
+ const vowelSame = na[0] === nb[0];
124
+ const codaA = na.slice(1).filter(p => !isVowelPhone(p));
125
+ const codaB = nb.slice(1).filter(p => !isVowelPhone(p));
126
+ const codaEq = codaA.length === codaB.length && codaA.every((p, i) => p === codaB[i]);
127
+
128
+ if (vowelSame) {
129
+ // Same stressed vowel. Matching-length codas whose consonants pair up
130
+ // within one phonetic family (wet/deck, dame/grain) → family rhyme.
131
+ if (codaA.length === codaB.length && codaA.length > 0
132
+ && codaA.every((p, i) => p === codaB[i] || sameFamily(p, codaB[i]))) {
133
+ return { type: 'family', structure };
134
+ }
135
+ // One extra trailing consonant on the second/first word (bray/brave).
136
+ if (codaA.length + 1 === codaB.length && codaA.every((p, i) => p === codaB[i])) return { type: 'augmented', structure };
137
+ if (codaB.length + 1 === codaA.length && codaB.every((p, i) => p === codaA[i])) return { type: 'diminished', structure };
138
+ return { type: 'assonant', structure };
139
+ }
140
+ if (codaEq && codaA.length > 0) return { type: 'consonant', structure };
141
+ const t = orthographicTier(a, b, pa, pb);
142
+ return t ? { type: t } : null;
143
+ }
144
+
145
+ // Strength tiers for scheme detection.
146
+ const STRONG: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect', 'family']);
147
+ const SLANT: Set<RhymeTypeName> = new Set(['assonant', 'consonant', 'augmented', 'diminished', 'wrenched', 'eye']);
148
+ // Full rhyme only — the stricter bar a pre-caesural INTERNAL rhyme must clear.
149
+ const STRICT_INTERNAL: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect']);
150
+
151
+ export interface LineRhyme {
152
+ endWord: string;
153
+ letter: string; // scheme letter ('A', 'B', …; '·' = unrhymed)
154
+ type?: RhymeTypeName; // relation to the matched earlier line
155
+ matchedLine?: number; // 0-based index within the stanza
156
+ }
157
+
158
+ /** Detect a stanza's rhyme scheme from its line-end words. Strong rhymes
159
+ * bind; slant-tier rhymes bind only when no strong candidate exists. */
160
+ export function detectScheme(endWords: string[]): LineRhyme[] {
161
+ const out: LineRhyme[] = [];
162
+ let nextLetter = 0;
163
+ for (let i = 0; i < endWords.length; i++) {
164
+ let best: { j: number; pair: RhymePair } | null = null;
165
+ for (let j = i - 1; j >= 0; j--) {
166
+ const pair = classifyRhymePair(endWords[j], endWords[i]);
167
+ if (!pair) continue;
168
+ if (STRONG.has(pair.type)) { best = { j, pair }; break; } // nearest strong wins
169
+ if (!best && SLANT.has(pair.type)) best = { j, pair }; // else nearest slant
170
+ }
171
+ if (best) {
172
+ out.push({ endWord: endWords[i], letter: out[best.j].letter, type: best.pair.type, matchedLine: best.j });
173
+ } else {
174
+ out.push({ endWord: endWords[i], letter: String.fromCharCode(65 + (nextLetter++ % 26)) });
175
+ }
176
+ }
177
+ // Rebind pass: a STRONG rhyme claims its partner back from an earlier
178
+ // slant-tier binding. (Sonnet 130: "rare" first slant-binds to the red/head
179
+ // group, then "compare" arrives as its perfect partner — the couplet wins.)
180
+ for (let k = 0; k < out.length; k++) {
181
+ const r = out[k];
182
+ if (r.matchedLine === undefined || !r.type || !STRONG.has(r.type)) continue;
183
+ const target = out[r.matchedLine];
184
+ if (target.matchedLine !== undefined && target.type && SLANT.has(target.type)) {
185
+ const fresh = String.fromCharCode(65 + (nextLetter++ % 26));
186
+ target.letter = fresh;
187
+ target.type = undefined;
188
+ target.matchedLine = undefined;
189
+ r.letter = fresh;
190
+ }
191
+ }
192
+ // Lines whose letter never recurs are unrhymed: mark '·' for readability.
193
+ const counts = new Map<string, number>();
194
+ for (const r of out) counts.set(r.letter, (counts.get(r.letter) ?? 0) + 1);
195
+ for (const r of out) if ((counts.get(r.letter) ?? 0) < 2) r.letter = '·';
196
+ // Re-letter the survivors in order of first appearance (A, B, C…).
197
+ const remap = new Map<string, string>();
198
+ let k = 0;
199
+ for (const r of out) {
200
+ if (r.letter === '·') continue;
201
+ if (!remap.has(r.letter)) remap.set(r.letter, String.fromCharCode(65 + (k++ % 26)));
202
+ r.letter = remap.get(r.letter)!;
203
+ }
204
+ return out;
205
+ }
206
+
207
+ const schemeStr = (rs: LineRhyme[]) => rs.map(r => r.letter).join('');
208
+
209
+ /** Canonical scheme for FORM matching: every line gets a letter in sequential
210
+ * first-appearance order, unrhymed lines (·) each their own — so "·A·A"
211
+ * compares as "ABCB". */
212
+ function canonicalScheme(rs: LineRhyme[]): string {
213
+ const remap = new Map<string, string>();
214
+ let k = 0;
215
+ const next = () => String.fromCharCode(65 + (k++ % 26));
216
+ let out = '';
217
+ for (const r of rs) {
218
+ if (r.letter === '·') { out += next(); continue; }
219
+ if (!remap.has(r.letter)) remap.set(r.letter, next());
220
+ out += remap.get(r.letter)!;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** Stanza-level form verdict (LYRICAL-compatible names where they overlap). */
226
+ function stanzaForm(rhymes: LineRhyme[], details: PhonologicalScansionDetail[]): string | undefined {
227
+ const s = canonicalScheme(rhymes);
228
+ const n = rhymes.length;
229
+ const meters = details.map(d => (d.consensusMeter ?? d.meter).split(' ')[0]);
230
+ const dominant = (name: string, frac = 0.5) => meters.filter(m => m === name).length / n >= frac;
231
+
232
+ if (n === 2 && s === 'AA') return 'couplet';
233
+ if (n === 3) {
234
+ if (s === 'ABA') return 'triplet (tercet, ABA)';
235
+ if (s === 'AAA') return 'mono-rhymed triplet';
236
+ }
237
+ if (n === 4) {
238
+ // Beat counts for the ballad gate: footCount (classical) or ictus count.
239
+ const beats = details.map((d, i) =>
240
+ d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses);
241
+ const alt43 = beats.length === 4 && beats[0] === beats[2] && beats[1] === beats[3]
242
+ && beats[0] === beats[1] + 1;
243
+ if (s === 'ABAB' || s === 'ABCB') {
244
+ if (alt43) return `ballad stanza (${s}, ${beats[0]}·${beats[1]})`;
245
+ return s === 'ABAB' ? 'quatrain (cross-rhymed, ABAB)' : `ballad-rhyme quatrain (ABCB)`;
246
+ }
247
+ if (s === 'ABBA') return 'quatrain (envelope, ABBA)';
248
+ if (s === 'AABB') return 'quatrain (couplet pair, AABB)';
249
+ if (s === 'AAAA') return 'mono-rhymed quatrain';
250
+ }
251
+ if (n === 5 && s === 'AABBA') {
252
+ const ternary = meters.filter(m => m === 'anapestic' || m === 'amphibrachic' || m === 'dactylic').length >= 3;
253
+ return ternary ? 'limerick (AABBA, ternary)' : 'limerick rhyme (AABBA)';
254
+ }
255
+ if (n === 6 && s === 'ABABCC') return 'sextilla / Venus-and-Adonis stanza (ABABCC)';
256
+ if (n === 7 && s === 'ABABBCC') return 'rhyme royal (ABABBCC)';
257
+ if (n === 8 && (s === 'ABABABCC' || s === 'ABABABAB')) return `octave (${s})`;
258
+
259
+ // Unrhymed stanza: blank verse when iambic pentameter dominates.
260
+ const unrhymed = rhymes.every(r => r.letter === '·');
261
+ if (unrhymed && n >= 3) {
262
+ const iambicPenta = details.filter(d =>
263
+ (d.consensusMeter ?? d.meter) === 'iambic pentameter').length / n;
264
+ if (iambicPenta >= 0.6) return 'blank verse';
265
+ if (details.every(d => d.meterName === 'free verse') || details.some(d => d.rhythmNote)) return undefined; // rhythm layer already speaks
266
+ }
267
+ return undefined;
268
+ }
269
+
270
+ /** Poem-level form verdicts that span stanzas (sonnets, terza rima…). */
271
+ function poemForm(stanzas: { rhymes: LineRhyme[]; details: PhonologicalScansionDetail[] }[]): string | undefined {
272
+ const all = stanzas.flatMap(st => st.rhymes);
273
+ const n = all.length;
274
+ // Whole-poem scheme with stanza-local letters concatenated is NOT meaningful;
275
+ // re-detect across the full poem for sonnet/terza checks.
276
+ if (n === 14) {
277
+ const rs = detectScheme(all.map(r => r.endWord));
278
+ const s = canonicalScheme(rs);
279
+ if (/^ABABCDCDEFEFGG$/.test(s)) return 'Shakespearean Sonnet';
280
+ if (/^ABBAABBA/.test(s)) return 'Petrarchan Sonnet';
281
+ if (s.endsWith('GG') || /(..)\1*..$/.test(s)) {
282
+ // 14 iambic-pentameter lines with a closing couplet still reads sonnet-like.
283
+ const last2 = rs[12].letter !== '·' && rs[12].letter === rs[13].letter;
284
+ const iambicPenta = stanzas.flatMap(st => st.details)
285
+ .filter(d => (d.consensusMeter ?? d.meter) === 'iambic pentameter').length / 14;
286
+ if (last2 && iambicPenta >= 0.5) return 'sonnet (14 lines, closing couplet)';
287
+ }
288
+ }
289
+ // Terza rima: chained tercets ABA BCB CDC…
290
+ if (stanzas.length >= 3 && stanzas.every(st => st.rhymes.length === 3)) {
291
+ let chained = true;
292
+ for (let i = 0; i + 1 < stanzas.length && chained; i++) {
293
+ const mid = stanzas[i].rhymes[1].endWord;
294
+ const nxt = stanzas[i + 1].rhymes;
295
+ const p1 = classifyRhymePair(mid, nxt[0].endWord);
296
+ const p3 = classifyRhymePair(mid, nxt[2].endWord);
297
+ if (!(p1 && STRONG.has(p1.type)) || !(p3 && STRONG.has(p3.type))) chained = false;
298
+ }
299
+ if (chained) return 'terza rima (ABA BCB CDC…)';
300
+ }
301
+ return undefined;
302
+ }
303
+
304
+ /** Syllable-bearing (non-punctuation) words of a line, in linear order. */
305
+ function lineWords(line: LineResult): ClsWord[] {
306
+ return line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
307
+ }
308
+
309
+ /** Last syllable-bearing word of a line (across its merged sentences). */
310
+ function lineEndWord(line: LineResult): string {
311
+ const ws = lineWords(line);
312
+ return ws.length ? ws[ws.length - 1].word : '';
313
+ }
314
+
315
+ // ─── INTERNAL (PRE-CAESURAL) RHYME — additive layer ─────────────────
316
+ //
317
+ // The per-stanza END-rhyme scheme (detectScheme) is the primary, UNTOUCHED
318
+ // system: every line keeps its end letter AND its rhyme type. Layered ON TOP,
319
+ // a word immediately preceding a caesura that FULLY rhymes (identical/rich/
320
+ // perfect — never the looser slant tiers, which on a caesura word are mostly
321
+ // coincidental noise) with an end word, or with another such internal word, is
322
+ // annotated as an INTERNAL rhyme. It reuses the end-rhyme letter it echoes, or
323
+ // — for an internal-only pair — a fresh letter that does not collide with the
324
+ // stanza's end letters. Internal rhymes render parenthesised before the end
325
+ // letter ("(A)B"), each carrying its own type. Purely additive: the end
326
+ // scheme's letters and types are never modified.
327
+
328
+ /** Attach pre-caesural internal rhymes to each line's `detail.rhyme`, on top of
329
+ * the per-stanza end scheme `rhymes` (from detectScheme). Mutates in place. */
330
+ function attachInternalRhymes(lines: LineResult[], rhymes: LineRhyme[]): void {
331
+ interface Pos { line: number; word: string; kind: 'end' | 'internal'; }
332
+ const positions: Pos[] = [];
333
+ for (let i = 0; i < lines.length; i++) {
334
+ const lw = lineWords(lines[i]);
335
+ const endObj = lw.length ? lw[lw.length - 1] : undefined;
336
+ positions.push({ line: i, word: rhymes[i].endWord, kind: 'end' });
337
+ const pre = preCaesuralWords(
338
+ lines[i].sentence.words, lines[i].phonologicalHierarchy, lines[i].phonologicalScansion.scansion);
339
+ for (const { word } of pre) {
340
+ if (word === endObj || !word.isContent) continue; // the end word / function words are not internal-rhyme bearers
341
+ positions.push({ line: i, word: word.word, kind: 'internal' });
342
+ }
343
+ }
344
+ // Union-find over positions. End-end links are NOT made (detectScheme already
345
+ // lettered the ends); only links INVOLVING an internal, at the full-rhyme tier.
346
+ const parent = positions.map((_, i) => i);
347
+ const find = (x: number): number => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
348
+ const union = (a: number, b: number) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
349
+ for (let a = 0; a < positions.length; a++) {
350
+ for (let b = a + 1; b < positions.length; b++) {
351
+ if (positions[a].kind === 'end' && positions[b].kind === 'end') continue;
352
+ if (!positions[a].word || !positions[b].word) continue;
353
+ const pair = classifyRhymePair(positions[a].word, positions[b].word);
354
+ if (pair && STRICT_INTERNAL.has(pair.type)) union(a, b);
355
+ }
356
+ }
357
+ // Letter per class: a class containing a lettered end takes that end's letter;
358
+ // an internal-only class of >=2 gets a fresh letter clear of the stanza's ends.
359
+ const endLetterAt = new Map<number, string>();
360
+ positions.forEach((p, i) => { if (p.kind === 'end' && rhymes[p.line].letter !== '\u00b7') endLetterAt.set(i, rhymes[p.line].letter); });
361
+ const used = new Set<string>(rhymes.filter(r => r.letter !== '\u00b7').map(r => r.letter));
362
+ let code = 0;
363
+ const freshLetter = () => { let c: string; do { c = String.fromCharCode(65 + (code++ % 26)); } while (used.has(c)); used.add(c); return c; };
364
+ const classMembers = new Map<number, number[]>();
365
+ for (let i = 0; i < positions.length; i++) { const r = find(i); if (!classMembers.has(r)) classMembers.set(r, []); classMembers.get(r)!.push(i); }
366
+ const classLetter = new Map<number, string>();
367
+ for (const [root, members] of classMembers) {
368
+ if (members.length < 2) continue;
369
+ const endMember = members.find(m => endLetterAt.has(m));
370
+ if (endMember !== undefined) classLetter.set(root, endLetterAt.get(endMember)!);
371
+ else if (members.some(m => positions[m].kind === 'internal')) classLetter.set(root, freshLetter());
372
+ }
373
+ const typeOf = (p: number): RhymeTypeName | undefined => {
374
+ for (const m of classMembers.get(find(p)) ?? []) {
375
+ if (m === p) continue;
376
+ const pair = classifyRhymePair(positions[m].word, positions[p].word);
377
+ if (pair && STRICT_INTERNAL.has(pair.type)) return pair.type;
378
+ }
379
+ return undefined;
380
+ };
381
+ for (let i = 0; i < lines.length; i++) {
382
+ const internal: { word: string; letter: string; type?: string }[] = [];
383
+ positions.forEach((p, idx) => {
384
+ if (p.line !== i || p.kind !== 'internal') return;
385
+ const letter = classLetter.get(find(idx));
386
+ if (letter) internal.push({ word: p.word, letter, type: typeOf(idx) });
387
+ });
388
+ const endLetter = rhymes[i].letter;
389
+ const intStr = internal.map(x => `(${x.letter})`).join('');
390
+ const notation = intStr + (endLetter !== '\u00b7' ? endLetter : (internal.length ? '' : '\u00b7'));
391
+ lines[i].phonologicalScansion.rhyme = {
392
+ endWord: rhymes[i].endWord,
393
+ letter: endLetter,
394
+ type: rhymes[i].type,
395
+ matchedLine: rhymes[i].matchedLine,
396
+ internal: internal.length ? internal : undefined,
397
+ notation,
398
+ };
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Annotate rhyme letters/types (`detail.rhyme`) and form verdicts
404
+ * (`detail.formNote`) across a poem. Stanza forms are per-stanza; a poem-level
405
+ * form (sonnet, terza rima) overrides stanza notes. Annotation-only — no
406
+ * meter/scansion/certainty is touched.
407
+ */
408
+ export function applyRhymeAndForm(stanzas: LineResult[][]): void {
409
+ // FORM detection uses per-stanza end-rhyme schemes (relative, restarting each
410
+ // stanza) — that is what couplet/quatrain/sonnet patterns are defined over.
411
+ const analyzed = stanzas.map(lines => {
412
+ const details = lines.map(l => l.phonologicalScansion);
413
+ const rhymes = detectScheme(lines.map(lineEndWord));
414
+ return { lines, details, rhymes };
415
+ });
416
+ for (const { details, rhymes } of analyzed) {
417
+ const form = stanzaForm(rhymes, details);
418
+ for (const d of details) d.formNote = form;
419
+ }
420
+ const pform = poemForm(analyzed);
421
+ if (pform) for (const { details } of analyzed) for (const d of details) d.formNote = pform;
422
+ // Per-stanza end-rhyme letters + TYPES (detectScheme), then the additive
423
+ // pre-caesural internal-rhyme layer. Writes `detail.rhyme`; the original
424
+ // end-rhyme display is restored and extended, never replaced.
425
+ for (const { lines, rhymes } of analyzed) attachInternalRhymes(lines, rhymes);
426
+ }
427
+
428
+ // ─── PHONOPOETICS — poem-wide end / caesural / head rhyme, alliteration,
429
+ // acrostic, with the maintainer's three-phase lettering ─────────────
430
+ //
431
+ // Lettering proceeds in three phases over ONE shared alphabet:
432
+ // 1. END words (line 1→N): each rhyme group gets a letter A,B,C…; a later word
433
+ // reuses an earlier group's letter when it rhymes with it (across stanzas).
434
+ // 2. CAESURAL words (pre-caesura): continue the alphabet (new letter per new
435
+ // caesural rhyme); a caesural word reuses an END letter ONLY if it rhymes
436
+ // with that exact end group.
437
+ // 3. HEAD words (line-initial): continue the alphabet, a new letter per head
438
+ // rhyme (head rhymes do not reuse end/caesural letters).
439
+ // Alliteration carries NO letters; an acrostic's letters are inherent.
440
+ // Each rhyme letter is to be COLOURED by the strongest relative-stress tier among
441
+ // the syllables it spans (`topStress`) — done at the display layer.
442
+
443
+ const STRESS_ORDER: StressLevel[] = ['x', 'w', 'n', 'm', 's'];
444
+ function topStressOf(...ws: (ClsWord | undefined)[]): StressLevel {
445
+ let best: StressLevel = 'x';
446
+ for (const w of ws) {
447
+ if (!w) continue;
448
+ for (const s of w.syllables) {
449
+ const r = s.relativeStress ?? 'w';
450
+ if (STRESS_ORDER.indexOf(r) > STRESS_ORDER.indexOf(best)) best = r;
451
+ }
452
+ }
453
+ return best;
454
+ }
455
+
456
+ /** First consonant phoneme of a word (for alliteration), or '' if vowel-initial/OOV. */
457
+ function onsetPhone(word: string): string {
458
+ const ph = phonesOf(word);
459
+ if (!ph || ph.length === 0) return '';
460
+ const p0 = base(ph[0]);
461
+ return isVowelPhone(p0) ? '' : p0;
462
+ }
463
+
464
+ export interface RhymeRel {
465
+ fromWord: string; fromLabel: string; // the rhyme site being annotated
466
+ toWord: string; toLabel: string; // its partner
467
+ letter: string;
468
+ type?: RhymeTypeName;
469
+ kind: 'end' | 'caesural' | 'head';
470
+ topStress: StressLevel; // strongest tier spanned (letter colour)
471
+ }
472
+ export interface Phonopoetics {
473
+ endScheme: string; // poem-wide canonical end scheme ("ABAB…")
474
+ end: RhymeRel[];
475
+ caesural: RhymeRel[];
476
+ head: RhymeRel[];
477
+ alliteration: { label: string; words: string[] }[];
478
+ acrostics: { labels: string[]; firsts: string[]; word: string }[];
479
+ }
480
+
481
+ /** Whole-poem phonopoetic analysis: structured data for the Phonopoetics
482
+ * synopsis section. Read-only — never mutates the per-line scansion. */
483
+ export function analyzePhonopoetics(stanzas: LineResult[][]): Phonopoetics {
484
+ const multi = stanzas.length > 1;
485
+ interface PL { line: LineResult; label: string; }
486
+ const PLs: PL[] = [];
487
+ stanzas.forEach((st, s) => st.forEach((line, l) =>
488
+ PLs.push({ line, label: multi ? `S${s + 1}L${l + 1}` : `L${l + 1}` })));
489
+
490
+ const endObj = (pl: PL): ClsWord | undefined => { const ws = lineWords(pl.line); return ws[ws.length - 1]; };
491
+ const headObj = (pl: PL): ClsWord | undefined => lineWords(pl.line).find(w => w.isContent);
492
+ const caesuralObjs = (pl: PL): ClsWord[] => {
493
+ const end = endObj(pl);
494
+ const pre = preCaesuralWords(pl.line.sentence.words, pl.line.phonologicalHierarchy, pl.line.phonologicalScansion.scansion);
495
+ return pre.map(p => p.word).filter(w => w !== end && w.isContent);
496
+ };
497
+
498
+ // ── Phase 1: END rhymes (poem-wide) ──
499
+ const endWords = PLs.map(pl => lineEndWord(pl.line));
500
+ const es = detectScheme(endWords);
501
+ const endScheme = canonicalScheme(es);
502
+ const byLetter = new Map<string, number[]>();
503
+ es.forEach((r, i) => { if (r.letter !== '·') { (byLetter.get(r.letter) ?? byLetter.set(r.letter, []).get(r.letter)!).push(i); } });
504
+ let nextCode = byLetter.size;
505
+ const freshLetter = (): string => String.fromCharCode(65 + (nextCode++ % 26));
506
+
507
+ const end: RhymeRel[] = [];
508
+ for (const [letter, idxs] of byLetter) {
509
+ for (let k = 1; k < idxs.length; k++) {
510
+ const i = idxs[k], j = idxs[k - 1]; // j earlier, i later → show earlier → later
511
+ end.push({
512
+ fromWord: endWords[j], fromLabel: PLs[j].label,
513
+ toWord: endWords[i], toLabel: PLs[i].label,
514
+ letter, type: es[i].type, kind: 'end',
515
+ topStress: topStressOf(endObj(PLs[i]), endObj(PLs[j])),
516
+ });
517
+ }
518
+ }
519
+
520
+ // ── Phase 2: CAESURAL rhymes ──
521
+ interface CW { pl: number; word: ClsWord; letter?: string; pLabel?: string; pWord?: string; type?: RhymeTypeName; }
522
+ const cws: CW[] = [];
523
+ PLs.forEach((pl, i) => caesuralObjs(pl).forEach(w => cws.push({ pl: i, word: w })));
524
+ // (a) bind to an exact END group it rhymes with → reuse that end letter
525
+ for (const cw of cws) {
526
+ for (let i = 0; i < PLs.length; i++) {
527
+ if (es[i].letter === '·') continue; // unrhymed end → not a "pair"
528
+ const ew = endObj(PLs[i]); if (!ew || ew === cw.word) continue;
529
+ const pair = classifyRhymePair(cw.word.word, ew.word);
530
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
531
+ cw.letter = es[i].letter; cw.pLabel = PLs[i].label; cw.pWord = ew.word; cw.type = pair.type; break;
532
+ }
533
+ }
534
+ }
535
+ // (b) caesural↔caesural among the still-unbound → fresh letters
536
+ const unbound = cws.filter(c => !c.letter);
537
+ for (let i = 0; i < unbound.length; i++) {
538
+ if (unbound[i].letter) continue;
539
+ for (let j = 0; j < i; j++) {
540
+ const pair = classifyRhymePair(unbound[i].word.word, unbound[j].word.word);
541
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
542
+ if (!unbound[j].letter) unbound[j].letter = freshLetter();
543
+ unbound[i].letter = unbound[j].letter;
544
+ // only the later member is annotated (points back) so each pair shows once
545
+ unbound[i].pLabel = PLs[unbound[j].pl].label; unbound[i].pWord = unbound[j].word.word; unbound[i].type = pair.type;
546
+ break;
547
+ }
548
+ }
549
+ }
550
+ const caesural: RhymeRel[] = cws.filter(c => c.letter && c.pLabel).map(c => ({
551
+ fromWord: c.word.word, fromLabel: PLs[c.pl].label,
552
+ toWord: c.pWord!, toLabel: c.pLabel!,
553
+ letter: c.letter!, type: c.type, kind: 'caesural' as const,
554
+ topStress: topStressOf(c.word),
555
+ }));
556
+
557
+ // ── Phase 3: HEAD rhymes (line-initial) — fresh letters, no reuse ──
558
+ interface HW { pl: number; word: ClsWord; letter?: string; pLabel?: string; pWord?: string; type?: RhymeTypeName; }
559
+ const hws: HW[] = PLs.map((pl, i) => ({ pl: i, word: headObj(pl)! })).filter(h => h.word);
560
+ for (let i = 0; i < hws.length; i++) {
561
+ if (hws[i].letter) continue;
562
+ for (let j = 0; j < i; j++) {
563
+ const pair = classifyRhymePair(hws[i].word.word, hws[j].word.word);
564
+ if (pair && STRICT_INTERNAL.has(pair.type)) {
565
+ if (!hws[j].letter) hws[j].letter = freshLetter();
566
+ hws[i].letter = hws[j].letter;
567
+ // only the later member is annotated (points back) so each pair shows once
568
+ hws[i].pLabel = PLs[hws[j].pl].label; hws[i].pWord = hws[j].word.word; hws[i].type = pair.type;
569
+ break;
570
+ }
571
+ }
572
+ }
573
+ const head: RhymeRel[] = hws.filter(h => h.letter && h.pLabel).map(h => ({
574
+ fromWord: h.word.word, fromLabel: PLs[h.pl].label,
575
+ toWord: h.pWord!, toLabel: h.pLabel!,
576
+ letter: h.letter!, type: h.type, kind: 'head' as const,
577
+ topStress: topStressOf(h.word),
578
+ }));
579
+
580
+ // ── Alliteration: maximal runs (≥2) of content words sharing first letter AND
581
+ // first consonant phoneme (function words may sit between, but do not count). ──
582
+ const alliteration: { label: string; words: string[] }[] = [];
583
+ for (const pl of PLs) {
584
+ const cw = lineWords(pl.line).filter(w => w.isContent);
585
+ let run: ClsWord[] = [];
586
+ const flush = () => { if (run.length >= 2) alliteration.push({ label: pl.label, words: run.map(w => w.word) }); run = []; };
587
+ for (const w of cw) {
588
+ const letter0 = (w.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
589
+ const phone0 = onsetPhone(w.word);
590
+ if (!letter0 || !phone0) { flush(); continue; }
591
+ if (run.length === 0) { run = [w]; continue; }
592
+ const prev = run[run.length - 1];
593
+ const pLetter = (prev.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
594
+ if (letter0 === pLetter && phone0 === onsetPhone(prev.word)) run.push(w);
595
+ else { flush(); run = [w]; }
596
+ }
597
+ flush();
598
+ }
599
+
600
+ // ── Acrostic: per-stanza (and, if multi-stanza, whole-poem) first letters that
601
+ // spell a dictionary word (≥3 letters). ──
602
+ const acrostics: { labels: string[]; firsts: string[]; word: string }[] = [];
603
+ const firstLetterOf = (line: LineResult): string => {
604
+ const ws = lineWords(line);
605
+ return ws.length ? (ws[0].word.match(/[a-z]/i)?.[0] ?? '').toUpperCase() : '';
606
+ };
607
+ const checkAcrostic = (lines: LineResult[], labels: string[]) => {
608
+ const firsts = lines.map(firstLetterOf);
609
+ if (firsts.some(f => !f)) return;
610
+ const word = firsts.join('');
611
+ if (word.length >= 3 && phonesOf(word)) acrostics.push({ labels, firsts, word });
612
+ };
613
+ stanzas.forEach((st, s) => checkAcrostic(st, st.map((_, l) => multi ? `S${s + 1}L${l + 1}` : `L${l + 1}`)));
614
+ if (multi) checkAcrostic(stanzas.flat(), PLs.map(p => p.label));
615
+
616
+ return { endScheme, end, caesural, head, alliteration, acrostics };
617
+ }
618
+
619
+ // ─── POEM-LEVEL META-MEASURE (synopsis) ─────────────────────────────
620
+ //
621
+ // A cumulative, NON-INTERFERING reading of the whole poem, shown at the foot of
622
+ // the reading views. It draws solely on determinations already made per line
623
+ // (meter, rhythm, rhyme, form) and never overrides them. Deliberately offers
624
+ // SEVERAL top conclusions rather than forcing a single verdict.
625
+
626
+ // Canonical end-rhyme schemes of the LYRICAL FORM_REGISTRY forms whose pattern is
627
+ // expressible in plain letters (refrain forms — villanelle, pantoum, rondeau … —
628
+ // need repetition checking and are deferred). Used to NOTE whole-poem rhyme-
629
+ // scheme alignment in the synopsis; the per-stanza/poem form layer remains the
630
+ // authority for the FORM verdict itself.
631
+ const REGISTRY_FORM_SCHEMES: { lines: number; scheme: string; name: string }[] = [
632
+ { lines: 2, scheme: 'AA', name: 'Couplet' },
633
+ { lines: 3, scheme: 'ABA', name: 'Triplet' },
634
+ { lines: 4, scheme: 'ABAB', name: 'Quatrain' },
635
+ { lines: 5, scheme: 'AABBA', name: 'Limerick' },
636
+ { lines: 6, scheme: 'ABABCC', name: 'Sextilla' },
637
+ { lines: 7, scheme: 'ABABBCC', name: 'Septet (rhyme royal)' },
638
+ { lines: 9, scheme: 'AAABBBCCC', name: 'Triad' },
639
+ { lines: 10, scheme: 'ABABCDECDE', name: 'English Ode' },
640
+ { lines: 14, scheme: 'ABABCDCDEFEFGG', name: 'Shakespearean Sonnet' },
641
+ { lines: 14, scheme: 'ABBAABBACDCDCD', name: 'Petrarchan Sonnet' },
642
+ { lines: 14, scheme: 'ABBAABBACDECDE', name: 'Petrarchan Sonnet' },
643
+ ];
644
+
645
+ function matchRegistryForm(totalLines: number, canonicalEndScheme: string): string | undefined {
646
+ return REGISTRY_FORM_SCHEMES.find(f => f.lines === totalLines && f.scheme === canonicalEndScheme)?.name;
647
+ }
648
+
649
+ /** A labelled line of the poem synopsis (display renders each). */
650
+ export interface SynopsisRow { label: string; value: string; }
651
+
652
+ const descriptorOf = (d: PhonologicalScansionDetail): string =>
653
+ d.metricalityNote ? 'plausible prose'
654
+ : d.rhythmNote ? d.rhythmNote // accentual/dolnik/taktovik → beats
655
+ : d.meterName === 'free verse' ? 'free verse'
656
+ : d.meter; // e.g. "iambic pentameter"
657
+
658
+ // Compact metre labels for the synopsis ("iambic pentameter" → "iamb penta").
659
+ // Rhythm notes ("4/3 ♪beat accentual"), "free verse", and "plausible prose" are
660
+ // left untouched (they are not family+foot labels).
661
+ const _FAMILY_ABBR: Record<string, string> = {
662
+ iambic: 'iamb', trochaic: 'troch', dactylic: 'dact', anapestic: 'anap',
663
+ amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
664
+ };
665
+ const _FOOT_ABBR: Record<string, string> = {
666
+ monometer: 'mono', dimeter: 'di', trimeter: 'tri', tetrameter: 'tetra',
667
+ pentameter: 'penta', hexameter: 'hexa', heptameter: 'hepta', octameter: 'octa',
668
+ };
669
+ function abbrevMeter(label: string): string {
670
+ const parts = label.split(' ');
671
+ if (parts.length === 2 && _FAMILY_ABBR[parts[0]] && _FOOT_ABBR[parts[1]]) {
672
+ return `${_FAMILY_ABBR[parts[0]]} ${_FOOT_ABBR[parts[1]]}`;
673
+ }
674
+ return label;
675
+ }
676
+
677
+ /**
678
+ * Build the cumulative poem synopsis: top meter(s)/mixed meters (accentual forms
679
+ * reported in beats), the poem-wide rhyme scheme (with internal rhymes), and the
680
+ * poetic-form alignment. Reads only existing per-line determinations.
681
+ */
682
+ export function summarizePoem(stanzas: LineResult[][]): SynopsisRow[] {
683
+ const lines = stanzas.flat();
684
+ const details = lines.map(l => l.phonologicalScansion);
685
+ const N = details.length;
686
+ const rows: SynopsisRow[] = [];
687
+ if (N === 0) return rows;
688
+
689
+ // ── Meter — several top conclusions, never forced into one ──
690
+ const tally = new Map<string, number>();
691
+ for (const d of details) tally.set(descriptorOf(d), (tally.get(descriptorOf(d)) ?? 0) + 1);
692
+ const ranked = [...tally.entries()].sort((a, b) => b[1] - a[1]);
693
+ let meterVal: string;
694
+ if (N === 1) {
695
+ meterVal = abbrevMeter(ranked[0][0]);
696
+ } else if (ranked[0][1] / N >= 0.6) {
697
+ const rest = ranked.slice(1, 3).map(([m, c]) => `${abbrevMeter(m)} (${c})`);
698
+ meterVal = `predominantly ${abbrevMeter(ranked[0][0])} (${ranked[0][1]}/${N})`
699
+ + (rest.length ? `; also ${rest.join(', ')}` : '');
700
+ } else {
701
+ meterVal = 'Mixed | ' + ranked.slice(0, 3).map(([m, c]) => `${abbrevMeter(m)} (${c}/${N})`).join(' · ');
702
+ }
703
+ // "Rhythm" = the metre make-up; "Meter" = the mean fit % (the maintainer's
704
+ // synopsis taxonomy).
705
+ rows.push({ label: 'Rhythm', value: meterVal });
706
+
707
+ // ── Meter — mean of the per-line fit certainties ──
708
+ const meanCert = Math.round(details.reduce((s, d) => s + d.certainty, 0) / N);
709
+ rows.push({ label: 'Meter', value: `~${meanCert}% mean line-wise fit` });
710
+
711
+ // ── Form — the form layer's verdict(s) + registry-scheme alignment. (The
712
+ // detailed rhyme listing now lives in the Phonopoetics section; the synopsis
713
+ // carries only the canonical End-Rhyme Scheme, below.) ──
714
+ const endRhymes = N >= 2 ? detectScheme(lines.map(lineEndWord)) : [];
715
+ const endScheme = endRhymes.length ? canonicalScheme(endRhymes) : '';
716
+ const hasEndRhyme = endRhymes.some(r => r.letter !== '·');
717
+ const forms = [...new Set(details.map(d => d.formNote).filter((x): x is string => !!x))];
718
+ let formVal: string | undefined = forms.length ? forms.join(' · ') : undefined;
719
+ if (N >= 2) {
720
+ const reg = matchRegistryForm(N, endScheme);
721
+ if (reg && !(formVal && formVal.includes(reg))) {
722
+ formVal = (formVal ? formVal + ' · ' : '') + `aligns with the ${reg} scheme`;
723
+ }
724
+ }
725
+ if (formVal) rows.push({ label: 'Form', value: formVal });
726
+
727
+ // ── End-Rhyme Scheme — poem-wide canonical scheme, its own row (only end
728
+ // rhymes are matched against forms; caesural/head live in Phonopoetics). ──
729
+ if (hasEndRhyme) rows.push({ label: 'End-Rhyme Scheme', value: endScheme });
730
+
731
+ // ── Heterometric advisory — the whole-poem observation that per-line beat
732
+ // counts vary widely (kept OUT of the per-line display, as an advisory here). ──
733
+ const beats = details.map(d => (d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses));
734
+ const lo = Math.min(...beats), hi = Math.max(...beats);
735
+ if (N >= 3 && hi - lo >= 3) {
736
+ rows.push({ label: 'Note', value: `line lengths vary — ${lo}–${hi} beats per line` });
737
+ }
738
+
739
+ return rows;
740
+ }