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.
- package/.claude/settings.local.json +8 -0
- package/.opencode/skills/calliope-ts/SKILL.md +74 -0
- package/LICENSE +201 -0
- package/README.md +485 -0
- package/dist/depfix.d.ts +13 -0
- package/dist/depfix.d.ts.map +1 -0
- package/dist/depfix.js +84 -0
- package/dist/display.d.ts +38 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +890 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +504 -0
- package/dist/parser.d.ts +10 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +688 -0
- package/dist/phonological.d.ts +41 -0
- package/dist/phonological.d.ts.map +1 -0
- package/dist/phonological.js +788 -0
- package/dist/rhyme.d.ts +26 -0
- package/dist/rhyme.d.ts.map +1 -0
- package/dist/rhyme.js +340 -0
- package/dist/scandroid.d.ts +17 -0
- package/dist/scandroid.d.ts.map +1 -0
- package/dist/scandroid.js +435 -0
- package/dist/scansion.d.ts +37 -0
- package/dist/scansion.d.ts.map +1 -0
- package/dist/scansion.js +1007 -0
- package/dist/scansion_debug.js +586 -0
- package/dist/stress.d.ts +32 -0
- package/dist/stress.d.ts.map +1 -0
- package/dist/stress.js +1372 -0
- package/dist/tagfix.d.ts +6 -0
- package/dist/tagfix.d.ts.map +1 -0
- package/dist/tagfix.js +101 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +62 -0
- package/src/depfix.ts +88 -0
- package/src/display.ts +954 -0
- package/src/index.ts +541 -0
- package/src/parser.ts +837 -0
- package/src/phonological.ts +849 -0
- package/src/rhyme.ts +328 -0
- package/src/scandroid.ts +434 -0
- package/src/scansion.ts +1053 -0
- package/src/stress.ts +1381 -0
- package/src/tagfix.ts +104 -0
- package/src/types.ts +230 -0
package/src/scansion.ts
ADDED
|
@@ -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
|
+
}
|