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