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,586 @@
1
+ // scansion.ts — Key‑stress extraction, weighted metre scoring, and
2
+ // scansion assembly for McAleese’s phonological method.
3
+ import { isPunctuation } from './parser.js';
4
+ // ─── CONSTANTS: metre definitions & weights ──────────────────────
5
+ const METRES = {
6
+ iambic: { foot: 'ws', sylCount: 2 },
7
+ trochaic: { foot: 'sw', sylCount: 2 },
8
+ spondaic: { foot: 'ss', sylCount: 2 },
9
+ pyrrhic: { foot: 'ww', sylCount: 2 },
10
+ anapestic: { foot: 'wws', sylCount: 3 },
11
+ dactylic: { foot: 'sww', sylCount: 3 },
12
+ amphibrachic: { foot: 'wsw', sylCount: 3 },
13
+ bacchic: { foot: 'wss', sylCount: 3 },
14
+ };
15
+ const WEIGHT = {
16
+ IU: 2,
17
+ PP: 1,
18
+ PW3plus: 2,
19
+ PW2: 1,
20
+ CP: 1,
21
+ };
22
+ // ─── HELPER: stress compatibility ─────────────────────────────────
23
+ function stressCompatible(actual, expected) {
24
+ if (expected === 's')
25
+ return actual === 's' || actual === 'm';
26
+ // McAleese: 'w' matches anything except 's'.
27
+ return actual !== 's';
28
+ }
29
+ // ─── PATTERN MATCHING ─────────────────────────────────────────────
30
+ function patternMatches(pattern, foot, isForward = false) {
31
+ const plen = pattern.length;
32
+ const flen = foot.length;
33
+ // Single CP patterns: must match the STRONG position of the foot.
34
+ if (plen === 1) {
35
+ const single = pattern[0];
36
+ const strongPos = foot.indexOf('s');
37
+ if (strongPos !== -1) {
38
+ return stressCompatible(single, 's');
39
+ }
40
+ return foot.split('').some(expected => stressCompatible(single, expected));
41
+ }
42
+ // For shorter patterns, match against a contiguous substring of the foot
43
+ let tail;
44
+ if (plen < flen) {
45
+ // Only allow partial matching if pattern contains a strong syllable;
46
+ // otherwise a weak-only pattern gives no diagnostic rhythmic information.
47
+ if (!pattern.split('').some(c => c === 's' || c === 'm')) {
48
+ return false;
49
+ }
50
+ tail = isForward ? foot.slice(0, plen) : foot.slice(-plen);
51
+ for (let i = 0; i < plen; i++) {
52
+ const actual = pattern[i];
53
+ const expected = tail[i];
54
+ if (!stressCompatible(actual, expected))
55
+ return false;
56
+ }
57
+ return true;
58
+ }
59
+ if (plen > flen) {
60
+ tail = isForward ? pattern.slice(0, flen) : pattern.slice(-flen);
61
+ }
62
+ else {
63
+ tail = pattern;
64
+ }
65
+ for (let i = 0; i < flen; i++) {
66
+ const actual = tail[i];
67
+ const expected = foot[i];
68
+ if (!stressCompatible(actual, expected))
69
+ return false;
70
+ }
71
+ // Reject strong dipping for triple metres (dactylic, anapestic)
72
+ if (flen === 3 && plen >= flen) {
73
+ const first = tail[0];
74
+ const middle = tail[1];
75
+ const final = tail[2];
76
+ if (foot === 'sww') {
77
+ if ((first === 's' || first === 'm') && (middle !== 's' && middle !== 'm') && (final === 'm' || final === 's')) {
78
+ return false;
79
+ }
80
+ }
81
+ if (foot === 'wws') {
82
+ if ((first === 'm' || first === 's') && (middle !== 's' && middle !== 'm') && (final === 's' || final === 'm')) {
83
+ return false;
84
+ }
85
+ }
86
+ }
87
+ return true;
88
+ }
89
+ function flattenSyllables(words, ius) {
90
+ const ppStartWords = new Set();
91
+ if (ius) {
92
+ for (const iu of ius) {
93
+ for (const pp of iu.phonologicalPhrases) {
94
+ if (pp.cliticGroups.length > 0 && pp.cliticGroups[0].tokens.length > 0) {
95
+ ppStartWords.add(pp.cliticGroups[0].tokens[0]);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ const result = [];
101
+ let idx = 0;
102
+ let wordCounter = 0;
103
+ for (const w of words) {
104
+ if (isPunctuation(w.lexicalClass))
105
+ continue;
106
+ const isPoly = w.syllables.length > 1;
107
+ const isLeftEdgeWord = ppStartWords.has(w);
108
+ for (let si = 0; si < w.syllables.length; si++) {
109
+ const s = w.syllables[si];
110
+ result.push({
111
+ word: w,
112
+ stress: s.relativeStress ?? 'w',
113
+ globalIndex: idx++,
114
+ wordIdx: wordCounter,
115
+ isPoly,
116
+ weight: s.weight || 'L',
117
+ isLeftEdgePP: isLeftEdgeWord && (si === 0),
118
+ extrametrical: s.extrametrical,
119
+ });
120
+ }
121
+ wordCounter++;
122
+ }
123
+ return result;
124
+ }
125
+ // ─── RIGHT‑MOST STRESSED IN TOKEN GROUP ────────────────────────────
126
+ function rightmostStressed(tokens, flat, strongOnly = false) {
127
+ for (let i = flat.length - 1; i >= 0; i--) {
128
+ const fs = flat[i];
129
+ if (tokens.includes(fs.word)) {
130
+ if (strongOnly) {
131
+ if (fs.stress === 's' || fs.stress === 'm')
132
+ return fs;
133
+ }
134
+ else {
135
+ if (fs.stress !== 'w')
136
+ return fs;
137
+ }
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+ function extractContour(syls) {
143
+ let headWordIdx = -1;
144
+ // Find the last content word
145
+ for (let i = syls.length - 1; i >= 0; i--) {
146
+ if (syls[i].word.isContent) {
147
+ headWordIdx = i;
148
+ break;
149
+ }
150
+ }
151
+ if (headWordIdx === -1)
152
+ return syls; // No content word
153
+ // Find the start of this word
154
+ const targetWord = syls[headWordIdx].word;
155
+ let startIdx = headWordIdx;
156
+ while (startIdx > 0 && syls[startIdx - 1].word === targetWord) {
157
+ startIdx--;
158
+ }
159
+ // Return this word and all following syllables
160
+ return syls.slice(startIdx);
161
+ }
162
+ // ─── EXTRACT KEY STRESSES WITH POSITIONS ──────────────────────────
163
+ export function extractKeyStresses(ius, words) {
164
+ const result = [];
165
+ const flat = flattenSyllables(words);
166
+ // ── Polysyllabic words (PW) ──────────────────────────────
167
+ for (const w of words) {
168
+ if (isPunctuation(w.lexicalClass))
169
+ continue;
170
+ const sc = w.syllables.length;
171
+ if (sc < 2)
172
+ continue;
173
+ const pattern = w.syllables.map(s => s.relativeStress ?? 'w').join('');
174
+ const weight = sc >= 3 ? WEIGHT.PW3plus : WEIGHT.PW2;
175
+ const firstSylIdx = flat.findIndex(fs => fs.word === w);
176
+ const positions = [];
177
+ for (let j = 0; j < sc; j++) {
178
+ positions.push(firstSylIdx + j);
179
+ }
180
+ result.push({ unitType: 'PW', pattern, weight, positions });
181
+ }
182
+ // ── Clitic Groups, Phonological Phrases, Intonational Units ─
183
+ for (const iu of ius) {
184
+ const iuTokens = collectIUTokens(iu);
185
+ if (iuTokens.length === 0)
186
+ continue;
187
+ const unitSyllables = flat.filter(fs => iuTokens.includes(fs.word));
188
+ if (unitSyllables.length > 0) {
189
+ const tail = extractContour(unitSyllables);
190
+ const positions = tail.map(fs => fs.globalIndex);
191
+ const pattern = tail.map(fs => fs.stress).join('');
192
+ result.push({ unitType: 'IU', pattern, weight: WEIGHT.IU, positions });
193
+ }
194
+ for (const pp of iu.phonologicalPhrases) {
195
+ const ppTokens = collectPPTokens(pp);
196
+ if (ppTokens.length === 0)
197
+ continue;
198
+ const ppSyllables = flat.filter(fs => ppTokens.includes(fs.word));
199
+ if (ppSyllables.length > 0) {
200
+ const tail = extractContour(ppSyllables);
201
+ const positions = tail.map(fs => fs.globalIndex);
202
+ const pattern = tail.map(fs => fs.stress).join('');
203
+ result.push({ unitType: 'PP', pattern, weight: WEIGHT.PP, positions });
204
+ }
205
+ for (const cg of pp.cliticGroups) {
206
+ if (cg.tokens.length === 0)
207
+ continue;
208
+ const cpStressed = rightmostStressed(cg.tokens, flat, false);
209
+ if (cpStressed) {
210
+ result.push({
211
+ unitType: 'CP',
212
+ pattern: cpStressed.stress,
213
+ weight: WEIGHT.CP,
214
+ positions: [cpStressed.globalIndex],
215
+ });
216
+ }
217
+ }
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+ function collectIUTokens(iu) {
223
+ const tokens = [];
224
+ for (const pp of iu.phonologicalPhrases) {
225
+ tokens.push(...collectPPTokens(pp));
226
+ }
227
+ return tokens;
228
+ }
229
+ function collectPPTokens(pp) {
230
+ const tokens = [];
231
+ for (const cg of pp.cliticGroups) {
232
+ tokens.push(...cg.tokens);
233
+ }
234
+ return tokens;
235
+ }
236
+ // ─── POLYSYLLABIC RHYTHM BIAS ─────────────────────────────────────
237
+ function polysyllabicRhythmBias(words) {
238
+ const bias = { iambic: 0, trochaic: 0, anapestic: 0, dactylic: 0 };
239
+ for (const w of words) {
240
+ if (isPunctuation(w.lexicalClass))
241
+ continue;
242
+ if (w.syllables.length < 2 || !w.isContent)
243
+ continue;
244
+ const pat = w.syllables.map(s => s.relativeStress ?? 'w');
245
+ // end‑stressed → iambic / anapestic bias
246
+ if (isStrong(pat[pat.length - 1])) {
247
+ bias.iambic += 1;
248
+ bias.anapestic += 1;
249
+ }
250
+ // begin‑stressed → trochaic / dactylic bias
251
+ if (isStrong(pat[0])) {
252
+ bias.trochaic += 1;
253
+ bias.dactylic += 1;
254
+ }
255
+ }
256
+ return bias;
257
+ }
258
+ // ─── SYLLABIC PATTERN PRE-CHECK FOR TRIPLE METERS ──────────────────
259
+ function isStrong(stress) {
260
+ return stress === 's' || stress === 'm';
261
+ }
262
+ function isWeak(stress) {
263
+ return stress === 'w' || stress === 'n';
264
+ }
265
+ // ─── METRE SCORING ────────────────────────────────────────────────
266
+ function lineLengthName(feet) {
267
+ const names = [
268
+ '', 'monometer', 'dimeter', 'trimeter', 'tetrameter',
269
+ 'pentameter', 'hexameter', 'heptameter', 'octameter'
270
+ ];
271
+ return names[feet] || `${feet}-feet`;
272
+ }
273
+ function generateSummary(matchCounts) {
274
+ return Object.entries(matchCounts)
275
+ .map(([unit, count]) => `${unit}=${count}`)
276
+ .join(' ') || 'no matches';
277
+ }
278
+ function isStrongStress(stress) {
279
+ return stress === 's' || stress === 'm';
280
+ }
281
+ function countSyllables(str) {
282
+ return str.replace(/[^wmsngWsS]/g, '').length;
283
+ }
284
+ function getTemplatesForMeter(meter) {
285
+ let tmpls = [];
286
+ if (meter === 'iambic') {
287
+ tmpls = [
288
+ { pattern: ['W', 'S'], baseScore: 0 },
289
+ { pattern: ['W', 'W', 'S'], baseScore: -1 },
290
+ { pattern: ['S', 'W'], baseScore: -3, allowedAtStart: true },
291
+ { pattern: ['S'], baseScore: 0, allowedAtEnd: true },
292
+ { pattern: ['W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
293
+ { pattern: ['W', 'W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
294
+ ];
295
+ }
296
+ else if (meter === 'trochaic') {
297
+ tmpls = [
298
+ { pattern: ['S', 'W'], baseScore: 0 },
299
+ { pattern: ['S', 'W', 'W'], baseScore: -1 },
300
+ { pattern: ['S'], baseScore: 0, allowedAtEnd: true },
301
+ { pattern: ['W', 'S'], baseScore: -3 },
302
+ { pattern: ['S', 'W', 'S'], baseScore: -1, allowedAtEnd: true },
303
+ ];
304
+ }
305
+ else if (meter === 'anapestic') {
306
+ tmpls = [
307
+ { pattern: ['W', 'W', 'S'], baseScore: 0 },
308
+ { pattern: ['W', 'S'], baseScore: -1 },
309
+ { pattern: ['S'], baseScore: 0, allowedAtEnd: true },
310
+ { pattern: ['W', 'W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
311
+ { pattern: ['W', 'S', 'W'], baseScore: -1, allowedAtEnd: true },
312
+ ];
313
+ }
314
+ else if (meter === 'dactylic') {
315
+ tmpls = [
316
+ { pattern: ['S', 'W', 'W'], baseScore: 0 },
317
+ { pattern: ['S', 'W'], baseScore: 0 },
318
+ { pattern: ['S'], baseScore: 0, allowedAtEnd: true },
319
+ { pattern: ['S', 'W', 'S'], baseScore: -2, allowedAtEnd: true },
320
+ ];
321
+ }
322
+ else if (meter === 'amphibrachic') {
323
+ tmpls = [
324
+ { pattern: ['W', 'S', 'W'], baseScore: 0 },
325
+ { pattern: ['W', 'S'], baseScore: -1 },
326
+ { pattern: ['S', 'W'], baseScore: -2 },
327
+ { pattern: ['S'], baseScore: 0, allowedAtEnd: true },
328
+ { pattern: ['W', 'S', 'W', 'S'], baseScore: -1 },
329
+ ];
330
+ }
331
+ else if (meter !== 'free verse') {
332
+ const footStr = METRES[meter]?.foot || 'ws';
333
+ const basePattern = footStr.split('').map(c => c === 's' ? 'S' : 'W');
334
+ tmpls = [
335
+ { pattern: basePattern, baseScore: 0 },
336
+ { pattern: ['S'], baseScore: -1, allowedAtEnd: true },
337
+ ];
338
+ }
339
+ tmpls.push({ pattern: ['W'], baseScore: -10 });
340
+ tmpls.push({ pattern: ['S'], baseScore: -10 });
341
+ return tmpls;
342
+ }
343
+ function scoreSyllable(syl, expected) {
344
+ const actual = syl.stress;
345
+ let score = 0;
346
+ if (expected === 'S') {
347
+ if (actual === 's')
348
+ score = 4;
349
+ else if (actual === 'm')
350
+ score = 3;
351
+ else if (actual === 'n')
352
+ score = 1;
353
+ else
354
+ score = -2;
355
+ }
356
+ else {
357
+ if (actual === 'w')
358
+ score = 2;
359
+ else if (actual === 'n')
360
+ score = 1;
361
+ else if (actual === 'm')
362
+ score = -1;
363
+ else
364
+ score = -3;
365
+ // Fabb's constraint: heavy syllable in polysyllabic word must not
366
+ // occupy a weak metrical position, unless at the left edge of a PP.
367
+ if (syl.isPoly && syl.weight === 'H' && !syl.isLeftEdgePP) {
368
+ // Extrametrical syllables are already weight-invisible; reduce penalty.
369
+ if (syl.extrametrical) {
370
+ score -= 2;
371
+ }
372
+ else {
373
+ score -= 5;
374
+ }
375
+ }
376
+ }
377
+ return score;
378
+ }
379
+ function assembleScansion(words, meter, ius) {
380
+ const syls = flattenSyllables(words, ius);
381
+ if (meter === 'free verse') {
382
+ let out = '';
383
+ for (let i = 0; i < syls.length; i++) {
384
+ out += syls[i].stress;
385
+ }
386
+ return { scansion: out, footCount: 0, rawScore: 0 };
387
+ }
388
+ const cpMembership = new Map();
389
+ if (ius) {
390
+ let cpId = 0;
391
+ const wordToCp = new Map();
392
+ for (const iu of ius) {
393
+ for (const pp of iu.phonologicalPhrases) {
394
+ for (const cg of pp.cliticGroups) {
395
+ for (const word of cg.tokens) {
396
+ wordToCp.set(word, cpId);
397
+ }
398
+ cpId++;
399
+ }
400
+ }
401
+ }
402
+ for (let i = 0; i < syls.length; i++) {
403
+ const cp = wordToCp.get(syls[i].word);
404
+ if (cp !== undefined) {
405
+ cpMembership.set(i, cp);
406
+ }
407
+ }
408
+ }
409
+ const templates = getTemplatesForMeter(meter);
410
+ const N = syls.length;
411
+ const memo = {};
412
+ function solve(index) {
413
+ if (index === N) {
414
+ return { score: 0, feet: [] };
415
+ }
416
+ if (memo[index])
417
+ return memo[index];
418
+ let bestScore = -Infinity;
419
+ let bestFeet = [];
420
+ const isStart = index === 0;
421
+ for (const t of templates) {
422
+ const L = t.pattern.length;
423
+ if (index + L > N)
424
+ continue;
425
+ const isEnd = (index + L === N);
426
+ if (t.allowedAtStart && !isStart)
427
+ continue;
428
+ if (t.allowedAtEnd && !isEnd)
429
+ continue;
430
+ let matchScore = t.baseScore;
431
+ let footStr = '';
432
+ for (let i = 0; i < L; i++) {
433
+ matchScore += scoreSyllable(syls[index + i], t.pattern[i]);
434
+ footStr += syls[index + i].stress;
435
+ }
436
+ // Word-boundary constraint: penalize splitting polysyllabic words across feet
437
+ // (Kiparsky 1975: stress patterns within a single word are regulated with special strictness)
438
+ if (index + L < N) {
439
+ const lastSylInFoot = syls[index + L - 1];
440
+ const firstSylAfterFoot = syls[index + L];
441
+ if (lastSylInFoot.wordIdx === firstSylAfterFoot.wordIdx && lastSylInFoot.isPoly) {
442
+ matchScore -= 8; // heavy penalty for splitting a polysyllabic word
443
+ }
444
+ }
445
+ const sub = solve(index + L);
446
+ if (sub.score !== -Infinity) {
447
+ const totalScore = matchScore + sub.score;
448
+ if (totalScore > bestScore) {
449
+ bestScore = totalScore;
450
+ bestFeet = [footStr, ...sub.feet];
451
+ }
452
+ }
453
+ }
454
+ memo[index] = { score: bestScore, feet: bestFeet };
455
+ return memo[index];
456
+ }
457
+ const result = solve(0);
458
+ let currentSylIdx = 0;
459
+ const finalFeet = [];
460
+ for (const footStr of result.feet) {
461
+ let newFootStr = '';
462
+ for (let i = 0; i < footStr.length; i++) {
463
+ const syl = syls[currentSylIdx];
464
+ if (currentSylIdx > 0 && isStrongStress(syl.stress)) {
465
+ const prevSyl = syls[currentSylIdx - 1];
466
+ if (isStrongStress(prevSyl.stress)) {
467
+ const sameCP = cpMembership.get(currentSylIdx - 1) === cpMembership.get(currentSylIdx);
468
+ const sameWord = prevSyl.wordIdx === syl.wordIdx;
469
+ if (sameCP || sameWord) {
470
+ newFootStr += '-';
471
+ }
472
+ }
473
+ }
474
+ newFootStr += syl.stress;
475
+ currentSylIdx++;
476
+ }
477
+ finalFeet.push(newFootStr);
478
+ }
479
+ return { scansion: finalFeet.join('|'), footCount: finalFeet.length, rawScore: result.score };
480
+ }
481
+ export function scoreMeters(keyStresses, words, ius) {
482
+ const totalSyls = words.reduce((sum, w) => sum + (isPunctuation(w.lexicalClass) ? 0 : w.syllables.length), 0);
483
+ if (totalSyls === 0) {
484
+ return {
485
+ all: '', keyStresses: '', meter: 'free verse',
486
+ meterName: 'free verse', footCount: 0,
487
+ summary: 'no syllables', scansion: '',
488
+ certainty: 0, weightScore: 0, maxPossibleWeight: 0,
489
+ };
490
+ }
491
+ const candidateMeters = ['iambic', 'trochaic', 'anapestic', 'dactylic', 'amphibrachic'];
492
+ // McAleese's weighted scoring using key stresses
493
+ // For each candidate meter, check how many key stress patterns match the meter's foot shape
494
+ // Weight by unit type: IU=3, PP=2, PW3+=2, CP=1, PW2=1
495
+ const KEY_WEIGHT = { IU: 3, PP: 2, PW3plus: 2, CP: 1, PW2: 1 };
496
+ // Build linear stress pattern for triple meter detection
497
+ let linearPattern = '';
498
+ for (const w of words) {
499
+ if (isPunctuation(w.lexicalClass))
500
+ continue;
501
+ for (const s of w.syllables) {
502
+ linearPattern += s.relativeStress ?? 'w';
503
+ }
504
+ }
505
+ function keyStressScore(meter) { console.log("Scoring meter:", meter);
506
+ const foot = METRES[meter].foot;
507
+ let score = 0;
508
+ let maxPossible = 0;
509
+ // For triple meters, also check linear pattern
510
+ if (foot.length === 3) {
511
+ let linearMatches = 0;
512
+ let totalPositions = 0;
513
+ for (let i = 0; i <= linearPattern.length - foot.length; i++) {
514
+ const substring = linearPattern.substring(i, i + foot.length);
515
+ if (patternMatches(substring, foot, false)) {
516
+ linearMatches++;
517
+ }
518
+ totalPositions++;
519
+ }
520
+ // Linear pattern matching contributes 50% of score for triple meters
521
+ const linearScore = totalPositions > 0 ? (linearMatches / totalPositions) * 50 : 0;
522
+ score += linearScore;
523
+ maxPossible += 50;
524
+ }
525
+ for (const ks of keyStresses) {
526
+ const weight = ks.unitType === 'PW'
527
+ ? (ks.pattern.length >= 3 ? KEY_WEIGHT.PW3plus : KEY_WEIGHT.PW2)
528
+ : KEY_WEIGHT[ks.unitType] || 1;
529
+ maxPossible += weight;
530
+ if (patternMatches(ks.pattern, foot, false)) {
531
+ score += weight;
532
+ }
533
+ else if (patternMatches(ks.pattern, foot, true)) {
534
+ score += weight * 0.7; // forward scan gets 70% credit
535
+ }
536
+ }
537
+ const result = maxPossible > 0 ? (score / maxPossible) * 100 : 0; console.log(" score:", score, "maxPossible:", maxPossible, "result:", result); return result;
538
+ }
539
+ // Score each meter using key stresses
540
+ let bestMeter = 'free verse';
541
+ let bestKeyScore = 0;
542
+ for (const name of candidateMeters) {
543
+ const keyScore = keyStressScore(name);
544
+ if (keyScore > bestKeyScore) {
545
+ bestKeyScore = keyScore;
546
+ bestMeter = name;
547
+ }
548
+ }
549
+ // If no meter reaches threshold, use DP aligner as fallback
550
+ if (bestKeyScore < 40) {
551
+ let bestDPScore = -Infinity;
552
+ for (const name of candidateMeters) {
553
+ const assembly = assembleScansion(words, name, ius);
554
+ if (assembly.rawScore > bestDPScore) {
555
+ bestDPScore = assembly.rawScore;
556
+ bestMeter = name;
557
+ }
558
+ }
559
+ if (bestDPScore < 0) {
560
+ bestMeter = 'free verse';
561
+ }
562
+ }
563
+ // Assemble scansion using the detected meter
564
+ const assembly = assembleScansion(words, bestMeter, ius);
565
+ const bestScansion = assembly.scansion;
566
+ const bestFootCount = assembly.footCount;
567
+ const maxPossible = (bestFootCount * 4) + ((totalSyls - bestFootCount) * 2);
568
+ const certainty = maxPossible > 0 ? Math.max(0, Math.min(100, Math.round((assembly.rawScore / maxPossible) * 100))) : 0;
569
+ const footType = METRES[bestMeter];
570
+ const metreName = bestMeter === 'free verse'
571
+ ? 'free verse'
572
+ : `${bestMeter} ${lineLengthName(bestFootCount)}`;
573
+ const summary = `IU=1 PP=${ius?.reduce((s, iu) => s + iu.phonologicalPhrases.length, 0) ?? 0} feet=${bestFootCount} ${footType?.sylCount ?? 0}syl`;
574
+ return {
575
+ all: '',
576
+ keyStresses: '',
577
+ meter: metreName,
578
+ meterName: bestMeter,
579
+ footCount: bestFootCount,
580
+ summary,
581
+ scansion: bestScansion,
582
+ certainty,
583
+ weightScore: assembly.rawScore,
584
+ maxPossibleWeight: maxPossible,
585
+ };
586
+ }
@@ -0,0 +1,32 @@
1
+ import { ClsWord, IntonationalUnit } from './types.js';
2
+ /**
3
+ * Assign per‑syllable lexical stress to each word in a sentence.
4
+ *
5
+ * Uses the first CMU pronunciation. Function words have their
6
+ * primary stress downgraded to secondary (2 → 1).
7
+ */
8
+ export declare function assignLexicalStress(words: ClsWord[]): void;
9
+ /**
10
+ * Adjust stresses for nominal compounds.
11
+ *
12
+ * Right‑stressed by default: the second content word keeps primary (2),
13
+ * the first is reduced to secondary (1).
14
+ * Known left‑stressed compounds (material, time, measure, location, self)
15
+ * reverse the pattern.
16
+ */
17
+ export declare function applyCompoundStress(ius: IntonationalUnit[]): void;
18
+ /**
19
+ * Recursively assign higher stress to content words from right to left.
20
+ * Only the rightmost content word receives a boost (+1 above lexical primary).
21
+ * All other content words keep their lexical stress.
22
+ * This preserves lexical stress for meter detection while still indicating
23
+ * the nuclear accent for phonological phrasing.
24
+ */
25
+ export declare function applyNuclearStress(ius: IntonationalUnit[]): void;
26
+ /**
27
+ * Convert numeric per‑syllable stress to McAleese’s symbolic levels
28
+ * (w, n, m, s) and resolve adjacent identical stresses using dependency
29
+ * information.
30
+ */
31
+ export declare function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]): void;
32
+ //# sourceMappingURL=stress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stress.d.ts","sourceRoot":"","sources":["../src/stress.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAyB,gBAAgB,EAAsB,MAAM,YAAY,CAAC;AAujBlG;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CA6Q1D;AAID;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAgCjE;AAmBD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAyBhE;AAID;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,gBAAgB,EAAE,GAAG,IAAI,CA0GtF"}