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,1086 @@
1
+ // scansion.ts — Unified gradient foot-fitting for McAleese's phonological method.
2
+ //
3
+ // DESIGN (2026-05-29 rewrite):
4
+ // Meter selection and scansion-string assembly share ONE model. For every
5
+ // candidate meter we find — by dynamic programming — the best segmentation of
6
+ // the line's actual relative-stress contour into that meter's feet, allowing
7
+ // linguistically-grounded variation (gradient feet, single-foot substitutions,
8
+ // anacrusis, catalexis, feminine endings, edge-licensed inversion). The DP's
9
+ // score decides the meter; the very same segmentation IS the scansion. No more
10
+ // disconnect between "which meter" and "what does the foot string look like".
11
+ //
12
+ // Layered on top is McAleese's key-stress weighting: meters that place their
13
+ // beats at the right edges of phonological phrases / intonational units
14
+ // ("beginnings free, endings strict", Kiparsky/Hayes) are rewarded.
15
+ //
16
+ // Gradient feet (per the project's 4-level scale w < n < m < s): an iamb may
17
+ // surface as ws / ns / wm; an anapest as wws / wns / wnm / nms; etc. Strong
18
+ // metrical positions accept s/m fully and n by promotion (more readily when the
19
+ // syllable carries a lexical content stress demoted only by clash); weak
20
+ // positions accept w/n, tolerate m as a mild demotion, and treat s as the
21
+ // cardinal "stress maximum in weak position" violation (relaxed at a
22
+ // phonological-phrase left edge, per Fabb 1997).
23
+ import { isPunctuation, isQuoteTag } from './parser.js';
24
+ // ─── CONSTANTS: metre definitions & key-stress weights ──────────────
25
+ const METRES = {
26
+ iambic: { foot: 'ws', sylCount: 2 },
27
+ trochaic: { foot: 'sw', sylCount: 2 },
28
+ spondaic: { foot: 'ss', sylCount: 2 },
29
+ pyrrhic: { foot: 'ww', sylCount: 2 },
30
+ anapestic: { foot: 'wws', sylCount: 3 },
31
+ dactylic: { foot: 'sww', sylCount: 3 },
32
+ amphibrachic: { foot: 'wsw', sylCount: 3 },
33
+ bacchic: { foot: 'wss', sylCount: 3 },
34
+ };
35
+ // McAleese's prosodic-unit importance weights for key-stress scoring.
36
+ const WEIGHT = { IU: 3, PP: 2, PW3plus: 2, PW2: 1, CP: 1 };
37
+ // Candidate base meters. Iambic/trochaic/anapestic/dactylic/amphibrachic are
38
+ // the base meters of English verse and compete on equal footing. Bacchic is
39
+ // included only as a marginal whole-line candidate (it normally appears one foot
40
+ // at a time); pyrrhic & spondaic never form a whole line and are handled solely
41
+ // as in-line substitution feet, never as standalone candidates.
42
+ const CANDIDATE_METERS = [
43
+ 'iambic', 'trochaic', 'anapestic', 'dactylic', 'amphibrachic', 'bacchic',
44
+ ];
45
+ /**
46
+ * Flatten a sentence's words into a context-rich syllable stream in linear
47
+ * (reading) order. Phrasing context (PP starts, caesurae) is derived from the
48
+ * IU hierarchy by membership, so it stays correct even when clitic groups are
49
+ * stored out of linear order inside a phonological phrase.
50
+ */
51
+ function flattenSyllables(words, ius) {
52
+ // Map each word -> "iuIdx.ppIdx" key for caesura / PP-start detection.
53
+ const ppKeyOf = new Map();
54
+ const iuIdxOf = new Map();
55
+ if (ius) {
56
+ for (let iuIdx = 0; iuIdx < ius.length; iuIdx++) {
57
+ for (let ppIdx = 0; ppIdx < ius[iuIdx].phonologicalPhrases.length; ppIdx++) {
58
+ for (const cg of ius[iuIdx].phonologicalPhrases[ppIdx].cliticGroups) {
59
+ for (const tok of cg.tokens) {
60
+ ppKeyOf.set(tok, `${iuIdx}.${ppIdx}`);
61
+ iuIdxOf.set(tok, iuIdx);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ const result = [];
68
+ let idx = 0;
69
+ let wordCounter = 0;
70
+ let prevIuIdx = undefined;
71
+ let prevPPKey = undefined;
72
+ let sawPunctSinceLastSyl = true; // line start counts as a boundary
73
+ let prevWasPunct = false;
74
+ for (const w of words) {
75
+ // Quotation marks are tokens but not prosodic breaks — they neither close an
76
+ // IU nor license a caesura (a quoted word is read in the same breath).
77
+ if (isPunctuation(w.lexicalClass)) {
78
+ if (!isQuoteTag(w.lexicalClass))
79
+ sawPunctSinceLastSyl = true;
80
+ prevWasPunct = true;
81
+ continue;
82
+ }
83
+ const isPoly = w.syllables.length > 1;
84
+ const myIu = iuIdxOf.get(w);
85
+ const myPP = ppKeyOf.get(w);
86
+ const ppChanged = myPP !== undefined && myPP !== prevPPKey;
87
+ const iuChanged = myIu !== undefined && myIu !== prevIuIdx;
88
+ const caesura = sawPunctSinceLastSyl || iuChanged;
89
+ for (let si = 0; si < w.syllables.length; si++) {
90
+ const s = w.syllables[si];
91
+ result.push({
92
+ word: w,
93
+ stress: s.relativeStress ?? 'w',
94
+ lexicalStress: s.lexicalStress ?? s.stress ?? 0,
95
+ isContent: w.isContent,
96
+ globalIndex: idx++,
97
+ wordIdx: wordCounter,
98
+ isWordStart: si === 0,
99
+ isWordEnd: si === w.syllables.length - 1,
100
+ isPoly,
101
+ weight: s.weight || 'L',
102
+ isPPStart: ppChanged && si === 0,
103
+ caesuraBefore: caesura && si === 0,
104
+ clashAdjacent: false, // filled in below
105
+ promotable: false, // filled in below
106
+ isLineFinal: false, // filled in below
107
+ extrametrical: s.extrametrical,
108
+ });
109
+ }
110
+ prevIuIdx = myIu;
111
+ prevPPKey = myPP;
112
+ sawPunctSinceLastSyl = false;
113
+ prevWasPunct = false;
114
+ wordCounter++;
115
+ }
116
+ // Second pass: mark stress clashes (a strong syllable adjacent to another strong one).
117
+ for (let i = 0; i < result.length; i++) {
118
+ const prevStrong = i > 0 && isStrong(result[i - 1].stress);
119
+ const nextStrong = i < result.length - 1 && isStrong(result[i + 1].stress);
120
+ result[i].clashAdjacent = prevStrong || nextStrong;
121
+ }
122
+ // Third pass: Attridge promotion — an unstressed syllable flanked on BOTH
123
+ // sides by syllables no stronger than 'w' (or by a line edge) can realise a
124
+ // metrical beat ("promotion", Attridge 1982; the 4B4V 'o-with-beat'). This
125
+ // is what lets "happens to BE a French poet" carry its mid-line beat on a
126
+ // function verb without inventing lexical stress for it.
127
+ const weakOrEdge = (i) => i < 0 || i >= result.length || result[i].stress === 'x' || result[i].stress === 'w';
128
+ for (let i = 0; i < result.length; i++) {
129
+ result[i].promotable = result[i].stress === 'w' && weakOrEdge(i - 1) && weakOrEdge(i + 1);
130
+ }
131
+ if (result.length > 0)
132
+ result[result.length - 1].isLineFinal = true;
133
+ return result;
134
+ }
135
+ // ─── KEY-STRESS EXTRACTION (retained for display + right-edge weighting) ─
136
+ function collectIUTokens(iu) {
137
+ const tokens = [];
138
+ for (const pp of iu.phonologicalPhrases)
139
+ tokens.push(...collectPPTokens(pp));
140
+ return tokens;
141
+ }
142
+ function collectPPTokens(pp) {
143
+ const tokens = [];
144
+ for (const cg of pp.cliticGroups)
145
+ tokens.push(...cg.tokens);
146
+ return tokens;
147
+ }
148
+ /** The metrically diagnostic tail of a unit: rightmost strong syllable + its predecessor(s). */
149
+ function extractPhrasalTail(syls, maxLen = 2) {
150
+ if (syls.length === 0)
151
+ return [];
152
+ let rightStrong = -1;
153
+ for (let i = syls.length - 1; i >= 0; i--) {
154
+ if (syls[i].stress === 's' || syls[i].stress === 'm') {
155
+ rightStrong = i;
156
+ break;
157
+ }
158
+ }
159
+ if (rightStrong === -1)
160
+ return syls.slice(-maxLen);
161
+ const start = Math.max(0, rightStrong - (maxLen - 1));
162
+ return syls.slice(start, rightStrong + 1);
163
+ }
164
+ function rightmostStressed(tokens, flat) {
165
+ for (let i = flat.length - 1; i >= 0; i--) {
166
+ const fs = flat[i];
167
+ if (tokens.includes(fs.word) && fs.stress !== 'w' && fs.stress !== 'x')
168
+ return fs;
169
+ }
170
+ return undefined;
171
+ }
172
+ export function extractKeyStresses(ius, words) {
173
+ const result = [];
174
+ const flat = flattenSyllables(words);
175
+ // Polysyllabic words: whole contour.
176
+ for (const w of words) {
177
+ if (isPunctuation(w.lexicalClass))
178
+ continue;
179
+ const sc = w.syllables.length;
180
+ if (sc < 2)
181
+ continue;
182
+ const pattern = w.syllables.map(s => s.relativeStress ?? 'w').join('');
183
+ const weight = sc >= 3 ? WEIGHT.PW3plus : WEIGHT.PW2;
184
+ const firstSylIdx = flat.findIndex(fs => fs.word === w);
185
+ const positions = Array.from({ length: sc }, (_, j) => firstSylIdx + j);
186
+ result.push({ unitType: 'PW', pattern, weight, positions });
187
+ }
188
+ for (const iu of ius) {
189
+ const iuTokens = collectIUTokens(iu);
190
+ if (iuTokens.length === 0)
191
+ continue;
192
+ const iuSyls = flat.filter(fs => iuTokens.includes(fs.word));
193
+ if (iuSyls.length > 0) {
194
+ const tail = extractPhrasalTail(iuSyls, 3);
195
+ result.push({ unitType: 'IU', pattern: tail.map(fs => fs.stress).join(''), weight: WEIGHT.IU, positions: tail.map(fs => fs.globalIndex) });
196
+ }
197
+ for (const pp of iu.phonologicalPhrases) {
198
+ const ppTokens = collectPPTokens(pp);
199
+ if (ppTokens.length === 0)
200
+ continue;
201
+ const ppSyls = flat.filter(fs => ppTokens.includes(fs.word));
202
+ if (ppSyls.length > 0) {
203
+ const tail = extractPhrasalTail(ppSyls);
204
+ result.push({ unitType: 'PP', pattern: tail.map(fs => fs.stress).join(''), weight: WEIGHT.PP, positions: tail.map(fs => fs.globalIndex) });
205
+ }
206
+ for (const cg of pp.cliticGroups) {
207
+ if (cg.tokens.length === 0)
208
+ continue;
209
+ const cp = rightmostStressed(cg.tokens, flat);
210
+ if (cp)
211
+ result.push({ unitType: 'CP', pattern: cp.stress, weight: WEIGHT.CP, positions: [cp.globalIndex] });
212
+ }
213
+ }
214
+ }
215
+ return result;
216
+ }
217
+ // ─── GRADIENT SYLLABLE FIT ─────────────────────────────────────────
218
+ // A syllable is "strong" if it bears at least moderate relative stress.
219
+ function isStrong(s) { return s === 's' || s === 'm'; }
220
+ /**
221
+ * Score one syllable against an expected metrical position.
222
+ * Weak positions: w/n welcome, m a mild demotion, s the cardinal violation.
223
+ * Strong positions: s/m welcome, n a promotion (better when it is a content
224
+ * stress demoted only by clash), w a missing beat.
225
+ */
226
+ function scoreSyllable(syl, expected) {
227
+ const a = syl.stress;
228
+ if (expected === 'S') {
229
+ if (a === 's')
230
+ return 4;
231
+ if (a === 'm')
232
+ return 3;
233
+ if (a === 'n') {
234
+ // Promotion into a strong slot. A content syllable whose lexical stress
235
+ // is primary (demoted to 'n' only by a phrasal clash) re-promotes readily.
236
+ if (syl.lexicalStress >= 2)
237
+ return 2.5;
238
+ // Line-final beat: the strongest metrical slot accepts a secondary
239
+ // syllable (e.g. clause-final modal "might"), as in sung/musical verse.
240
+ if (syl.isLineFinal)
241
+ return 2.2;
242
+ return syl.isContent ? 1.5 : 0.8;
243
+ }
244
+ // 'x' (zero-provision clitic) in a strong slot — the cardinal missing beat,
245
+ // worse than a plain 'w': beating "the"/"a"/"of" is maximally unmetrical.
246
+ if (a === 'x')
247
+ return -3.2;
248
+ // 'w' in a strong slot — a missing beat, UNLESS flanked by weakness on both
249
+ // sides: Attridge promotion lets such a syllable realise the beat ("happens
250
+ // to BE a"). Value sits just below the pyrrhic-substitution alternative
251
+ // (2+2−1.6 = 2.4 for the foot) so duple meters keep their pyrrhics while
252
+ // ternary meters — which have no cheap pyrrhic escape — recover the beat.
253
+ if (syl.promotable)
254
+ return 0.3;
255
+ return syl.lexicalStress >= 2 ? 0 : -2.5;
256
+ }
257
+ else {
258
+ // 'x' (zero-provision clitic) in a weak slot — the ideal upbeat, marginally
259
+ // better than a plain weak syllable.
260
+ if (a === 'x')
261
+ return 2.2;
262
+ if (a === 'w')
263
+ return 2;
264
+ if (a === 'n')
265
+ return 1.6;
266
+ if (a === 'm') {
267
+ // Mild demotion; cheap at a PP left edge (Fabb) or in a stress clash
268
+ // (one of two adjacent stresses must yield to the meter).
269
+ if (syl.isPPStart)
270
+ return 0.5;
271
+ return syl.clashAdjacent ? -0.3 : -1.2;
272
+ }
273
+ // 's' in a weak slot — stress maximum in weak position (Fabb), the cardinal
274
+ // violation in isolation, but a routine, cheap demotion inside a clash.
275
+ if (syl.isPPStart)
276
+ return -0.6;
277
+ return syl.clashAdjacent ? -1.3 : -3.2;
278
+ }
279
+ }
280
+ // Substitution / variation penalties (negative = cost). Tuned so that an
281
+ // occasional substitution is cheap (one foot at a time) but a meter that needs
282
+ // substitution on most feet loses to the meter whose primary foot those are.
283
+ const P = {
284
+ INV_EDGE: -0.4, // duple inversion at a licensed left edge (line start / post-caesura)
285
+ INV_MID: -3.0, // duple inversion mid-line (marked)
286
+ TRI_IN_DUPLE: -2.2, // anapest/dactyl substituting inside a duple meter
287
+ DUPLE_IN_TRI: -1.3, // duple foot substituting inside a triple meter (often catalexis)
288
+ PYRR: -1.6,
289
+ SPON: -1.6,
290
+ CATAL: -0.4, // catalexis (truncated final foot)
291
+ FEM: -0.5, // feminine ending / hypercatalexis (extra final weak)
292
+ ANAC1: -0.5, // single anacrusis upbeat (falling meters)
293
+ ANAC2: -1.2, // double anacrusis upbeat
294
+ ACEPH: -0.6, // acephalous / headless first foot (rising meters)
295
+ ORPHAN: -8, // last-resort single-syllable foot
296
+ };
297
+ const S = (n) => () => n;
298
+ function getTemplatesForMeter(meter) {
299
+ let t = [];
300
+ switch (meter) {
301
+ case 'iambic':
302
+ // No headless ['S'] start: a stressed iambic line-opening is a trochaic
303
+ // INVERSION (below), and a line that needs inversion on two feet is really
304
+ // trochaic — letting the DP discover that rather than masking it.
305
+ t = [
306
+ { pattern: ['W', 'S'], score: S(0), isPrimary: true },
307
+ { pattern: ['S', 'W'], score: c => (c.isStart || c.caesuraBefore) ? P.INV_EDGE : P.INV_MID }, // inversion
308
+ { pattern: ['W', 'W', 'S'], score: S(P.TRI_IN_DUPLE) }, // anapestic substitution
309
+ { pattern: ['W', 'W'], score: S(P.PYRR) }, // pyrrhic
310
+ { pattern: ['S', 'S'], score: S(P.SPON) }, // spondee
311
+ { pattern: ['W', 'S', 'W'], score: S(P.FEM), atEnd: true }, // feminine ending
312
+ { pattern: ['S'], score: S(P.CATAL), atEnd: true, isPrimary: true }, // final beat-bearing monosyllable
313
+ ];
314
+ break;
315
+ case 'trochaic':
316
+ t = [
317
+ { pattern: ['S', 'W'], score: S(0), isPrimary: true },
318
+ { pattern: ['S'], score: S(P.CATAL), atEnd: true, isPrimary: true }, // catalexis (very common)
319
+ { pattern: ['W', 'S'], score: c => (c.isStart || c.caesuraBefore) ? P.INV_EDGE : P.INV_MID }, // rising inversion
320
+ { pattern: ['S', 'W', 'W'], score: S(P.TRI_IN_DUPLE) }, // dactylic substitution
321
+ { pattern: ['S', 'S'], score: S(P.SPON) },
322
+ { pattern: ['W', 'W'], score: S(P.PYRR) },
323
+ // A single opening upbeat is true anacrusis — extrametrical, not a foot.
324
+ // A DOUBLE upbeat fills a whole metrical position (a pyrrhic-substituted
325
+ // first foot: "By the | SHORES of | GIT-che | GU-mee" stays tetrameter),
326
+ // so it still counts toward the meter-length name.
327
+ { pattern: ['W'], score: S(P.ANAC1), atStart: true, countsAsFoot: false }, // anacrusis upbeat
328
+ { pattern: ['W', 'W'], score: S(P.ANAC2), atStart: true },
329
+ ];
330
+ break;
331
+ case 'anapestic':
332
+ t = [
333
+ { pattern: ['W', 'W', 'S'], score: S(0), isPrimary: true },
334
+ { pattern: ['W', 'S'], score: S(P.DUPLE_IN_TRI) }, // iambic substitution / acephalous
335
+ { pattern: ['S'], score: S(P.ACEPH), atStart: true },
336
+ // NB: making this acephalous start PRIMARY was tried (2026-06-12) to
337
+ // mirror the amphibrach's primary catalectic ending — it fixed some
338
+ // standalone Cowper-type anapests but boosted anapest against IAMBIC
339
+ // lines corpus-wide (epg64 −1.4pt): reverted. The amphi/anapest
340
+ // naming on shared grids is handled by sibling arbitration + the
341
+ // stanza anacrusis anchor instead.
342
+ { pattern: ['W', 'S'], score: c => (c.isStart || c.caesuraBefore) ? P.ACEPH : P.DUPLE_IN_TRI, atStart: true },
343
+ { pattern: ['W', 'W', 'S', 'W'], score: S(P.FEM), atEnd: true },
344
+ { pattern: ['W', 'S', 'W'], score: S(P.FEM), atEnd: true },
345
+ ];
346
+ break;
347
+ case 'dactylic':
348
+ t = [
349
+ { pattern: ['S', 'W', 'W'], score: S(0), isPrimary: true },
350
+ { pattern: ['S', 'W'], score: S(P.DUPLE_IN_TRI), atEnd: true, isPrimary: true }, // catalexis
351
+ { pattern: ['S'], score: S(P.CATAL), atEnd: true, isPrimary: true },
352
+ { pattern: ['S', 'W'], score: S(P.DUPLE_IN_TRI) }, // trochaic substitution
353
+ { pattern: ['W'], score: S(P.ANAC1), atStart: true, countsAsFoot: false }, // anacrusis
354
+ { pattern: ['W', 'W'], score: S(P.ANAC2), atStart: true }, // fills a foot slot (see trochaic)
355
+ ];
356
+ break;
357
+ case 'amphibrachic':
358
+ t = [
359
+ { pattern: ['W', 'S', 'W'], score: S(0), isPrimary: true },
360
+ { pattern: ['W', 'S'], score: S(P.CATAL), atEnd: true, isPrimary: true }, // catalexis
361
+ { pattern: ['S', 'W'], score: S(P.ACEPH), atStart: true }, // acephalous (lost initial weak)
362
+ { pattern: ['S'], score: S(P.ACEPH), atStart: true },
363
+ { pattern: ['W', 'S', 'W', 'W'], score: S(P.FEM), atEnd: true },
364
+ // Clipped clausula: the final foot reduced to its bare ictus ("alone
365
+ // in his BELgian HELL" — beats 2,5,7). Strictly this 1-slack final
366
+ // interval is dolnik-leaning, but without the template the whole
367
+ // amphibrachic fit collapsed to orphan feet (score ≈0.5) and the
368
+ // family vanished from the rankings of clipped lines entirely.
369
+ // Costed like a ternary-in-duple substitution (heavier than the
370
+ // catalectic WS): at the cheaper DUPLE_IN_TRI it poached iambic
371
+ // lines corpus-wide (epg64 −0.9pt).
372
+ { pattern: ['S'], score: S(P.TRI_IN_DUPLE), atEnd: true },
373
+ ];
374
+ break;
375
+ case 'bacchic':
376
+ t = [
377
+ { pattern: ['W', 'S', 'S'], score: S(0), isPrimary: true },
378
+ { pattern: ['W', 'S'], score: S(P.CATAL), atEnd: true },
379
+ { pattern: ['S', 'S'], score: S(P.ACEPH), atStart: true },
380
+ { pattern: ['S'], score: S(P.ACEPH), atStart: true },
381
+ ];
382
+ break;
383
+ default:
384
+ t = [{ pattern: ['W', 'S'], score: S(0), isPrimary: true }];
385
+ }
386
+ // Last-resort fallbacks so the DP always reaches the end of any contour.
387
+ // The orphan S bears a beat (counts as a defective foot); the orphan W does not.
388
+ t.push({ pattern: ['S'], score: S(P.ORPHAN) });
389
+ t.push({ pattern: ['W'], score: S(P.ORPHAN), countsAsFoot: false });
390
+ return t;
391
+ }
392
+ function fitMeter(syls, meter) {
393
+ const N = syls.length;
394
+ const templates = getTemplatesForMeter(meter);
395
+ const memo = new Array(N + 1);
396
+ function solve(i) {
397
+ if (i === N)
398
+ return { score: 0, feetLens: [], primaryFlags: [], countFlags: [], strongOffsets: [] };
399
+ const cached = memo[i];
400
+ if (cached)
401
+ return cached;
402
+ let best = { score: -Infinity, feetLens: [], primaryFlags: [], countFlags: [], strongOffsets: [] };
403
+ const isStart = i === 0;
404
+ const caesuraBefore = syls[i].caesuraBefore;
405
+ for (const tmpl of templates) {
406
+ const L = tmpl.pattern.length;
407
+ if (i + L > N)
408
+ continue;
409
+ const isEnd = i + L === N;
410
+ if (tmpl.atStart && !isStart)
411
+ continue;
412
+ if (tmpl.atEnd && !isEnd)
413
+ continue;
414
+ let footScore = tmpl.score({ isStart, caesuraBefore, isEnd });
415
+ const strongOffs = [];
416
+ let straddlesCaesura = false;
417
+ for (let k = 0; k < L; k++) {
418
+ footScore += scoreSyllable(syls[i + k], tmpl.pattern[k]);
419
+ if (tmpl.pattern[k] === 'S')
420
+ strongOffs.push(k);
421
+ // A foot may begin at a caesura but must not contain one in its interior:
422
+ // foot boundaries align with major prosodic breaks (commas, IU edges).
423
+ if (k > 0 && syls[i + k].caesuraBefore)
424
+ straddlesCaesura = true;
425
+ }
426
+ // Foot boundaries prefer to align with caesurae, but feet are abstract
427
+ // units: metrists place caesurae mid-foot freely (masculine/feminine
428
+ // caesura), and phrase-edge alignment is already rewarded separately by
429
+ // the McAleese right-edge bonus. Keep only a small nudge — a 3-syllable
430
+ // foot is structurally MORE likely to contain a comma than a 2-syllable
431
+ // one, so a heavy penalty here systematically taxed ternary meters in
432
+ // comma-rich lines (Nabokov's "Exile" read duple wherever commas fell).
433
+ if (straddlesCaesura)
434
+ footScore -= 1.0;
435
+ // NB: we deliberately do NOT add a blanket penalty for splitting a
436
+ // polysyllabic word across a foot boundary. Such splits are routine in
437
+ // English verse ("Through E|den took") and are metrically harmless when
438
+ // each syllable lands in a position matching its stress. The genuinely
439
+ // costly case — a word's stressed syllable forced into a weak slot — is
440
+ // already penalised by scoreSyllable (Fabb's constraint).
441
+ const sub = solve(i + L);
442
+ if (sub.score === -Infinity)
443
+ continue;
444
+ const total = footScore + sub.score;
445
+ if (total > best.score) {
446
+ best = {
447
+ score: total,
448
+ feetLens: [L, ...sub.feetLens],
449
+ primaryFlags: [!!tmpl.isPrimary, ...sub.primaryFlags],
450
+ countFlags: [tmpl.countsAsFoot !== false, ...sub.countFlags],
451
+ strongOffsets: [strongOffs, ...sub.strongOffsets],
452
+ };
453
+ }
454
+ }
455
+ memo[i] = best;
456
+ return best;
457
+ }
458
+ const sol = solve(0);
459
+ const feet = sol.feetLens;
460
+ const footStrs = [];
461
+ const beats = new Set();
462
+ let pos = 0;
463
+ let cleanFeet = 0;
464
+ let maxScore = 0;
465
+ for (let f = 0; f < feet.length; f++) {
466
+ const L = feet[f];
467
+ const strongSet = new Set(sol.strongOffsets[f]);
468
+ maxScore += strongSet.size * 4 + (L - strongSet.size) * 2; // ideal: 4 per strong slot, 2 per weak
469
+ let str = '';
470
+ // A foot counts as "clean" only when it uses a primary (un-substituted)
471
+ // template AND is actually realised as the ideal: every strong slot bears
472
+ // a real beat (s/m) and every weak slot is genuinely weak (w/n). A primary
473
+ // template with a promoted (n) beat or a stressed weak slot is NOT clean.
474
+ let clean = sol.primaryFlags[f];
475
+ for (let k = 0; k < L; k++) {
476
+ const syl = syls[pos + k];
477
+ str += syl.stress;
478
+ if (strongSet.has(k)) {
479
+ if (!isStrong(syl.stress))
480
+ clean = false;
481
+ }
482
+ else {
483
+ if (isStrong(syl.stress))
484
+ clean = false;
485
+ }
486
+ }
487
+ footStrs.push(str);
488
+ for (const off of sol.strongOffsets[f])
489
+ beats.add(syls[pos + off].globalIndex);
490
+ if (clean)
491
+ cleanFeet++;
492
+ pos += L;
493
+ }
494
+ const countedFeet = sol.countFlags.filter(Boolean).length;
495
+ return { feet, footStrs, beats, score: sol.score, maxScore, cleanFeet, countedFeet };
496
+ }
497
+ // ─── McALEESE RIGHT-EDGE (KEY-STRESS) BONUS ─────────────────────────
498
+ /**
499
+ * Reward a segmentation that places metrical beats at the right edges of
500
+ * phonological phrases and intonational units ("endings strict"). Returns a
501
+ * ratio in [0,1]: matched unit-weight over total unit-weight. This is the
502
+ * signal that distinguishes rising (iambic/anapestic) from falling
503
+ * (trochaic/dactylic) polarity, since phrase-final stresses are beats only in
504
+ * rising meters.
505
+ */
506
+ function rightEdgeRatio(flat, ius, beats) {
507
+ if (!ius || ius.length === 0)
508
+ return 0;
509
+ let matched = 0;
510
+ let total = 0;
511
+ const considerUnit = (tokens, weight) => {
512
+ const syls = flat.filter(fs => tokens.includes(fs.word));
513
+ let edge;
514
+ for (let i = syls.length - 1; i >= 0; i--) {
515
+ if (isStrong(syls[i].stress)) {
516
+ edge = syls[i];
517
+ break;
518
+ }
519
+ }
520
+ if (!edge)
521
+ return;
522
+ total += weight;
523
+ if (beats.has(edge.globalIndex))
524
+ matched += weight;
525
+ };
526
+ for (const iu of ius) {
527
+ considerUnit(collectIUTokens(iu), WEIGHT.IU);
528
+ for (const pp of iu.phonologicalPhrases)
529
+ considerUnit(collectPPTokens(pp), WEIGHT.PP);
530
+ }
531
+ return total > 0 ? matched / total : 0;
532
+ }
533
+ // ─── SCANSION STRING (with silent-beat clash markers) ───────────────
534
+ function buildScansionString(syls, feet, ius) {
535
+ // Clitic-phrase membership: a clash within the same CP or word inserts a
536
+ // silent beat ('-') before the second strong syllable (McAleese p.222).
537
+ const cpOf = new Map();
538
+ if (ius) {
539
+ let cpId = 0;
540
+ for (const iu of ius)
541
+ for (const pp of iu.phonologicalPhrases)
542
+ for (const cg of pp.cliticGroups) {
543
+ for (const tok of cg.tokens)
544
+ cpOf.set(tok, cpId);
545
+ cpId++;
546
+ }
547
+ }
548
+ const out = [];
549
+ let pos = 0;
550
+ for (const L of feet) {
551
+ let foot = '';
552
+ for (let k = 0; k < L; k++) {
553
+ const cur = syls[pos];
554
+ if (pos > 0 && isStrong(cur.stress)) {
555
+ const prev = syls[pos - 1];
556
+ if (isStrong(prev.stress)) {
557
+ const sameCP = cpOf.get(prev.word) !== undefined && cpOf.get(prev.word) === cpOf.get(cur.word);
558
+ if (sameCP || prev.wordIdx === cur.wordIdx)
559
+ foot += '-';
560
+ }
561
+ }
562
+ foot += cur.stress;
563
+ pos++;
564
+ }
565
+ out.push(foot);
566
+ }
567
+ return out.join('|');
568
+ }
569
+ // ─── DISPLAY / NAMING HELPERS ──────────────────────────────────────
570
+ // Greek-numeral foot-length names, exhaustive through 20 (icosameter) per the
571
+ // maintainer's standardised nomenclature. Long lines that are genuinely metrical
572
+ // deserve a real meter name rather than the "N-feet" othering, so the ladder runs
573
+ // all the way up; beyond 20 the bare "N-feet" fallback remains (lines that long
574
+ // are almost never integrally metrical, and the prose-likeness hedge handles them).
575
+ const LINE_LENGTH_NAMES = [
576
+ '', 'monometer', 'dimeter', 'trimeter', 'tetrameter', 'pentameter',
577
+ 'hexameter', 'heptameter', 'octameter', 'nonometer', 'decameter',
578
+ 'hendecameter', 'dodecameter', 'triskaidecameter', 'tetradecameter',
579
+ 'pentadecameter', 'hexadecameter', 'heptadecameter', 'octadecameter',
580
+ 'enneadecameter', 'icosameter',
581
+ ];
582
+ function lineLengthName(feet) {
583
+ return LINE_LENGTH_NAMES[feet] || `${feet}-feet`;
584
+ }
585
+ // ─── TOP-LEVEL METER SCORING ────────────────────────────────────────
586
+ // A meter's small intrinsic prior. Iamb is the unmarked default of English
587
+ // verse; bacchic is a marginal whole-line meter. Kept tiny — only a tie-breaker.
588
+ const METER_PRIOR = { iambic: 0.02 };
589
+ // Deliberate, project-level bias toward ternary meters. English prosody defaults
590
+ // toward duple readings, but this toolkit aims to open English verse to the more
591
+ // musical ternary rhythms of (e.g.) Russian Silver-Age sources in translation, so
592
+ // when a triple reading is genuinely competitive it is nudged ahead. Kept small
593
+ // enough that it never overturns a clearly-duple line.
594
+ const TERNARY_BIAS = 0.02;
595
+ const TERNARY_METERS = new Set(['anapestic', 'dactylic', 'amphibrachic', 'bacchic']);
596
+ const DUPLE_METERS = new Set(['iambic', 'trochaic']);
597
+ // Weights against the (0..1) normalised fit fraction.
598
+ const REDGE_WEIGHT = 0.28; // right-edge (key-stress) agreement — disambiguates polarity
599
+ const CLEAN_WEIGHT = 0.12; // share of feet realised cleanly (real beats, no substitution)
600
+ const ONSET_WEIGHT = 0.05; // left-edge onset cue — coarse rising vs falling polarity
601
+ // Below this combined score, no meter is convincing → free verse.
602
+ const FREE_VERSE_THRESHOLD = 0.62;
603
+ const FALLING_METERS = new Set(['trochaic', 'dactylic']);
604
+ const RISING_METERS = new Set(['iambic', 'anapestic', 'amphibrachic', 'bacchic']);
605
+ /**
606
+ * Coarse onset polarity cue. If the line's first *strong* syllable is its very
607
+ * first syllable, the rhythm falls (trochaic/dactylic); if it is preceded by an
608
+ * upbeat, the rhythm rises (iambic/anapestic/amphibrachic). We deliberately use
609
+ * the relative-stress contour (not lexical prominence) and only the coarse
610
+ * rising/falling split — the finer "one vs two upbeats" distinction is unreliable
611
+ * across acephalous/anacrustic variants. Only rewards a match, never penalises.
612
+ */
613
+ function onsetBonus(flat, meter) {
614
+ let f0 = -1;
615
+ for (let i = 0; i < flat.length; i++) {
616
+ if (isStrong(flat[i].stress)) {
617
+ f0 = i;
618
+ break;
619
+ }
620
+ }
621
+ if (f0 < 0)
622
+ return 0;
623
+ if (f0 === 0)
624
+ return FALLING_METERS.has(meter) ? ONSET_WEIGHT : 0;
625
+ return RISING_METERS.has(meter) ? ONSET_WEIGHT : 0;
626
+ }
627
+ // ─── METRICALITY ASSESSMENT (Option-0 prose-likeness hedge) ─────────
628
+ //
629
+ // A single decontextualised line is, in generative-metrics terms, almost always
630
+ // fittable to SOME grid (English prose alternates; phrase-ends are right-strong),
631
+ // so absolute fit cannot separate prose from verse — empirically, loose real
632
+ // verse ("Half a league…", Prufrock) scores BELOW expository prose. What DOES
633
+ // separate them is non-periodicity accumulated AT LENGTH: a long run of text that
634
+ // (a) commits to no meter — its top candidates straddle BOTH the rising/falling
635
+ // and the duple/triple divides within a hair — and (b) realises that best fit only
636
+ // weakly. This is a deliberately HIGH-PRECISION gate: it fires only on the
637
+ // unmistakable un-lineated-prose case and never on short, loose, or ternary verse
638
+ // (the project's prized cases). It is advisory — it changes the displayed verdict
639
+ // WORDING only; the scansion, fit, ranking, foot count and certainty are intact.
640
+ // Necessary length: real metrical lines top out around the hepta-/octameter, so
641
+ // 9+ feet is almost never an integral line. Combined (AND) with the commitment
642
+ // and confidence gates below, clean verse that happens to exceed this length keeps
643
+ // a healthy margin and certainty and is therefore spared.
644
+ const PROSE_MIN_FEET = 9;
645
+ const PROSE_MAX_MARGIN = 0.10; // top1 − top2: prose does not commit to one meter
646
+ const PROSE_MAX_CERTAINTY = 66; // and realises even its best fit only weakly
647
+ /** Does the top-3 ranking straddle BOTH polarity (rising/falling) AND foot-size
648
+ * (duple/triple)? The fingerprint of a contour equidistant from every meter —
649
+ * present in prose, absent in committed verse (whose ties stay within a family). */
650
+ function rankingStraddles(ranking) {
651
+ const top = ranking.slice(0, 3).map(r => r.meter);
652
+ const rising = top.some(m => RISING_METERS.has(m));
653
+ const falling = top.some(m => FALLING_METERS.has(m));
654
+ const triple = top.some(m => TERNARY_METERS.has(m));
655
+ const duple = top.some(m => DUPLE_METERS.has(m));
656
+ return rising && falling && triple && duple;
657
+ }
658
+ /**
659
+ * If a line reads as plausible prose (see above), return the advisory hedge
660
+ * string; otherwise undefined. Reads only fields already present on the detail,
661
+ * so it runs as a late, non-destructive pipeline pass (`applyMetricalityLayer`).
662
+ */
663
+ export function metricalityVerdict(detail) {
664
+ if (detail.meterName === 'free verse')
665
+ return undefined; // already non-committal
666
+ if (detail.rhythmNote)
667
+ return undefined; // accentual/dolnik already named
668
+ if (detail.footCount < PROSE_MIN_FEET)
669
+ return undefined;
670
+ const ranking = detail.ranking;
671
+ if (!ranking || ranking.length < 2)
672
+ return undefined;
673
+ const margin = ranking[0].score - ranking[1].score;
674
+ if (margin >= PROSE_MAX_MARGIN)
675
+ return undefined; // commits to one meter
676
+ if (detail.certainty >= PROSE_MAX_CERTAINTY)
677
+ return undefined;
678
+ if (!rankingStraddles(ranking))
679
+ return undefined;
680
+ return `No consistent metered rhythm(s) discerned. Reads as plausible prose. `
681
+ + `(Closest fit: ${detail.meter}, ${detail.certainty}%)`;
682
+ }
683
+ /** Set `detail.metricalityNote` on every line that reads as plausible prose.
684
+ * Non-destructive: only the new advisory field is written. */
685
+ export function applyMetricalityLayer(details) {
686
+ for (const d of details)
687
+ d.metricalityNote = metricalityVerdict(d);
688
+ }
689
+ export function scoreMeters(keyStresses, words, ius, force) {
690
+ const flat = flattenSyllables(words, ius);
691
+ const N = flat.length;
692
+ if (N === 0) {
693
+ return {
694
+ all: '', keyStresses: '', meter: 'free verse', meterName: 'free verse',
695
+ footCount: 0, summary: 'no syllables', scansion: '',
696
+ certainty: 0, weightScore: 0, maxPossibleWeight: 0,
697
+ };
698
+ }
699
+ let best = null;
700
+ // Every candidate's composite fit score, so the top-N can be surfaced (display).
701
+ const candidates = [];
702
+ const fitsByMeter = new Map();
703
+ // `force` re-fits the line under ONE specific meter (used by the stanza/
704
+ // poem continuity rename: a near-tie line adopts the dominant meter, and
705
+ // its scansion/foot-count/certainty must come from that meter's own fit).
706
+ for (const meter of (force ? [force] : CANDIDATE_METERS)) {
707
+ const fit = fitMeter(flat, meter);
708
+ if (fit.feet.length === 0 || fit.maxScore <= 0)
709
+ continue;
710
+ const redge = rightEdgeRatio(flat, ius, fit.beats);
711
+ // Fraction of this meter's own ideal that the contour achieves. Normalising
712
+ // by each meter's maximum removes the structural advantage duple meters would
713
+ // otherwise enjoy (more strong slots ⇒ more points).
714
+ const fitFraction = fit.score / fit.maxScore;
715
+ const cleanRatio = fit.feet.length > 0 ? fit.cleanFeet / fit.feet.length : 0;
716
+ const finalScore = fitFraction
717
+ + REDGE_WEIGHT * redge
718
+ + CLEAN_WEIGHT * cleanRatio
719
+ + onsetBonus(flat, meter)
720
+ + (TERNARY_METERS.has(meter) ? TERNARY_BIAS : 0)
721
+ + (METER_PRIOR[meter] ?? 0);
722
+ candidates.push({ meter, score: finalScore });
723
+ fitsByMeter.set(meter, { fit, finalScore, redge });
724
+ if (!best || finalScore > best.finalScore + 1e-9) {
725
+ best = { meter, fit, finalScore, redge };
726
+ }
727
+ }
728
+ // ── Ternary-sibling arbitration ──
729
+ // When two ternary families (anapest/amphibrach/dactyl) fit the line with the
730
+ // IDENTICAL beat grid, the difference is purely one of conventional naming —
731
+ // the reading is the same. Metrists then name the foot so that (1) poly-
732
+ // syllabic words are not split across foot boundaries ("he HAPpens to | BE a"
733
+ // not "pens to BE"), and (2) foot boundaries align with phrase breaks
734
+ // ("at the FOE | and we CAMPED" not "the FOE and | we CAMPED"). Composite
735
+ // scores within 5% are treated as naming noise.
736
+ if (best && TERNARY_METERS.has(best.meter)) {
737
+ const wordSplits = (fit) => {
738
+ let splits = 0, pos = 0;
739
+ for (const L of fit.feet) {
740
+ pos += L;
741
+ if (pos < N && flat[pos].isPoly && !flat[pos].isWordStart)
742
+ splits++;
743
+ }
744
+ return splits;
745
+ };
746
+ const straddles = (fit) => {
747
+ let count = 0, pos = 0;
748
+ for (const L of fit.feet) {
749
+ for (let k = 1; k < L; k++)
750
+ if (flat[pos + k].caesuraBefore) {
751
+ count++;
752
+ break;
753
+ }
754
+ pos += L;
755
+ }
756
+ return count;
757
+ };
758
+ const sameBeats = (a, b) => a.size === b.size && [...a].every(v => b.has(v));
759
+ let chosen = { meter: best.meter, ...fitsByMeter.get(best.meter) };
760
+ for (const sib of TERNARY_METERS) {
761
+ if (sib === chosen.meter)
762
+ continue;
763
+ const cand = fitsByMeter.get(sib);
764
+ if (!cand || cand.finalScore < best.finalScore * 0.95)
765
+ continue;
766
+ if (!sameBeats(cand.fit.beats, best.fit.beats))
767
+ continue;
768
+ const better = wordSplits(cand.fit) < wordSplits(chosen.fit) ||
769
+ (wordSplits(cand.fit) === wordSplits(chosen.fit) &&
770
+ (straddles(cand.fit) < straddles(chosen.fit) ||
771
+ (straddles(cand.fit) === straddles(chosen.fit) && cand.finalScore > chosen.finalScore)));
772
+ if (better)
773
+ chosen = { meter: sib, ...cand };
774
+ }
775
+ if (chosen.meter !== best.meter)
776
+ best = { meter: chosen.meter, fit: chosen.fit, finalScore: chosen.finalScore, redge: chosen.redge };
777
+ }
778
+ // Ranked candidate meters (best first) — the same finalScores computed above,
779
+ // except that sibling arbitration (above) may have re-ordered same-grid
780
+ // ternary names: the chosen name leads.
781
+ const ranking = [...candidates].sort((a, b) => b.score - a.score);
782
+ if (best) {
783
+ const bi = ranking.findIndex(r => r.meter === best.meter);
784
+ if (bi > 0) {
785
+ const [b] = ranking.splice(bi, 1);
786
+ ranking.unshift(b);
787
+ }
788
+ }
789
+ const totalWeight = keyStresses.reduce((s, k) => s + k.weight, 0);
790
+ if (!best || (!force && best.finalScore < FREE_VERSE_THRESHOLD)) {
791
+ // Free verse: still emit the bare relative-stress contour for display.
792
+ return {
793
+ all: '', keyStresses: '', meter: 'free verse', meterName: 'free verse',
794
+ footCount: 0, summary: `IU=${ius?.length ?? 0} (below metrical threshold)`,
795
+ scansion: flat.map(f => f.stress).join(''),
796
+ certainty: 0, weightScore: 0, maxPossibleWeight: totalWeight,
797
+ ranking,
798
+ };
799
+ }
800
+ const { meter, fit, redge } = best;
801
+ const scansion = buildScansionString(flat, fit.feet, ius);
802
+ // Meter-length name counts only genuine feet (beat-less anacrusis upbeats and
803
+ // orphan-W edge units are excluded), so an upbeat pentameter is not "hexameter".
804
+ const footCount = fit.countedFeet;
805
+ // A "line" whose every segment is a beat-less edge unit (e.g. a single
806
+ // reduced syllable: "a") has no feet to name a meter from — free verse.
807
+ if (footCount <= 0 && !force) {
808
+ return {
809
+ all: '', keyStresses: '', meter: 'free verse', meterName: 'free verse',
810
+ footCount: 0, summary: `IU=${ius?.length ?? 0} (no beat-bearing feet)`,
811
+ scansion: flat.map(f => f.stress).join(''),
812
+ certainty: 0, weightScore: 0, maxPossibleWeight: totalWeight,
813
+ ranking,
814
+ };
815
+ }
816
+ // Certainty = proportion of segments realised by a clean (un-substituted) foot,
817
+ // tempered by the right-edge agreement. Denominator stays ALL segments
818
+ // (fit.feet.length) so this naming fix changes no certainty values.
819
+ const cleanRatio = fit.feet.length > 0 ? fit.cleanFeet / fit.feet.length : 0;
820
+ const certainty = Math.max(0, Math.min(100, Math.round(100 * (0.7 * cleanRatio + 0.3 * redge))));
821
+ const metreName = `${meter} ${lineLengthName(footCount)}`;
822
+ const summary = `IU=${ius?.length ?? 0} PP=${ius?.reduce((s, iu) => s + iu.phonologicalPhrases.length, 0) ?? 0} feet=${footCount} clean=${fit.cleanFeet}/${fit.feet.length}`;
823
+ return {
824
+ all: '', keyStresses: '', meter: metreName, meterName: meter,
825
+ footCount, summary, scansion, certainty,
826
+ weightScore: Math.round(redge * totalWeight), maxPossibleWeight: totalWeight,
827
+ ranking,
828
+ };
829
+ }
830
+ export function ictusProfile(scansion) {
831
+ const letters = scansion.replace(/[^xwnms]/g, '');
832
+ const positions = [];
833
+ for (let i = 0; i < letters.length; i++) {
834
+ const c = letters[i];
835
+ if (c === 's' || c === 'm') {
836
+ positions.push(i);
837
+ continue;
838
+ }
839
+ // Attridge promotion at the rhythm level: the strong beat is NOT solely
840
+ // the s tier. m always counts; an 'n' flanked on both sides by x/w (or a
841
+ // line edge) realises a beat; and a 'w' in the DEEPEST valley — flanked by
842
+ // zero-provision 'x' (or an edge) on both sides, e.g. "it IS an" — is
843
+ // promoted too (three offbeats in a row are what duple rhythm forbids).
844
+ // 'x' itself never carries a beat.
845
+ if (c === 'n') {
846
+ const lo = i === 0 || letters[i - 1] === 'x' || letters[i - 1] === 'w';
847
+ const hi = i === letters.length - 1 || letters[i + 1] === 'x' || letters[i + 1] === 'w';
848
+ if (lo && hi)
849
+ positions.push(i);
850
+ }
851
+ else if (c === 'w') {
852
+ const lo = i === 0 || letters[i - 1] === 'x';
853
+ const hi = i === letters.length - 1 || letters[i + 1] === 'x';
854
+ if (lo && hi)
855
+ positions.push(i);
856
+ }
857
+ }
858
+ const intervals = [];
859
+ for (let i = 1; i < positions.length; i++)
860
+ intervals.push(positions[i] - positions[i - 1] - 1);
861
+ return {
862
+ syllables: letters.length,
863
+ ictuses: positions.length,
864
+ intervals,
865
+ anacrusis: positions.length > 0 ? positions[0] : letters.length,
866
+ };
867
+ }
868
+ /** Classify pooled inter-ictus intervals into the dolnik/taktovik/accentual family. */
869
+ function intervalFamily(intervals) {
870
+ if (intervals.length === 0)
871
+ return null;
872
+ const within = (lo, hi) => intervals.filter(v => v >= lo && v <= hi).length / intervals.length;
873
+ if (within(1, 1) === 1)
874
+ return 'duple';
875
+ if (within(2, 2) === 1)
876
+ return 'ternary';
877
+ // ≥90% tolerance: an isolated clash (0) or long dip does not bump the family.
878
+ if (within(1, 2) >= 0.9)
879
+ return 'dolnik';
880
+ if (within(1, 3) >= 0.9)
881
+ return 'taktovik';
882
+ return 'accentual';
883
+ }
884
+ const ICTUS_NAMES = ['', '1-ictus', '2-ictus', '3-ictus', '4-ictus', '5-ictus', '6-ictus'];
885
+ const ictusName = (k) => ICTUS_NAMES[k] || `${k}-ictus`;
886
+ /**
887
+ * Stanza-level rhythm classification. Fires only when:
888
+ * (a) syllable counts VARY across the stanza (range ≥ 2) — a steady-count
889
+ * stanza is accentual-syllabic territory and is left to the classical
890
+ * machinery (this is what keeps loose iambics like Frost untouched); and
891
+ * (b) no classical meter dominates confidently (≥60% of lines under one
892
+ * meter at mean certainty ≥70).
893
+ * Then: alternating 4·3 ictuses → ballad; constant ictus count + interval
894
+ * family → dolnik / taktovik / accentual. Single lines (or 2-line stanzas)
895
+ * get only the per-line free-verse refinement below.
896
+ */
897
+ export function applyRhythmLayer(details) {
898
+ const lines = details.filter(d => d.scansion && d.scansion.length > 0);
899
+ for (const d of lines)
900
+ d.rhythmNote = undefined; // idempotent
901
+ const profiles = lines.map(d => ictusProfile(d.scansion));
902
+ if (lines.length >= 3) {
903
+ const syls = profiles.map(p => p.syllables);
904
+ const sylRange = Math.max(...syls) - Math.min(...syls);
905
+ const counts = profiles.map(p => p.ictuses);
906
+ if (sylRange >= 2) {
907
+ // Classical-dominance guard. Ternary SIBLINGS (anapest/amphibrach/
908
+ // dactyl) are grouped as ONE family here: their grids coincide modulo
909
+ // anacrusis, so a stanza reading amphi 7 / dact 3 / anap 2 (Nabokov's
910
+ // "Exile", whose tetrameter·tetrameter·trimeter design also varies the
911
+ // syllable count) is solidly classical — without the grouping it was
912
+ // stamped "free verse (heterometric)". A ≥70% family majority counts
913
+ // as classical regardless of certainty; a CONFIDENT half-majority
914
+ // (≥50% at mean certainty ≥70) does too — heterometric STANZA DESIGN
915
+ // (tetrameter·tetrameter·trimeter) is classical verse, not free verse.
916
+ // Genuine accentual verse scatters across families (Wyatt's best
917
+ // single family covers 0.43) and passes under both bars.
918
+ const byMeter = new Map();
919
+ lines.forEach((d) => {
920
+ if (d.meterName === 'free verse')
921
+ return;
922
+ const family = TERNARY_METERS.has(d.meterName) ? 'ternary' : d.meterName;
923
+ if (!byMeter.has(family))
924
+ byMeter.set(family, []);
925
+ byMeter.get(family).push(d.certainty);
926
+ });
927
+ let classical = false;
928
+ for (const [, certs] of byMeter) {
929
+ const coverage = certs.length / lines.length;
930
+ const meanCert = certs.reduce((a, b) => a + b, 0) / certs.length;
931
+ if (coverage >= 0.7 || (coverage >= 0.5 && meanCert >= 70)) {
932
+ classical = true;
933
+ break;
934
+ }
935
+ }
936
+ if (!classical) {
937
+ let note;
938
+ // Alternating ictus counts (canonically 4·3): reported as a RHYTHM
939
+ // fact only — whether it is a ballad stanza is a question of FORM
940
+ // (quatrains + rhyme scheme), answered by the form layer, not here.
941
+ const evens = counts.filter((_, i) => i % 2 === 0);
942
+ const odds = counts.filter((_, i) => i % 2 === 1);
943
+ const allEq = (a, v) => a.length > 0 && a.every(x => x === v);
944
+ if (counts.length >= 4 && allEq(evens, evens[0]) && allEq(odds, odds[0]) && evens[0] !== odds[0]) {
945
+ const pooled = profiles.flatMap(p => p.intervals);
946
+ const family = intervalFamily(pooled);
947
+ const flavour = family === 'dolnik' ? 'dolnik' : 'accentual';
948
+ // "4/3 ♪beat accentual" — no "alternating" (too long), "/" not "·" (so
949
+ // "4·3" is not misread as 12), ♪ marks that these are beat counts.
950
+ note = `${evens[0]}/${odds[0]} ♪beat ${flavour}`;
951
+ }
952
+ else {
953
+ // Constant ictus count (mode covering ≥70% of lines, total spread ≤1).
954
+ const mode = [...new Set(counts)].map(v => [v, counts.filter(c => c === v).length])
955
+ .sort((a, b) => b[1] - a[1])[0];
956
+ const spread = Math.max(...counts) - Math.min(...counts);
957
+ if (mode && mode[1] / counts.length >= 0.7 && spread <= 1) {
958
+ const pooled = profiles.flatMap(p => p.intervals);
959
+ const family = intervalFamily(pooled);
960
+ if (family === 'dolnik')
961
+ note = `${ictusName(mode[0])} dolnik`;
962
+ else if (family === 'taktovik')
963
+ note = `${ictusName(mode[0])} taktovik`;
964
+ else if (family === 'accentual')
965
+ note = `${mode[0]}-beat accentual`;
966
+ // duple/ternary pooled intervals with varying syllable counts =
967
+ // anacrusis/clausula variation only — classical machinery's domain.
968
+ }
969
+ // NB: a high-spread stanza with NO constant beat count is NOT stamped
970
+ // here. Forcing a "heterometric" rhythmNote onto every line both
971
+ // polluted the per-line display and (because the continuity pass is
972
+ // gated by rhythmNote) blocked the stanza-continuity rename. Lines
973
+ // instead keep their own meter and get a per-line dolnik/accentual
974
+ // reading below; the whole-poem heterometric observation is reported
975
+ // by the synopsis (summarizePoem), outside the per-line section.
976
+ }
977
+ if (note)
978
+ for (const d of lines)
979
+ d.rhythmNote = note;
980
+ }
981
+ }
982
+ }
983
+ // Per-line refinement: give a free-verse line its interval reading.
984
+ for (let i = 0; i < lines.length; i++) {
985
+ const d = lines[i];
986
+ if (d.rhythmNote || d.meterName !== 'free verse')
987
+ continue;
988
+ const p = profiles[i];
989
+ if (p.ictuses < 2)
990
+ continue;
991
+ const family = intervalFamily(p.intervals);
992
+ if (family === 'dolnik')
993
+ d.rhythmNote = `${ictusName(p.ictuses)} dolnik line`;
994
+ else if (family === 'taktovik')
995
+ d.rhythmNote = `${ictusName(p.ictuses)} taktovik line`;
996
+ else if (family === 'accentual')
997
+ d.rhythmNote = `${p.ictuses}-beat accentual line`;
998
+ }
999
+ }
1000
+ /**
1001
+ * Stanza-level consensus (McAleese A2.1 §5b, "where there is a tie, use
1002
+ * surrounding patterns"). Each line keeps its own standalone scansion/meter;
1003
+ * but when a line's top meter merely *edges out* the stanza's dominant meter (a
1004
+ * near-tie, within `tie` of its own best fit), we annotate it with the dominant
1005
+ * meter via `consensusMeter` — making the divergence EXPLICIT rather than
1006
+ * silently homogenising it. Confident lines (whose own meter clearly beats the
1007
+ * dominant) are left untouched, so genuine metrical variation stays visible.
1008
+ *
1009
+ * Mutates the passed details in place. No-op for <2 lines or a stanza with no
1010
+ * unique dominant meter.
1011
+ */
1012
+ export function applyStanzaConsensus(details, tie = 0.975) {
1013
+ if (details.length < 2)
1014
+ return;
1015
+ const counts = new Map();
1016
+ for (const d of details) {
1017
+ if (d.meterName === 'free verse')
1018
+ continue;
1019
+ counts.set(d.meterName, (counts.get(d.meterName) ?? 0) + 1);
1020
+ }
1021
+ // Dominant meter = the strict, unique plurality (≥2 lines).
1022
+ let dominant = '';
1023
+ let max = 0;
1024
+ let tied = false;
1025
+ for (const [m, c] of counts) {
1026
+ if (c > max) {
1027
+ max = c;
1028
+ dominant = m;
1029
+ tied = false;
1030
+ }
1031
+ else if (c === max)
1032
+ tied = true;
1033
+ }
1034
+ if (max < 2 || tied || !dominant)
1035
+ return;
1036
+ // Ternary ANACRUSIS ANCHOR (Gasparov): when the stanza's dominant meter is
1037
+ // ternary, the family is fixed by the stanza's anacrusis profile, not by the
1038
+ // per-line name race — a Russian ternary keeps a CONSTANT anacrusis (0 →
1039
+ // dactyl, 1 → amphibrach, 2 → anapest), while English anapestic verse mixes
1040
+ // full (2) and acephalous (1) openings. So: constant 1 → amphibrachic
1041
+ // (Nabokov's "Exile"); any 2s present alongside 1s → anapestic (Cowper);
1042
+ // constant 0 → dactylic. Overrides the plurality name for the ANNOTATION
1043
+ // target only; every line's standalone reading is preserved.
1044
+ if (TERNARY_METERS.has(dominant)) {
1045
+ const anacs = [];
1046
+ for (const d of details) {
1047
+ if (!TERNARY_METERS.has(d.meterName))
1048
+ continue;
1049
+ const p = ictusProfile(d.scansion);
1050
+ if (p.ictuses >= 2 && p.anacrusis <= 2)
1051
+ anacs.push(p.anacrusis);
1052
+ }
1053
+ if (anacs.length >= 2) {
1054
+ const has = (v) => anacs.includes(v);
1055
+ let family = null;
1056
+ if (has(2) && !has(0))
1057
+ family = 'anapestic';
1058
+ else if (has(0) && !has(2))
1059
+ family = anacs.every(a => a === 0) ? 'dactylic' : null;
1060
+ else if (anacs.every(a => a === 1))
1061
+ family = 'amphibrachic';
1062
+ if (family && family !== dominant)
1063
+ dominant = family;
1064
+ }
1065
+ }
1066
+ for (const d of details) {
1067
+ d.consensusMeter = undefined; // idempotent: clear any prior annotation
1068
+ if (d.meterName === 'free verse' || d.meterName === dominant)
1069
+ continue;
1070
+ const own = d.ranking?.[0]?.score ?? 0;
1071
+ const dom = d.ranking?.find(r => r.meter === dominant)?.score ?? 0;
1072
+ // Ternary SIBLINGS (anapest/amphibrach/dactyl) share their slack/beat
1073
+ // alternation, so a 5% composite gap between them is naming noise — e.g. a
1074
+ // spondaic anacrusis ("big BOOKS that are HURting…") lets the dactylic fit
1075
+ // edge out the stanza's amphibrachs by seizing the clash syllable as an
1076
+ // extra beat (Gasparov: an over-stressed anacrusis does NOT change the
1077
+ // meter). Non-sibling divergence keeps the stricter 0.975 near-tie.
1078
+ const siblings = TERNARY_METERS.has(d.meterName)
1079
+ && TERNARY_METERS.has(dominant);
1080
+ const threshold = siblings ? 0.95 : tie;
1081
+ if (own > 0 && dom >= own * threshold) {
1082
+ const lengthWord = d.meter.split(' ')[1] ?? '';
1083
+ d.consensusMeter = (dominant + (lengthWord ? ' ' + lengthWord : '')).trim();
1084
+ }
1085
+ }
1086
+ }