calliope-ts 0.0.1

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 (50) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.opencode/skills/calliope-ts/SKILL.md +74 -0
  3. package/LICENSE +201 -0
  4. package/README.md +485 -0
  5. package/dist/depfix.d.ts +13 -0
  6. package/dist/depfix.d.ts.map +1 -0
  7. package/dist/depfix.js +84 -0
  8. package/dist/display.d.ts +38 -0
  9. package/dist/display.d.ts.map +1 -0
  10. package/dist/display.js +890 -0
  11. package/dist/index.d.ts +50 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +504 -0
  14. package/dist/parser.d.ts +10 -0
  15. package/dist/parser.d.ts.map +1 -0
  16. package/dist/parser.js +688 -0
  17. package/dist/phonological.d.ts +41 -0
  18. package/dist/phonological.d.ts.map +1 -0
  19. package/dist/phonological.js +788 -0
  20. package/dist/rhyme.d.ts +26 -0
  21. package/dist/rhyme.d.ts.map +1 -0
  22. package/dist/rhyme.js +340 -0
  23. package/dist/scandroid.d.ts +17 -0
  24. package/dist/scandroid.d.ts.map +1 -0
  25. package/dist/scandroid.js +435 -0
  26. package/dist/scansion.d.ts +37 -0
  27. package/dist/scansion.d.ts.map +1 -0
  28. package/dist/scansion.js +1007 -0
  29. package/dist/scansion_debug.js +586 -0
  30. package/dist/stress.d.ts +32 -0
  31. package/dist/stress.d.ts.map +1 -0
  32. package/dist/stress.js +1372 -0
  33. package/dist/tagfix.d.ts +6 -0
  34. package/dist/tagfix.d.ts.map +1 -0
  35. package/dist/tagfix.js +101 -0
  36. package/dist/types.d.ts +173 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +4 -0
  39. package/package.json +62 -0
  40. package/src/depfix.ts +88 -0
  41. package/src/display.ts +954 -0
  42. package/src/index.ts +541 -0
  43. package/src/parser.ts +837 -0
  44. package/src/phonological.ts +849 -0
  45. package/src/rhyme.ts +328 -0
  46. package/src/scandroid.ts +434 -0
  47. package/src/scansion.ts +1053 -0
  48. package/src/stress.ts +1381 -0
  49. package/src/tagfix.ts +104 -0
  50. package/src/types.ts +230 -0
@@ -0,0 +1,1007 @@
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
+ function lineLengthName(feet) {
571
+ const names = ['', 'monometer', 'dimeter', 'trimeter', 'tetrameter', 'pentameter',
572
+ 'hexameter', 'heptameter', 'octameter', 'nonameter', 'decameter'];
573
+ return names[feet] || `${feet}-feet`;
574
+ }
575
+ // ─── TOP-LEVEL METER SCORING ────────────────────────────────────────
576
+ // A meter's small intrinsic prior. Iamb is the unmarked default of English
577
+ // verse; bacchic is a marginal whole-line meter. Kept tiny — only a tie-breaker.
578
+ const METER_PRIOR = { iambic: 0.02 };
579
+ // Deliberate, project-level bias toward ternary meters. English prosody defaults
580
+ // toward duple readings, but this toolkit aims to open English verse to the more
581
+ // musical ternary rhythms of (e.g.) Russian Silver-Age sources in translation, so
582
+ // when a triple reading is genuinely competitive it is nudged ahead. Kept small
583
+ // enough that it never overturns a clearly-duple line.
584
+ const TERNARY_BIAS = 0.02;
585
+ const TERNARY_METERS = new Set(['anapestic', 'dactylic', 'amphibrachic', 'bacchic']);
586
+ // Weights against the (0..1) normalised fit fraction.
587
+ const REDGE_WEIGHT = 0.28; // right-edge (key-stress) agreement — disambiguates polarity
588
+ const CLEAN_WEIGHT = 0.12; // share of feet realised cleanly (real beats, no substitution)
589
+ const ONSET_WEIGHT = 0.05; // left-edge onset cue — coarse rising vs falling polarity
590
+ // Below this combined score, no meter is convincing → free verse.
591
+ const FREE_VERSE_THRESHOLD = 0.62;
592
+ const FALLING_METERS = new Set(['trochaic', 'dactylic']);
593
+ const RISING_METERS = new Set(['iambic', 'anapestic', 'amphibrachic', 'bacchic']);
594
+ /**
595
+ * Coarse onset polarity cue. If the line's first *strong* syllable is its very
596
+ * first syllable, the rhythm falls (trochaic/dactylic); if it is preceded by an
597
+ * upbeat, the rhythm rises (iambic/anapestic/amphibrachic). We deliberately use
598
+ * the relative-stress contour (not lexical prominence) and only the coarse
599
+ * rising/falling split — the finer "one vs two upbeats" distinction is unreliable
600
+ * across acephalous/anacrustic variants. Only rewards a match, never penalises.
601
+ */
602
+ function onsetBonus(flat, meter) {
603
+ let f0 = -1;
604
+ for (let i = 0; i < flat.length; i++) {
605
+ if (isStrong(flat[i].stress)) {
606
+ f0 = i;
607
+ break;
608
+ }
609
+ }
610
+ if (f0 < 0)
611
+ return 0;
612
+ if (f0 === 0)
613
+ return FALLING_METERS.has(meter) ? ONSET_WEIGHT : 0;
614
+ return RISING_METERS.has(meter) ? ONSET_WEIGHT : 0;
615
+ }
616
+ export function scoreMeters(keyStresses, words, ius, force) {
617
+ const flat = flattenSyllables(words, ius);
618
+ const N = flat.length;
619
+ if (N === 0) {
620
+ return {
621
+ all: '', keyStresses: '', meter: 'free verse', meterName: 'free verse',
622
+ footCount: 0, summary: 'no syllables', scansion: '',
623
+ certainty: 0, weightScore: 0, maxPossibleWeight: 0,
624
+ };
625
+ }
626
+ let best = null;
627
+ // Every candidate's composite fit score, so the top-N can be surfaced (display).
628
+ const candidates = [];
629
+ const fitsByMeter = new Map();
630
+ // `force` re-fits the line under ONE specific meter (used by the stanza/
631
+ // poem continuity rename: a near-tie line adopts the dominant meter, and
632
+ // its scansion/foot-count/certainty must come from that meter's own fit).
633
+ for (const meter of (force ? [force] : CANDIDATE_METERS)) {
634
+ const fit = fitMeter(flat, meter);
635
+ if (fit.feet.length === 0 || fit.maxScore <= 0)
636
+ continue;
637
+ const redge = rightEdgeRatio(flat, ius, fit.beats);
638
+ // Fraction of this meter's own ideal that the contour achieves. Normalising
639
+ // by each meter's maximum removes the structural advantage duple meters would
640
+ // otherwise enjoy (more strong slots ⇒ more points).
641
+ const fitFraction = fit.score / fit.maxScore;
642
+ const cleanRatio = fit.feet.length > 0 ? fit.cleanFeet / fit.feet.length : 0;
643
+ const finalScore = fitFraction
644
+ + REDGE_WEIGHT * redge
645
+ + CLEAN_WEIGHT * cleanRatio
646
+ + onsetBonus(flat, meter)
647
+ + (TERNARY_METERS.has(meter) ? TERNARY_BIAS : 0)
648
+ + (METER_PRIOR[meter] ?? 0);
649
+ candidates.push({ meter, score: finalScore });
650
+ fitsByMeter.set(meter, { fit, finalScore, redge });
651
+ if (!best || finalScore > best.finalScore + 1e-9) {
652
+ best = { meter, fit, finalScore, redge };
653
+ }
654
+ }
655
+ // ── Ternary-sibling arbitration ──
656
+ // When two ternary families (anapest/amphibrach/dactyl) fit the line with the
657
+ // IDENTICAL beat grid, the difference is purely one of conventional naming —
658
+ // the reading is the same. Metrists then name the foot so that (1) poly-
659
+ // syllabic words are not split across foot boundaries ("he HAPpens to | BE a"
660
+ // not "pens to BE"), and (2) foot boundaries align with phrase breaks
661
+ // ("at the FOE | and we CAMPED" not "the FOE and | we CAMPED"). Composite
662
+ // scores within 5% are treated as naming noise.
663
+ if (best && TERNARY_METERS.has(best.meter)) {
664
+ const wordSplits = (fit) => {
665
+ let splits = 0, pos = 0;
666
+ for (const L of fit.feet) {
667
+ pos += L;
668
+ if (pos < N && flat[pos].isPoly && !flat[pos].isWordStart)
669
+ splits++;
670
+ }
671
+ return splits;
672
+ };
673
+ const straddles = (fit) => {
674
+ let count = 0, pos = 0;
675
+ for (const L of fit.feet) {
676
+ for (let k = 1; k < L; k++)
677
+ if (flat[pos + k].caesuraBefore) {
678
+ count++;
679
+ break;
680
+ }
681
+ pos += L;
682
+ }
683
+ return count;
684
+ };
685
+ const sameBeats = (a, b) => a.size === b.size && [...a].every(v => b.has(v));
686
+ let chosen = { meter: best.meter, ...fitsByMeter.get(best.meter) };
687
+ for (const sib of TERNARY_METERS) {
688
+ if (sib === chosen.meter)
689
+ continue;
690
+ const cand = fitsByMeter.get(sib);
691
+ if (!cand || cand.finalScore < best.finalScore * 0.95)
692
+ continue;
693
+ if (!sameBeats(cand.fit.beats, best.fit.beats))
694
+ continue;
695
+ const better = wordSplits(cand.fit) < wordSplits(chosen.fit) ||
696
+ (wordSplits(cand.fit) === wordSplits(chosen.fit) &&
697
+ (straddles(cand.fit) < straddles(chosen.fit) ||
698
+ (straddles(cand.fit) === straddles(chosen.fit) && cand.finalScore > chosen.finalScore)));
699
+ if (better)
700
+ chosen = { meter: sib, ...cand };
701
+ }
702
+ if (chosen.meter !== best.meter)
703
+ best = { meter: chosen.meter, fit: chosen.fit, finalScore: chosen.finalScore, redge: chosen.redge };
704
+ }
705
+ // Ranked candidate meters (best first) — the same finalScores computed above,
706
+ // except that sibling arbitration (above) may have re-ordered same-grid
707
+ // ternary names: the chosen name leads.
708
+ const ranking = [...candidates].sort((a, b) => b.score - a.score);
709
+ if (best) {
710
+ const bi = ranking.findIndex(r => r.meter === best.meter);
711
+ if (bi > 0) {
712
+ const [b] = ranking.splice(bi, 1);
713
+ ranking.unshift(b);
714
+ }
715
+ }
716
+ const totalWeight = keyStresses.reduce((s, k) => s + k.weight, 0);
717
+ if (!best || (!force && best.finalScore < FREE_VERSE_THRESHOLD)) {
718
+ // Free verse: still emit the bare relative-stress contour for display.
719
+ return {
720
+ all: '', keyStresses: '', meter: 'free verse', meterName: 'free verse',
721
+ footCount: 0, summary: `IU=${ius?.length ?? 0} (below metrical threshold)`,
722
+ scansion: flat.map(f => f.stress).join(''),
723
+ certainty: 0, weightScore: 0, maxPossibleWeight: totalWeight,
724
+ ranking,
725
+ };
726
+ }
727
+ const { meter, fit, redge } = best;
728
+ const scansion = buildScansionString(flat, fit.feet, ius);
729
+ // Meter-length name counts only genuine feet (beat-less anacrusis upbeats and
730
+ // orphan-W edge units are excluded), so an upbeat pentameter is not "hexameter".
731
+ const footCount = fit.countedFeet;
732
+ // A "line" whose every segment is a beat-less edge unit (e.g. a single
733
+ // reduced syllable: "a") has no feet to name a meter from — free verse.
734
+ if (footCount <= 0 && !force) {
735
+ return {
736
+ all: '', keyStresses: '', meter: 'free verse', meterName: 'free verse',
737
+ footCount: 0, summary: `IU=${ius?.length ?? 0} (no beat-bearing feet)`,
738
+ scansion: flat.map(f => f.stress).join(''),
739
+ certainty: 0, weightScore: 0, maxPossibleWeight: totalWeight,
740
+ ranking,
741
+ };
742
+ }
743
+ // Certainty = proportion of segments realised by a clean (un-substituted) foot,
744
+ // tempered by the right-edge agreement. Denominator stays ALL segments
745
+ // (fit.feet.length) so this naming fix changes no certainty values.
746
+ const cleanRatio = fit.feet.length > 0 ? fit.cleanFeet / fit.feet.length : 0;
747
+ const certainty = Math.max(0, Math.min(100, Math.round(100 * (0.7 * cleanRatio + 0.3 * redge))));
748
+ const metreName = `${meter} ${lineLengthName(footCount)}`;
749
+ 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}`;
750
+ return {
751
+ all: '', keyStresses: '', meter: metreName, meterName: meter,
752
+ footCount, summary, scansion, certainty,
753
+ weightScore: Math.round(redge * totalWeight), maxPossibleWeight: totalWeight,
754
+ ranking,
755
+ };
756
+ }
757
+ export function ictusProfile(scansion) {
758
+ const letters = scansion.replace(/[^xwnms]/g, '');
759
+ const positions = [];
760
+ for (let i = 0; i < letters.length; i++) {
761
+ const c = letters[i];
762
+ if (c === 's' || c === 'm') {
763
+ positions.push(i);
764
+ continue;
765
+ }
766
+ // Attridge promotion at the rhythm level: the strong beat is NOT solely
767
+ // the s tier. m always counts; an 'n' flanked on both sides by x/w (or a
768
+ // line edge) realises a beat; and a 'w' in the DEEPEST valley — flanked by
769
+ // zero-provision 'x' (or an edge) on both sides, e.g. "it IS an" — is
770
+ // promoted too (three offbeats in a row are what duple rhythm forbids).
771
+ // 'x' itself never carries a beat.
772
+ if (c === 'n') {
773
+ const lo = i === 0 || letters[i - 1] === 'x' || letters[i - 1] === 'w';
774
+ const hi = i === letters.length - 1 || letters[i + 1] === 'x' || letters[i + 1] === 'w';
775
+ if (lo && hi)
776
+ positions.push(i);
777
+ }
778
+ else if (c === 'w') {
779
+ const lo = i === 0 || letters[i - 1] === 'x';
780
+ const hi = i === letters.length - 1 || letters[i + 1] === 'x';
781
+ if (lo && hi)
782
+ positions.push(i);
783
+ }
784
+ }
785
+ const intervals = [];
786
+ for (let i = 1; i < positions.length; i++)
787
+ intervals.push(positions[i] - positions[i - 1] - 1);
788
+ return {
789
+ syllables: letters.length,
790
+ ictuses: positions.length,
791
+ intervals,
792
+ anacrusis: positions.length > 0 ? positions[0] : letters.length,
793
+ };
794
+ }
795
+ /** Classify pooled inter-ictus intervals into the dolnik/taktovik/accentual family. */
796
+ function intervalFamily(intervals) {
797
+ if (intervals.length === 0)
798
+ return null;
799
+ const within = (lo, hi) => intervals.filter(v => v >= lo && v <= hi).length / intervals.length;
800
+ if (within(1, 1) === 1)
801
+ return 'duple';
802
+ if (within(2, 2) === 1)
803
+ return 'ternary';
804
+ // ≥90% tolerance: an isolated clash (0) or long dip does not bump the family.
805
+ if (within(1, 2) >= 0.9)
806
+ return 'dolnik';
807
+ if (within(1, 3) >= 0.9)
808
+ return 'taktovik';
809
+ return 'accentual';
810
+ }
811
+ const ICTUS_NAMES = ['', '1-ictus', '2-ictus', '3-ictus', '4-ictus', '5-ictus', '6-ictus'];
812
+ const ictusName = (k) => ICTUS_NAMES[k] || `${k}-ictus`;
813
+ /**
814
+ * Stanza-level rhythm classification. Fires only when:
815
+ * (a) syllable counts VARY across the stanza (range ≥ 2) — a steady-count
816
+ * stanza is accentual-syllabic territory and is left to the classical
817
+ * machinery (this is what keeps loose iambics like Frost untouched); and
818
+ * (b) no classical meter dominates confidently (≥60% of lines under one
819
+ * meter at mean certainty ≥70).
820
+ * Then: alternating 4·3 ictuses → ballad; constant ictus count + interval
821
+ * family → dolnik / taktovik / accentual. Single lines (or 2-line stanzas)
822
+ * get only the per-line free-verse refinement below.
823
+ */
824
+ export function applyRhythmLayer(details) {
825
+ const lines = details.filter(d => d.scansion && d.scansion.length > 0);
826
+ for (const d of lines)
827
+ d.rhythmNote = undefined; // idempotent
828
+ const profiles = lines.map(d => ictusProfile(d.scansion));
829
+ if (lines.length >= 3) {
830
+ const syls = profiles.map(p => p.syllables);
831
+ const sylRange = Math.max(...syls) - Math.min(...syls);
832
+ const counts = profiles.map(p => p.ictuses);
833
+ if (sylRange >= 2) {
834
+ // Classical-dominance guard. Ternary SIBLINGS (anapest/amphibrach/
835
+ // dactyl) are grouped as ONE family here: their grids coincide modulo
836
+ // anacrusis, so a stanza reading amphi 7 / dact 3 / anap 2 (Nabokov's
837
+ // "Exile", whose tetrameter·tetrameter·trimeter design also varies the
838
+ // syllable count) is solidly classical — without the grouping it was
839
+ // stamped "free verse (heterometric)". A ≥70% family majority counts
840
+ // as classical regardless of certainty; a CONFIDENT half-majority
841
+ // (≥50% at mean certainty ≥70) does too — heterometric STANZA DESIGN
842
+ // (tetrameter·tetrameter·trimeter) is classical verse, not free verse.
843
+ // Genuine accentual verse scatters across families (Wyatt's best
844
+ // single family covers 0.43) and passes under both bars.
845
+ const byMeter = new Map();
846
+ lines.forEach((d) => {
847
+ if (d.meterName === 'free verse')
848
+ return;
849
+ const family = TERNARY_METERS.has(d.meterName) ? 'ternary' : d.meterName;
850
+ if (!byMeter.has(family))
851
+ byMeter.set(family, []);
852
+ byMeter.get(family).push(d.certainty);
853
+ });
854
+ let classical = false;
855
+ for (const [, certs] of byMeter) {
856
+ const coverage = certs.length / lines.length;
857
+ const meanCert = certs.reduce((a, b) => a + b, 0) / certs.length;
858
+ if (coverage >= 0.7 || (coverage >= 0.5 && meanCert >= 70)) {
859
+ classical = true;
860
+ break;
861
+ }
862
+ }
863
+ if (!classical) {
864
+ let note;
865
+ // Alternating ictus counts (canonically 4·3): reported as a RHYTHM
866
+ // fact only — whether it is a ballad stanza is a question of FORM
867
+ // (quatrains + rhyme scheme), answered by the form layer, not here.
868
+ const evens = counts.filter((_, i) => i % 2 === 0);
869
+ const odds = counts.filter((_, i) => i % 2 === 1);
870
+ const allEq = (a, v) => a.length > 0 && a.every(x => x === v);
871
+ if (counts.length >= 4 && allEq(evens, evens[0]) && allEq(odds, odds[0]) && evens[0] !== odds[0]) {
872
+ const pooled = profiles.flatMap(p => p.intervals);
873
+ const family = intervalFamily(pooled);
874
+ const flavour = family === 'dolnik' ? 'dolnik' : 'accentual';
875
+ note = `alternating ${evens[0]}·${odds[0]}-ictus ${flavour}`;
876
+ }
877
+ else {
878
+ // Constant ictus count (mode covering ≥70% of lines, total spread ≤1).
879
+ const mode = [...new Set(counts)].map(v => [v, counts.filter(c => c === v).length])
880
+ .sort((a, b) => b[1] - a[1])[0];
881
+ const spread = Math.max(...counts) - Math.min(...counts);
882
+ if (mode && mode[1] / counts.length >= 0.7 && spread <= 1) {
883
+ const pooled = profiles.flatMap(p => p.intervals);
884
+ const family = intervalFamily(pooled);
885
+ if (family === 'dolnik')
886
+ note = `${ictusName(mode[0])} dolnik`;
887
+ else if (family === 'taktovik')
888
+ note = `${ictusName(mode[0])} taktovik`;
889
+ else if (family === 'accentual')
890
+ note = `${mode[0]}-beat accentual`;
891
+ // duple/ternary pooled intervals with varying syllable counts =
892
+ // anacrusis/clausula variation only — classical machinery's domain.
893
+ }
894
+ else if (spread >= 3) {
895
+ note = 'free verse (heterometric)';
896
+ }
897
+ }
898
+ if (note)
899
+ for (const d of lines)
900
+ d.rhythmNote = note;
901
+ }
902
+ }
903
+ }
904
+ // Per-line refinement: give a free-verse line its interval reading.
905
+ for (let i = 0; i < lines.length; i++) {
906
+ const d = lines[i];
907
+ if (d.rhythmNote || d.meterName !== 'free verse')
908
+ continue;
909
+ const p = profiles[i];
910
+ if (p.ictuses < 2)
911
+ continue;
912
+ const family = intervalFamily(p.intervals);
913
+ if (family === 'dolnik')
914
+ d.rhythmNote = `${ictusName(p.ictuses)} dolnik line`;
915
+ else if (family === 'taktovik')
916
+ d.rhythmNote = `${ictusName(p.ictuses)} taktovik line`;
917
+ else if (family === 'accentual')
918
+ d.rhythmNote = `${p.ictuses}-beat accentual line`;
919
+ }
920
+ }
921
+ /**
922
+ * Stanza-level consensus (McAleese A2.1 §5b, "where there is a tie, use
923
+ * surrounding patterns"). Each line keeps its own standalone scansion/meter;
924
+ * but when a line's top meter merely *edges out* the stanza's dominant meter (a
925
+ * near-tie, within `tie` of its own best fit), we annotate it with the dominant
926
+ * meter via `consensusMeter` — making the divergence EXPLICIT rather than
927
+ * silently homogenising it. Confident lines (whose own meter clearly beats the
928
+ * dominant) are left untouched, so genuine metrical variation stays visible.
929
+ *
930
+ * Mutates the passed details in place. No-op for <2 lines or a stanza with no
931
+ * unique dominant meter.
932
+ */
933
+ export function applyStanzaConsensus(details, tie = 0.975) {
934
+ if (details.length < 2)
935
+ return;
936
+ const counts = new Map();
937
+ for (const d of details) {
938
+ if (d.meterName === 'free verse')
939
+ continue;
940
+ counts.set(d.meterName, (counts.get(d.meterName) ?? 0) + 1);
941
+ }
942
+ // Dominant meter = the strict, unique plurality (≥2 lines).
943
+ let dominant = '';
944
+ let max = 0;
945
+ let tied = false;
946
+ for (const [m, c] of counts) {
947
+ if (c > max) {
948
+ max = c;
949
+ dominant = m;
950
+ tied = false;
951
+ }
952
+ else if (c === max)
953
+ tied = true;
954
+ }
955
+ if (max < 2 || tied || !dominant)
956
+ return;
957
+ // Ternary ANACRUSIS ANCHOR (Gasparov): when the stanza's dominant meter is
958
+ // ternary, the family is fixed by the stanza's anacrusis profile, not by the
959
+ // per-line name race — a Russian ternary keeps a CONSTANT anacrusis (0 →
960
+ // dactyl, 1 → amphibrach, 2 → anapest), while English anapestic verse mixes
961
+ // full (2) and acephalous (1) openings. So: constant 1 → amphibrachic
962
+ // (Nabokov's "Exile"); any 2s present alongside 1s → anapestic (Cowper);
963
+ // constant 0 → dactylic. Overrides the plurality name for the ANNOTATION
964
+ // target only; every line's standalone reading is preserved.
965
+ if (TERNARY_METERS.has(dominant)) {
966
+ const anacs = [];
967
+ for (const d of details) {
968
+ if (!TERNARY_METERS.has(d.meterName))
969
+ continue;
970
+ const p = ictusProfile(d.scansion);
971
+ if (p.ictuses >= 2 && p.anacrusis <= 2)
972
+ anacs.push(p.anacrusis);
973
+ }
974
+ if (anacs.length >= 2) {
975
+ const has = (v) => anacs.includes(v);
976
+ let family = null;
977
+ if (has(2) && !has(0))
978
+ family = 'anapestic';
979
+ else if (has(0) && !has(2))
980
+ family = anacs.every(a => a === 0) ? 'dactylic' : null;
981
+ else if (anacs.every(a => a === 1))
982
+ family = 'amphibrachic';
983
+ if (family && family !== dominant)
984
+ dominant = family;
985
+ }
986
+ }
987
+ for (const d of details) {
988
+ d.consensusMeter = undefined; // idempotent: clear any prior annotation
989
+ if (d.meterName === 'free verse' || d.meterName === dominant)
990
+ continue;
991
+ const own = d.ranking?.[0]?.score ?? 0;
992
+ const dom = d.ranking?.find(r => r.meter === dominant)?.score ?? 0;
993
+ // Ternary SIBLINGS (anapest/amphibrach/dactyl) share their slack/beat
994
+ // alternation, so a 5% composite gap between them is naming noise — e.g. a
995
+ // spondaic anacrusis ("big BOOKS that are HURting…") lets the dactylic fit
996
+ // edge out the stanza's amphibrachs by seizing the clash syllable as an
997
+ // extra beat (Gasparov: an over-stressed anacrusis does NOT change the
998
+ // meter). Non-sibling divergence keeps the stricter 0.975 near-tie.
999
+ const siblings = TERNARY_METERS.has(d.meterName)
1000
+ && TERNARY_METERS.has(dominant);
1001
+ const threshold = siblings ? 0.95 : tie;
1002
+ if (own > 0 && dom >= own * threshold) {
1003
+ const lengthWord = d.meter.split(' ')[1] ?? '';
1004
+ d.consensusMeter = (dominant + (lengthWord ? ' ' + lengthWord : '')).trim();
1005
+ }
1006
+ }
1007
+ }