calliope-ts 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +32 -20
  2. package/dist/caesura.d.ts +33 -0
  3. package/dist/caesura.d.ts.map +1 -0
  4. package/dist/caesura.js +202 -0
  5. package/dist/calliope/boundaries.d.ts +19 -0
  6. package/dist/calliope/boundaries.d.ts.map +1 -0
  7. package/dist/calliope/boundaries.js +182 -0
  8. package/dist/calliope/bracketing.d.ts +11 -0
  9. package/dist/calliope/bracketing.d.ts.map +1 -0
  10. package/dist/calliope/bracketing.js +416 -0
  11. package/dist/calliope/deps.d.ts +4 -0
  12. package/dist/calliope/deps.d.ts.map +1 -0
  13. package/dist/calliope/deps.js +181 -0
  14. package/dist/calliope/engine.d.ts +3 -0
  15. package/dist/calliope/engine.d.ts.map +1 -0
  16. package/dist/calliope/engine.js +71 -0
  17. package/dist/calliope/feats.d.ts +9 -0
  18. package/dist/calliope/feats.d.ts.map +1 -0
  19. package/dist/calliope/feats.js +45 -0
  20. package/dist/calliope/names.d.ts +7 -0
  21. package/dist/calliope/names.d.ts.map +1 -0
  22. package/dist/calliope/names.js +42 -0
  23. package/dist/calliope/postag.d.ts +8 -0
  24. package/dist/calliope/postag.d.ts.map +1 -0
  25. package/dist/calliope/postag.js +250 -0
  26. package/dist/calliope/prosodic.d.ts +3 -0
  27. package/dist/calliope/prosodic.d.ts.map +1 -0
  28. package/dist/calliope/prosodic.js +275 -0
  29. package/dist/calliope/relstress.d.ts +4 -0
  30. package/dist/calliope/relstress.d.ts.map +1 -0
  31. package/dist/calliope/relstress.js +688 -0
  32. package/dist/calliope/stressrules.d.ts +28 -0
  33. package/dist/calliope/stressrules.d.ts.map +1 -0
  34. package/dist/calliope/stressrules.js +147 -0
  35. package/dist/calliope/syntax.d.ts +38 -0
  36. package/dist/calliope/syntax.d.ts.map +1 -0
  37. package/dist/calliope/syntax.js +234 -0
  38. package/dist/calliope/udpos.d.ts +13 -0
  39. package/dist/calliope/udpos.d.ts.map +1 -0
  40. package/dist/calliope/udpos.js +156 -0
  41. package/dist/clio/caesura.d.ts +27 -0
  42. package/dist/clio/caesura.d.ts.map +1 -0
  43. package/dist/clio/caesura.js +148 -0
  44. package/dist/clio/depfix.d.ts +13 -0
  45. package/dist/clio/depfix.d.ts.map +1 -0
  46. package/dist/clio/depfix.js +84 -0
  47. package/dist/clio/display.d.ts +32 -0
  48. package/dist/clio/display.d.ts.map +1 -0
  49. package/dist/clio/display.js +976 -0
  50. package/dist/clio/engine.d.ts +3 -0
  51. package/dist/clio/engine.d.ts.map +1 -0
  52. package/dist/clio/engine.js +28 -0
  53. package/dist/clio/parser.d.ts +10 -0
  54. package/dist/clio/parser.d.ts.map +1 -0
  55. package/dist/clio/parser.js +696 -0
  56. package/dist/clio/phonological.d.ts +41 -0
  57. package/dist/clio/phonological.d.ts.map +1 -0
  58. package/dist/clio/phonological.js +788 -0
  59. package/dist/clio/phrasestress.d.ts +6 -0
  60. package/dist/clio/phrasestress.d.ts.map +1 -0
  61. package/dist/clio/phrasestress.js +106 -0
  62. package/dist/clio/pipeline.d.ts +11 -0
  63. package/dist/clio/pipeline.d.ts.map +1 -0
  64. package/dist/clio/pipeline.js +147 -0
  65. package/dist/clio/rhyme.d.ts +65 -0
  66. package/dist/clio/rhyme.d.ts.map +1 -0
  67. package/dist/clio/rhyme.js +761 -0
  68. package/dist/clio/scandroid.d.ts +17 -0
  69. package/dist/clio/scandroid.d.ts.map +1 -0
  70. package/dist/clio/scandroid.js +435 -0
  71. package/dist/clio/scansion.d.ts +46 -0
  72. package/dist/clio/scansion.d.ts.map +1 -0
  73. package/dist/clio/scansion.js +1086 -0
  74. package/dist/clio/semantics.d.ts +44 -0
  75. package/dist/clio/semantics.d.ts.map +1 -0
  76. package/dist/clio/semantics.js +139 -0
  77. package/dist/clio/stress.d.ts +83 -0
  78. package/dist/clio/stress.d.ts.map +1 -0
  79. package/dist/clio/stress.js +1737 -0
  80. package/dist/clio/tagfix.d.ts +6 -0
  81. package/dist/clio/tagfix.d.ts.map +1 -0
  82. package/dist/clio/tagfix.js +101 -0
  83. package/dist/display.d.ts +0 -6
  84. package/dist/display.d.ts.map +1 -1
  85. package/dist/display.js +322 -141
  86. package/dist/engine.d.ts +9 -0
  87. package/dist/engine.d.ts.map +1 -0
  88. package/dist/engine.js +12 -0
  89. package/dist/index.d.ts +4 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +169 -26
  92. package/dist/parser.d.ts +3 -3
  93. package/dist/parser.d.ts.map +1 -1
  94. package/dist/parser.js +320 -563
  95. package/dist/phonological.d.ts +1 -1
  96. package/dist/phonological.d.ts.map +1 -1
  97. package/dist/phonological.js +73 -4
  98. package/dist/phrasestress.d.ts +6 -0
  99. package/dist/phrasestress.d.ts.map +1 -0
  100. package/dist/phrasestress.js +106 -0
  101. package/dist/rhyme.d.ts +40 -1
  102. package/dist/rhyme.d.ts.map +1 -1
  103. package/dist/rhyme.js +435 -7
  104. package/dist/scansion.d.ts +9 -0
  105. package/dist/scansion.d.ts.map +1 -1
  106. package/dist/scansion.js +145 -18
  107. package/dist/semantics.d.ts +44 -0
  108. package/dist/semantics.d.ts.map +1 -0
  109. package/dist/semantics.js +139 -0
  110. package/dist/stress.d.ts +127 -4
  111. package/dist/stress.d.ts.map +1 -1
  112. package/dist/stress.js +834 -61
  113. package/dist/types.d.ts +15 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/package.json +5 -3
  116. package/src/caesura.ts +201 -0
  117. package/src/calliope/boundaries.ts +190 -0
  118. package/src/calliope/bracketing.ts +390 -0
  119. package/src/calliope/deps.ts +160 -0
  120. package/src/calliope/engine.ts +77 -0
  121. package/src/calliope/feats.ts +46 -0
  122. package/src/calliope/names.ts +44 -0
  123. package/src/calliope/postag.ts +253 -0
  124. package/src/calliope/prosodic.ts +262 -0
  125. package/src/calliope/relstress.ts +645 -0
  126. package/src/calliope/stressrules.ts +147 -0
  127. package/src/calliope/syntax.ts +218 -0
  128. package/src/calliope/udpos.ts +152 -0
  129. package/src/calliope_src_contents.md +19049 -0
  130. package/src/clio/caesura.ts +145 -0
  131. package/src/clio/depfix.ts +88 -0
  132. package/src/clio/display.ts +1042 -0
  133. package/src/clio/engine.ts +38 -0
  134. package/src/clio/parser.ts +845 -0
  135. package/src/clio/phonological.ts +849 -0
  136. package/src/clio/phrasestress.ts +108 -0
  137. package/src/clio/pipeline.ts +154 -0
  138. package/src/clio/rhyme.ts +740 -0
  139. package/src/clio/scandroid.ts +434 -0
  140. package/src/clio/scansion.ts +1130 -0
  141. package/src/clio/semantics.ts +134 -0
  142. package/src/clio/stress.ts +1731 -0
  143. package/src/clio/tagfix.ts +104 -0
  144. package/src/display.ts +321 -137
  145. package/src/engine.ts +22 -0
  146. package/src/index.ts +176 -32
  147. package/src/parser.ts +346 -682
  148. package/src/phonological.ts +72 -4
  149. package/src/phrasestress.ts +108 -0
  150. package/src/rhyme.ts +428 -8
  151. package/src/scansion.ts +136 -15
  152. package/src/semantics.ts +134 -0
  153. package/src/stress.ts +794 -64
  154. package/src/types.ts +39 -4
  155. package/tests/DataForHayesLinesOnly.txt +364 -0
  156. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  157. package/tests/basic.test.ts +443 -37
  158. package/tests/bench-hayes.mjs +72 -0
  159. package/tests/wagner-stress.test.ts +188 -0
  160. package/vitest.config.ts +0 -15
package/dist/stress.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import * as nounsing from 'nounsing-pro';
5
5
  import { isPunctuation } from './parser.js';
6
6
  import { collectPPTokens, syllabifyWord } from './phonological.js';
7
+ import { isStrandedPreposition, isContrastivePossessive, isVocative, isDeicticLocative } from './semantics.js';
7
8
  // ─── CONSTANTS & CLASSIFICATIONS ──────────────────────────────────
8
9
  /**
9
10
  * Content‑word POS tags (nouns, adjectives, lexical verbs, adverbs).
@@ -118,6 +119,23 @@ const FUNCTION_ADVERBS = new Set([
118
119
  'always', 'often', 'sometimes', 'usually',
119
120
  'today', 'tomorrow', 'yesterday', 'tonight',
120
121
  ]);
122
+ /**
123
+ * Rising (iambic) disyllabic function words — prepositions, conjunctions, and
124
+ * deictic adverbs that stress the SECOND syllable (be·CAUSE, a·BOUT, be·TWEEN).
125
+ * A handful are recorded fully-reduced ("00") in the augmented dictionary; for
126
+ * those the all-zero re-stamp must fix the FINAL syllable, NOT take the
127
+ * disyllabic forestress default (which mis-read be·CAUSE as BE·cause). The
128
+ * re-stamp only fires on a genuine all-zero entry, so listing a word that
129
+ * already carries a peak is harmless. Trochaic IN·to / UN·to / ON·to are
130
+ * deliberately excluded — they correctly keep forestress.
131
+ */
132
+ const RISING_FUNCTION_WORDS = new Set([
133
+ 'because', 'about', 'above', 'around', 'across', 'along', 'among', 'amongst',
134
+ 'against', 'amid', 'amidst', 'apart', 'ahead', 'aside', 'away', 'aloft',
135
+ 'alone', 'aloud', 'anew', 'awhile', 'ago',
136
+ 'before', 'behind', 'below', 'beneath', 'beside', 'besides', 'between',
137
+ 'beyond', 'within', 'without',
138
+ ]);
121
139
  /**
122
140
  * Oblique (object/dative) pronouns. In clause-final position these are
123
141
  * canonically unstressed and do NOT attract the beat ("…and beHIND me", not
@@ -200,6 +218,70 @@ const FUNCTION_VERBS = new Set([
200
218
  'continue', 'continues', 'continued', 'continuing',
201
219
  'let', 'lets', "let's"
202
220
  ]);
221
+ /** The copula / auxiliary forms of BE — they reduce in connected speech whether
222
+ * used as copula ("she IS fair" → reduced) or auxiliary ("is going"). */
223
+ const BE_FORMS = new Set(['be', 'am', 'is', 'are', 'was', 'were', 'being']);
224
+ /**
225
+ * Exclamatory / vocative interjections. These are emphatic, expressive elements —
226
+ * never zero-provision clitics — yet FinNLP routinely mis-tags vocative "O" as a
227
+ * preposition (IN) and "Lo" as a proper noun (NNP), which would otherwise floor
228
+ * them to 'x'. Keyed off the lemma (not the unreliable tag), they are raised to at
229
+ * least 'n'; an exclaimed one ("O!", "Oh!") is lifted a further tier by the
230
+ * interjection-emphasis pass at the end of assignRelativeStresses. Apostrophe
231
+ * forms (o'er = over, e'er = ever) are NOT here — they are aphaeresis clitics.
232
+ */
233
+ const EXCLAM_INTERJECTIONS = new Set([
234
+ 'o', 'oh', 'ah', 'ay', 'aye', 'lo', 'alas', 'alack', 'hark', 'fie', 'woe',
235
+ 'ho', 'oho', 'aha', 'ahoy', 'hurrah', 'huzza',
236
+ ]);
237
+ /**
238
+ * Honest baseline relative prominence for a FUNCTION word (McAleese step 1b:
239
+ * "w=weak, n=some stress, m=subordinate strong, s=strong"). A monosyllabic
240
+ * function word bears no lexical *some-stress*; the dictionary's citation stress
241
+ * (which marks "and"/"in"/"my"/"could" as primary) is a CITATION artefact, not
242
+ * connected-speech prominence. We floor it to its true reading prominence — the
243
+ * meter layer still PROMOTES it to a beat where the metre calls for it (McAleese's
244
+ * Appendix-A Test 2: stressless "than"/"in" stay weak in the contour; the metre
245
+ * lifts only "from", which carries latent stress). Returns the tier to floor to,
246
+ * or null to leave the word untouched.
247
+ *
248
+ * 'x' (zero-provision) — pure schwa-proclitics that fully reduce: coordinators
249
+ * (CC: and/or/but/nor), monosyllabic prepositions & subordinators (IN), the
250
+ * infinitival/prepositional "to" (TO), possessive determiners (PRP$/WP$:
251
+ * my/your/his/her/its/our/their/whose).
252
+ * 'w' (overt-weak, still meter-promotable) — full-vowel function words: personal
253
+ * pronouns (PRP), modals (MD), existential "there" (EX), the copula/auxiliary
254
+ * BE forms, the AUXILIARY uses of have/do (by dependency role — a main-verb
255
+ * "have"/"did" keeps its beat), and reduced deictic/discourse adverbs.
256
+ *
257
+ * Content words and the internal contour of POLYSYLLABIC function words
258
+ * (be·NEATH = x·n, with·OUT) are returned null (untouched).
259
+ */
260
+ function relativeFloorFor(word) {
261
+ if (word.isContent)
262
+ return null;
263
+ if (word.syllables.length !== 1)
264
+ return null; // keep polysyllabic function contour
265
+ const pos = word.lexicalClass;
266
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
267
+ // Pure schwa-proclitics → zero-provision 'x'.
268
+ if (pos === 'CC' || pos === 'PRP$' || pos === 'WP$' || pos === 'TO')
269
+ return 'x';
270
+ if (pos === 'IN')
271
+ return 'x'; // prepositions / subordinators cliticise
272
+ // Full-vowel weak function words → overt-weak 'w' (meter-promotable).
273
+ if (pos === 'PRP' || pos === 'MD' || pos === 'EX')
274
+ return 'w';
275
+ if (BE_FORMS.has(lemma))
276
+ return 'w'; // copula & auxiliary BE both reduce
277
+ // NB: have/do are deliberately NOT floored — unlike BE they routinely bear a
278
+ // beat (main-verb "have"/"do", emphatic "DID"), and even as auxiliaries they
279
+ // carry an iambic beat often enough ("what HAD I given") that flooring them
280
+ // mis-reads such lines. Their level is left to the clash filter + meter layer.
281
+ if (FUNCTION_ADVERBS.has(lemma))
282
+ return 'w'; // so/then/here/there/when/just…
283
+ return null;
284
+ }
203
285
  /** Left‑stressed compound categories with example first‑word lists. */
204
286
  const LEFT_STRESS_MATERIAL = new Set([
205
287
  'metal', 'wood', 'silk', 'cotton', 'glass', 'stone', 'iron', 'steel',
@@ -254,7 +336,7 @@ const RIGHT_STRESS_HEADS = new Set([
254
336
  'juice', 'avenue', 'day',
255
337
  ]);
256
338
  /** Check if a pair of words forms a left‑stressed compound. */
257
- function isLeftStressedPair(w1, w2) {
339
+ export function isLeftStressedPair(w1, w2) {
258
340
  const first = w1.toLowerCase();
259
341
  const second = w2.toLowerCase().replace(/'s$/, '');
260
342
  // A right-stressing head overrides any forestress modifier (apple PIE).
@@ -280,6 +362,46 @@ function isLeftStressedPair(w1, w2) {
280
362
  return true;
281
363
  return false;
282
364
  }
365
+ /** True if `w2` is a head noun that keeps phrasal/right stress against an N1
366
+ * modifier (apple PIE, summer DAY, Fifth AVenue) — the marked right-stress
367
+ * exceptions to the otherwise fore-stressing Compound Stress Rule. */
368
+ export function isRightStressedHead(w2) {
369
+ return RIGHT_STRESS_HEADS.has(w2.toLowerCase().replace(/'s$/, ''));
370
+ }
371
+ /**
372
+ * Direction of primary stress for an adjacent two-word modification structure,
373
+ * the SINGLE source of truth shared by the lexical compound pass
374
+ * (`applyCompoundStress`) and the Phrase-Stress phase (`computePhraseStress`),
375
+ * so the two layers cannot disagree.
376
+ *
377
+ * 'left' = fore-stress, primary on w1 — the Compound Stress Rule default for
378
+ * an N+N compound (Chomsky–Halle; McAleese's worked example marks
379
+ * ICE cream with primary on "ice", not "cream"): KITCHen table,
380
+ * WINdow frame, BEDroom door, plus the curated LEFT_STRESS_* sets.
381
+ * 'right' = end-stress, primary on w2 — the marked exceptions: food/temporal
382
+ * "made of N1" heads (apple PIE, summer DAY), Adj+N which is phrasal
383
+ * not compound (sweet CREAM, red CAR), and proper-name sequences
384
+ * which carry their own right-headed prosody (New YORK, John SMITH).
385
+ * null = not a compound/modification pair at all.
386
+ *
387
+ * The fore-stress default is restricted to COMMON-noun N+N: proper-noun pairs
388
+ * (NNP/NNPS) are excluded because place- and personal-name sequences are not
389
+ * reliably fore-stressed, and flipping them would mis-teach New YORK / John SMITH.
390
+ */
391
+ export function compoundStressSide(w1, pos1, w2, pos2) {
392
+ const isNN = pos1.startsWith('N') && pos2.startsWith('N');
393
+ const isAdjN = pos1.startsWith('J') && pos2.startsWith('N');
394
+ if (!isNN && !isAdjN)
395
+ return null;
396
+ if (isLeftStressedPair(w1, w2))
397
+ return 'left'; // curated fore-stress modifier
398
+ if (isRightStressedHead(w2))
399
+ return 'right'; // apple PIE, Fifth AVenue
400
+ const proper = (p) => p === 'NNP' || p === 'NNPS';
401
+ if (isNN && !proper(pos1) && !proper(pos2))
402
+ return 'left'; // common-N+N compound default
403
+ return 'right'; // Adj+N phrasal / proper-name pair
404
+ }
283
405
  /**
284
406
  * Lexicalised forestress COLLOCATIONS — fixed two-word phrases that stress the
285
407
  * LEFT element, even though the second word is not a noun (so the N+N/J+N
@@ -291,6 +413,7 @@ function isLeftStressedPair(w1, w2) {
291
413
  const LEFT_STRESS_COLLOCATIONS = [
292
414
  { w1: 'good', w2: 'old' }, // GOOD old days
293
415
  { w1: 'end', w2: 'all', ok: w => w.lexicalClass !== 'PDT' }, // END-all (idiom), not "end ALL the wars"
416
+ { w1: 'old', w2: 'days' }, // the OLD days (fixed expression: "days" is semantically light)
294
417
  ];
295
418
  /** True if (w1,w2) is a lexicalised forestress collocation in this context. */
296
419
  function isLeftStressedCollocation(w1, w2) {
@@ -308,7 +431,7 @@ const VOWEL_CHARS = new Set('aeiouyAEIOUY');
308
431
  * silent 'e' ("where·fore", "there·in"): the plain vowel-group count reads the
309
432
  * 'e' as a nucleus and over-counts. Count the parts instead. */
310
433
  const SILENT_E_COMPOUND_RE = /^(where|there|here)(fore|in|by|of|on|upon|at|to|with|out|after|under|unto|abouts?|soever)$/;
311
- function countVowelGroups(word) {
434
+ function countVowelGroups(word, keepDisyllabicFinalE = false) {
312
435
  {
313
436
  const m = word.toLowerCase().replace(/[^a-z]/g, '').match(SILENT_E_COMPOUND_RE);
314
437
  // Closed-class second elements; counted directly ("fore" would otherwise
@@ -334,9 +457,20 @@ function countVowelGroups(word) {
334
457
  inVowel = false;
335
458
  }
336
459
  }
337
- if (groups >= 3 && n > 2 && lower[n - 1] === 'e' && VOWEL_CHARS.has(lower[n - 1])) {
460
+ // Final orthographic silent-e ("magic e"): a word-final 'e' that opens its own
461
+ // vowel group is silent in the default modern reading. Original rule fired only
462
+ // at 3+ groups; extended (2026-07-02) to DISYLLABIC shapes — archaic verse
463
+ // spellings ("seke", "fote", "nowe", "raunge", "chaunge") read as their modern
464
+ // monosyllables (seek, foot, now, range, change), which restored Wyatt's 4-beat
465
+ // accentual profile. Exclusions for the disyllabic case only:
466
+ // • -Cle/-Cre codas (obstruent+liquid+e): the liquid is syllabic and the 'e'
467
+ // is its written nucleus (table, sabre) — no deduction;
468
+ // • proper nouns (keepDisyllabicFinalE): foreign names pronounce it (Dante).
469
+ if (groups >= 2 && n > 2 && lower[n - 1] === 'e' && VOWEL_CHARS.has(lower[n - 1])) {
338
470
  const lastVowelStart = vowelPositions[vowelPositions.length - 1];
339
- if (lastVowelStart === n - 1) {
471
+ const syllabicLiquid = /[^aeiouy][lr]e$/.test(lower);
472
+ const allow = groups >= 3 || (!syllabicLiquid && !keepDisyllabicFinalE);
473
+ if (lastVowelStart === n - 1 && allow) {
340
474
  groups--;
341
475
  }
342
476
  }
@@ -419,8 +553,209 @@ function morphologicalStress(w) {
419
553
  return { pattern: [...stemNumeric, ...new Array(added).fill(0)], suffix: added >= 1 ? rule.suffix : '' };
420
554
  }
421
555
  }
556
+ // Tier 1b: PREFIX decomposition (Wagner §6.5.2 — a prefix forms its OWN prosodic
557
+ // domain; the STEM keeps its primary stress). Only fires on a genuinely OOV word
558
+ // (no suffix decomposition found and the bare word isn't in the lexicon — checked
559
+ // by the caller), and only when the prefix-stripped remainder IS a known stem, so
560
+ // it can only EXTEND coverage, never alter an in-lexicon word. Heavy separable
561
+ // prefixes (over-/under-/out-/anti-…) bear secondary stress; light ones
562
+ // (un-/re-/dis-…) are unstressed before the stem's primary.
563
+ const pre = prefixStress(w);
564
+ if (pre)
565
+ return pre;
422
566
  return null;
423
567
  }
568
+ /** Productive prefixes: syllable count + whether the prefix's first syllable bears
569
+ * a SECONDARY stress (separable/heavy) or is unstressed (light Latinate). */
570
+ const PREFIX_RULES = [
571
+ { prefix: 'counter', sylls: 2, sec: true },
572
+ { prefix: 'over', sylls: 2, sec: true }, { prefix: 'under', sylls: 2, sec: true },
573
+ { prefix: 'inter', sylls: 2, sec: true }, { prefix: 'super', sylls: 2, sec: true },
574
+ { prefix: 'anti', sylls: 2, sec: true }, { prefix: 'semi', sylls: 2, sec: true },
575
+ { prefix: 'multi', sylls: 2, sec: true }, { prefix: 'ultra', sylls: 2, sec: true },
576
+ { prefix: 'fore', sylls: 1, sec: true }, { prefix: 'out', sylls: 1, sec: true },
577
+ { prefix: 'non', sylls: 1, sec: true }, { prefix: 'self', sylls: 1, sec: true },
578
+ { prefix: 'un', sylls: 1, sec: false }, { prefix: 're', sylls: 1, sec: false },
579
+ { prefix: 'pre', sylls: 1, sec: false }, { prefix: 'dis', sylls: 1, sec: false },
580
+ { prefix: 'mis', sylls: 1, sec: false }, { prefix: 'de', sylls: 1, sec: false },
581
+ ];
582
+ function prefixStress(w) {
583
+ // longest prefix first (counter- before -); guard against tiny stems.
584
+ for (const rule of [...PREFIX_RULES].sort((a, b) => b.prefix.length - a.prefix.length)) {
585
+ if (!w.startsWith(rule.prefix))
586
+ continue;
587
+ const stem = w.slice(rule.prefix.length);
588
+ if (stem.length < 3)
589
+ continue; // need a real stem (re+do too short)
590
+ const data = nounsing.all(stem);
591
+ const raw = data && data.length ? (data[0].stress?.stressTrans || '') : '';
592
+ if (!raw)
593
+ continue;
594
+ const stemNumeric = [...raw].map(c => mapCMUStress(parseInt(c, 10)));
595
+ if (stemNumeric.length === 0 || !stemNumeric.some(n => n >= 2))
596
+ continue; // stem must carry a primary
597
+ const head = rule.sec ? 1 : 0;
598
+ const preNumeric = [head, ...new Array(Math.max(0, rule.sylls - 1)).fill(0)];
599
+ return { pattern: [...preNumeric, ...stemNumeric], suffix: '', prefix: rule.prefix };
600
+ }
601
+ return null;
602
+ }
603
+ /** Display-only prefix detection for ALL words (in-vocab AND OOV).
604
+ *
605
+ * The OOV-only `morphologicalStress` prefix path (Tier 1b) only fires when no
606
+ * suffix decomposition is found — so "disillusions" (OOV, suffix "-s" → stem
607
+ * "disillusion" IN-VOCAB) returns before the prefix path runs, and
608
+ * "uneducated" (IN-VOCAB) never hits the OOV branch at all. Neither gets
609
+ * `morphPrefix` set, and the display syllabifier's Maximal Onset principle
610
+ * pulls the prefix's final consonant into the next syllable (di·sil·lu·sions,
611
+ * u·ne·du·ca·ted).
612
+ *
613
+ * This pass runs AFTER `assignLexicalStress` for every word: if it starts with
614
+ * a known productive prefix (from `PREFIX_RULES`) and the stripped stem is in
615
+ * the nounsing-pro dictionary, set `word.morphPrefix`. The syllabifier then
616
+ * peels the prefix as the first syllable(s) and syllabifies the stem
617
+ * separately, respecting the morpheme boundary (dis·il·lu·sions,
618
+ * un·ed·u·ca·ted). DISPLAY-ONLY — never changes stress or meter.
619
+ *
620
+ * Guards: the stem must carry a primary stress (so we don't peel a prefix off
621
+ * a function word like "into"), and the prefix must be at least 2 characters
622
+ * (to avoid false positives on short words). */
623
+ export function detectDisplayPrefixes(words) {
624
+ for (const word of words) {
625
+ if (word.morphPrefix)
626
+ continue; // already set by OOV path
627
+ if (word.syllables.length < 2)
628
+ continue; // monosyllables: no boundary to place
629
+ const clean = word.word.toLowerCase().replace(/-/g, '').replace(/[''']/g, '');
630
+ if (clean.length < 5)
631
+ continue; // too short to have prefix + real stem
632
+ for (const rule of [...PREFIX_RULES].sort((a, b) => b.prefix.length - a.prefix.length)) {
633
+ if (!clean.startsWith(rule.prefix))
634
+ continue;
635
+ const stem = clean.slice(rule.prefix.length);
636
+ if (stem.length < 3)
637
+ continue; // need a real stem
638
+ const data = nounsing.all(stem);
639
+ if (!data || data.length === 0)
640
+ continue;
641
+ const raw = data[0].stress?.stressTrans || '';
642
+ if (!raw || !raw.split('').some(c => c === '1' || c === '2'))
643
+ continue; // stem must carry stress
644
+ word.morphPrefix = rule.prefix;
645
+ break;
646
+ }
647
+ }
648
+ }
649
+ /**
650
+ * Stress Shift — swap primary↔secondary when Nounsing-Pro confirms the word
651
+ * CAN shift (`suffixShiftPotential` returns `shiftLikely: true`) and the
652
+ * phonological context motivates it. This is NOT a global rule — it is
653
+ * grounded in the dictionary's own shift-likelihood assessment plus a
654
+ * syntactic/phonological context gate.
655
+ *
656
+ * Conditions (ALL must hold):
657
+ * 1. `suffixShiftPotential(word)` returns `shiftLikely: true`
658
+ * 2. The word's `stressTrans` has both `1` (primary) and `2` (secondary)
659
+ * 3. The LAST digit of `stressTrans` is `2` — secondary is on the final
660
+ * syllable, so swapping 1↔2 moves primary TO the final
661
+ * 4. Context gate (either):
662
+ * a. The word is a VB at the start of a phonological phrase (imperative
663
+ * at phrase start — "REcognize" → "recogNIZE")
664
+ * b. Rhythm Rule clash: a stressed syllable follows within 2 syllables
665
+ * (the shift avoids a clash with the following stress)
666
+ *
667
+ * Words like "realize" (shiftLikely=false) are correctly EXCLUDED —
668
+ * Nounsing-Pro says they cannot shift, and the code respects that.
669
+ *
670
+ * Effect: swap the `lexicalStress` values of the primary (2) and secondary (1)
671
+ * syllables, so `peakSyllable` and all downstream computation see the shifted
672
+ * peak. The syllables' `stress` (raw CMU) values are also swapped for
673
+ * consistency.
674
+ */
675
+ export function applyStressShift(words, ius) {
676
+ // Build a set of words that are at the start of a phonological phrase
677
+ const ppInitials = new Set();
678
+ for (const iu of ius) {
679
+ for (const pp of iu.phonologicalPhrases) {
680
+ const toks = collectPPTokens(pp)
681
+ .filter(w => w.syllables.length > 0)
682
+ .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
683
+ if (toks.length > 0)
684
+ ppInitials.add(toks[0]);
685
+ }
686
+ }
687
+ const stressed = words.filter(w => w.syllables.length > 0 && !isPunctuation(w.lexicalClass));
688
+ const flat = [];
689
+ for (const w of [...stressed].sort((a, b) => a.absoluteIndex - b.absoluteIndex))
690
+ for (const s of w.syllables)
691
+ flat.push({ word: w, syl: s });
692
+ for (const word of stressed) {
693
+ if (word.syllables.length < 3)
694
+ continue; // need 3+ syllables for a meaningful shift
695
+ const data = nounsing.all(word.word.toLowerCase().replace(/[''']/g, ''));
696
+ if (!data || data.length === 0)
697
+ continue;
698
+ const st = data[0].stress?.stressTrans ?? '';
699
+ if (!st)
700
+ continue;
701
+ // Condition 2: has both primary (1) and secondary (2)
702
+ if (!st.includes('1') || !st.includes('2'))
703
+ continue;
704
+ // Condition 3: the secondary (2) is at a HIGHER index than the primary
705
+ // (1) in the stressTrans string — the swap moves primary RIGHTWARD (toward
706
+ // the end of the word). This covers both "recognize" (102: 1→0, 2→2) and
707
+ // "dictating" (120: 1→0, 2→1). Words like "understand" (201: primary
708
+ // already on final) are correctly excluded — the swap would move primary
709
+ // leftward, which is wrong.
710
+ const idx1 = st.indexOf('1');
711
+ const idx2 = st.indexOf('2');
712
+ if (idx2 <= idx1)
713
+ continue;
714
+ // Condition 1: shiftLikely from Nounsing-Pro
715
+ let shiftLikely = false;
716
+ try {
717
+ const shift = nounsing.suffixShiftPotential(word.word.toLowerCase().replace(/[''']/g, ''));
718
+ if (shift && shift.length > 0)
719
+ shiftLikely = !!shift[0].shiftLikely;
720
+ }
721
+ catch { /* graceful no-op */ }
722
+ if (!shiftLikely)
723
+ continue;
724
+ // Condition 4: context gate — any verb form (VB*). The stress shift is a
725
+ // well-known phonological process for English verbs (Rhythm Rule, imperative
726
+ // emphasis, compound stress). Nounsing-Pro's shiftLikely already encodes
727
+ // the phonological possibility; restricting to verb forms excludes nouns
728
+ // like "potato" (NN, shiftLikely=true) that should NOT shift. No PP-initial
729
+ // requirement — the shift applies to verb forms in any position, matching
730
+ // how English speakers actually shift stress in running speech.
731
+ if (!/^VB/.test(word.lexicalClass))
732
+ continue;
733
+ // Execute the shift: swap lexicalStress (and stress) of the primary(2→1)
734
+ // and secondary(1→2) syllables. In our internal mapping:
735
+ // CMU/nounsing 1=primary → lexicalStress 2
736
+ // CMU/nounsing 2=secondary → lexicalStress 1
737
+ // CMU/nounsing 0=unstressed → lexicalStress 0
738
+ // So we swap the syllable with lexicalStress===2 and the one with ===1.
739
+ let primarySyl = null;
740
+ let secondarySyl = null;
741
+ for (const s of word.syllables) {
742
+ if ((s.lexicalStress ?? s.stress) === 2)
743
+ primarySyl = s;
744
+ if ((s.lexicalStress ?? s.stress) === 1)
745
+ secondarySyl = s;
746
+ }
747
+ if (!primarySyl || !secondarySyl)
748
+ continue;
749
+ // Swap lexicalStress
750
+ const tmpLex = primarySyl.lexicalStress;
751
+ primarySyl.lexicalStress = secondarySyl.lexicalStress;
752
+ secondarySyl.lexicalStress = tmpLex;
753
+ // Swap raw stress too (for consistency with downstream that reads s.stress)
754
+ const tmpStress = primarySyl.stress;
755
+ primarySyl.stress = secondarySyl.stress;
756
+ secondarySyl.stress = tmpStress;
757
+ }
758
+ }
424
759
  /** Archaic verbal suffixes whose orthographic peel cleanly separates a silent-
425
760
  * consonant stem from the suffix for DISPLAY (know·est not kno·west). Other
426
761
  * suffixes keep the default orthographic syllabifier (it handles them well). */
@@ -468,6 +803,10 @@ const PRESTRESS_SUFFIXES = [
468
803
  // data-derived 2026-06-10: final-stressing (ultShift)
469
804
  { suffix: 'ette', offset: 1 }, { suffix: 'ese', offset: 1 },
470
805
  { suffix: 'eer', offset: 1 }, { suffix: 'ique', offset: 1 },
806
+ // -oon is a reliable final-stresser (bal·LOON, car·TOON, co·COON, after·NOON);
807
+ // OOV-only. -ee/-ade are deliberately NOT added: they are impure (COF·fee,
808
+ // com·RADE, DEC·ade) and would mis-stress more than they fix.
809
+ { suffix: 'oon', offset: 1 },
471
810
  // data-derived: penult-stressing
472
811
  { suffix: 'ion', offset: 2 }, { suffix: 'sive', offset: 2 },
473
812
  { suffix: 'lla', offset: 2 }, { suffix: 'llo', offset: 2 },
@@ -493,8 +832,8 @@ const PRESTRESS_SUFFIXES = [
493
832
  * falls on a heavy penult, else the antepenult (Hayes 1982). This fixes e.g.
494
833
  * an·FRAC·tuous / e·NIG·ma where blind forestress erred.
495
834
  */
496
- function englishStressRule(w, isContent) {
497
- const n = countVowelGroups(w);
835
+ function englishStressRule(w, isContent, properNoun = false) {
836
+ const n = countVowelGroups(w, properNoun);
498
837
  const primary = isContent ? 2 : 1;
499
838
  if (n <= 1)
500
839
  return [primary];
@@ -512,8 +851,35 @@ function englishStressRule(w, isContent) {
512
851
  const penult = n - 2; // final (n-1) is extrametrical
513
852
  const heavyPenult = sylls[penult] ? syllableIsHeavy(sylls[penult]) : true;
514
853
  pattern[heavyPenult ? penult : Math.max(0, n - 3)] = primary;
854
+ // A CONTENT polysyllable does not stay flat after its one primary — give it the
855
+ // rhythmic secondaries English requires (so an OOV 4+-syllable word reads with a
856
+ // real 'n'-tier beat, not a long unstressed tail).
857
+ if (isContent) {
858
+ const heavy = sylls.map(s => (s ? syllableIsHeavy(s) : false));
859
+ addSecondaryStresses(pattern, heavy);
860
+ }
515
861
  return pattern;
516
862
  }
863
+ /** Hayes-style rhythmic SECONDARY stresses for an OOV content polysyllable that the
864
+ * English Stress Rule has given a single primary (2). English does not leave a long
865
+ * word with one stress and a flat reduced tail: PRETONIC syllables alternating
866
+ * leftward from the primary bear a secondary (1) — the metrical grid's lower beats —
867
+ * e.g. Mìs·sis·SÌp·pi, à·pa·là·CHI·an. Only the PRETONIC pattern is added: English
868
+ * pre-stress secondaries are robust, whereas POST-tonic ones are weak and reduce, and
869
+ * adding them nudged the meter-fitter on a handful of corpus lines for no qualitative
870
+ * gain. Deliberately conservative — a secondary is placed only ≥ 2 syllables from the
871
+ * primary (so it can never clash with the primary or another secondary), so it can
872
+ * only ADD the 'n'-tier differentiation the contour was missing, never a competing
873
+ * beat. */
874
+ function addSecondaryStresses(pattern, _heavy) {
875
+ const p = pattern.indexOf(2);
876
+ if (p < 2)
877
+ return; // need a primary with ≥ 2 pretonic syllables
878
+ for (let i = p - 2; i >= 0; i -= 2) { // pretonic alternation only
879
+ if (pattern[i] === 0)
880
+ pattern[i] = 1;
881
+ }
882
+ }
517
883
  /**
518
884
  * Per-syllable heaviness from nounsing's `syllStruct` CV transcription
519
885
  * ("L.CL.CLC": C = consonant, L = lax/short nucleus, T = tense/long nucleus).
@@ -662,10 +1028,16 @@ export function assignLexicalStress(words) {
662
1028
  word.isContent = isContent;
663
1029
  // Tier 1: morphological stem (reuse real lexical stress); Tier 2: ESR.
664
1030
  const morph = morphologicalStress(cleanWord);
665
- const pattern = morph ? morph.pattern : englishStressRule(cleanWord, isContent);
1031
+ const pattern = morph ? morph.pattern
1032
+ : englishStressRule(cleanWord, isContent, /^NNPS?$/.test(word.lexicalClass));
666
1033
  // Record an archaic verbal suffix so the display splits know·est, not kno·west.
667
1034
  if (morph && DISPLAY_PEEL_SUFFIXES.has(morph.suffix))
668
1035
  word.morphSuffix = morph.suffix;
1036
+ // Record a productive prefix so the display splits dis·il·lu·sions, not
1037
+ // di·sil·lu·sions (the Maximal Onset principle would otherwise pull the
1038
+ // prefix's final consonant into the next syllable).
1039
+ if (morph && morph.prefix)
1040
+ word.morphPrefix = morph.prefix;
669
1041
  const syls = pattern.map(numeric => {
670
1042
  // Mirror the in-vocab function-word reduction (primary → secondary).
671
1043
  const n = (!isContent && numeric === 2) ? 1 : numeric;
@@ -738,7 +1110,12 @@ export function assignLexicalStress(words) {
738
1110
  if (rawStress.length >= 2 && /^0+$/.test(rawStress)) {
739
1111
  const cw = word.word.toLowerCase().replace(/-/g, '').replace(/['’]/g, '');
740
1112
  const heavy = heavyFromSyllStruct(profile.phonology.syllStruct, rawStress.length);
741
- const idx = defaultStressIndex(cw, rawStress.length, heavy);
1113
+ // Rising/iambic function words (be·CAUSE, a·BOUT) recorded fully-reduced
1114
+ // take FINAL-syllable stress; everything else keeps the forestress default
1115
+ // (IN·to, and polysyllabic content artifacts).
1116
+ const idx = RISING_FUNCTION_WORDS.has(cw)
1117
+ ? rawStress.length - 1
1118
+ : defaultStressIndex(cw, rawStress.length, heavy);
742
1119
  rawStress = rawStress.split('').map((c, i) => (i === idx ? '1' : '0')).join('');
743
1120
  }
744
1121
  // Targeted fix for the "I'm" anomaly: a subject-pronoun contraction the
@@ -832,10 +1209,12 @@ export function assignLexicalStress(words) {
832
1209
  /**
833
1210
  * Adjust stresses for nominal compounds.
834
1211
  *
835
- * Right‑stressed by default: the second content word keeps primary (2),
836
- * the first is reduced to secondary (1).
837
- * Known left‑stressed compounds (material, time, measure, location, self)
838
- * reverse the pattern.
1212
+ * Fore-stressed by default: an N+N compound puts primary (2) on the first
1213
+ * element, secondary (1) on the second the Compound Stress Rule (Chomsky–
1214
+ * Halle; McAleese marks ICE cream with primary on "ice", KITCHen table,
1215
+ * WINdow frame). The marked right-stress exceptions — food/temporal "made of"
1216
+ * heads (apple PIE), Adj+N phrases (sweet CREAM), and proper-name sequences
1217
+ * (New YORK) — reverse it. All of this lives in `compoundStressSide`.
839
1218
  */
840
1219
  export function applyCompoundStress(ius) {
841
1220
  for (const iu of ius) {
@@ -850,13 +1229,10 @@ export function applyCompoundStress(ius) {
850
1229
  // Wait, they must be adjacent in the sentence!
851
1230
  if (Math.abs(w1.absoluteIndex - w2.absoluteIndex) !== 1)
852
1231
  continue;
853
- const pos1 = w1.lexicalClass;
854
- const pos2 = w2.lexicalClass;
855
- const isCompound = (pos2.startsWith('N') && (pos1.startsWith('N') || pos1.startsWith('J')));
856
- if (!isCompound)
1232
+ const side = compoundStressSide(w1.word, w1.lexicalClass, w2.word, w2.lexicalClass);
1233
+ if (side === null)
857
1234
  continue;
858
- const leftStressed = isLeftStressedPair(w1.word, w2.word);
859
- if (leftStressed) {
1235
+ if (side === 'left') {
860
1236
  setPrimaryStress(w1, 2);
861
1237
  setPrimaryStress(w2, 1);
862
1238
  }
@@ -869,7 +1245,7 @@ export function applyCompoundStress(ius) {
869
1245
  }
870
1246
  }
871
1247
  /** Locate the syllable with the highest stress and set it to `value`. */
872
- function setPrimaryStress(word, value) {
1248
+ export function setPrimaryStress(word, value) {
873
1249
  let maxIdx = -1;
874
1250
  let maxVal = -1;
875
1251
  for (let i = 0; i < word.syllables.length; i++) {
@@ -894,35 +1270,108 @@ export function applyNuclearStress(ius) {
894
1270
  for (const iu of ius) {
895
1271
  for (const pp of iu.phonologicalPhrases) {
896
1272
  const words = collectPPTokens(pp).sort((a, b) => a.index - b.index);
897
- // Find the rightmost content word and boost its primary by 1 level.
1273
+ // The nuclear accent normally lands on the rightmost CONTENT word.
1274
+ let target = null;
898
1275
  for (let i = words.length - 1; i >= 0; i--) {
899
1276
  if (words[i].isContent) {
900
- const word = words[i];
901
- let maxIdx = -1;
902
- let maxVal = -1;
903
- for (let j = 0; j < word.syllables.length; j++) {
904
- if (word.syllables[j].stress > maxVal) {
905
- maxVal = word.syllables[j].stress;
906
- maxIdx = j;
907
- }
1277
+ target = words[i];
1278
+ break;
1279
+ }
1280
+ }
1281
+ // But a content-less phrase ending in a non-oblique personal pronoun puts
1282
+ // the nuclear accent on that final pronoun (NSR: the accent falls on the
1283
+ // last accentable item; a clause-final addressee/focus pronoun bears it —
1284
+ // "…to YOU"). Only when there is no content word to carry it, so "I KNOW
1285
+ // you" is untouched; oblique objects (me/him/them) stay deaccented.
1286
+ if (!target) {
1287
+ let lastIdx = -1;
1288
+ for (let i = words.length - 1; i >= 0; i--) {
1289
+ if (!isPunctuation(words[i].lexicalClass)) {
1290
+ lastIdx = i;
1291
+ break;
908
1292
  }
909
- if (maxIdx >= 0) {
910
- // Boost the rightmost content word's primary by 1.
911
- word.syllables[maxIdx].stress = word.syllables[maxIdx].stress + 1;
1293
+ }
1294
+ if (lastIdx >= 0) {
1295
+ const w = words[lastIdx];
1296
+ if (w.lexicalClass === 'PRP'
1297
+ && !OBLIQUE_PRONOUNS.has(w.word.toLowerCase().replace(/['’]/g, ''))) {
1298
+ target = w;
912
1299
  }
913
- break; // Only the rightmost content word gets boosted.
914
1300
  }
915
1301
  }
1302
+ if (target) {
1303
+ let maxIdx = -1;
1304
+ let maxVal = -1;
1305
+ for (let j = 0; j < target.syllables.length; j++) {
1306
+ if (target.syllables[j].stress > maxVal) {
1307
+ maxVal = target.syllables[j].stress;
1308
+ maxIdx = j;
1309
+ }
1310
+ }
1311
+ if (maxIdx >= 0)
1312
+ target.syllables[maxIdx].stress += 1;
1313
+ }
916
1314
  }
917
1315
  }
918
1316
  }
919
1317
  // ─── RELATIVE STRESS ASSIGNMENT (4‑LEVEL) ─────────────────────────
1318
+ /**
1319
+ * Phrase-edge sets used for the "endings strict / beginnings loose" floor gate.
1320
+ * McAleese (after Hayes & Kaun): metrical and lexical stress coincide at the
1321
+ * ENDS of phonological units (clitic phrase 90% / phonological phrase 97% /
1322
+ * intonational unit 99%); Selkirk notes a function word is not reduced at the
1323
+ * end of a clitic phrase ("of" never → "o'" there). So a function word at the
1324
+ * right edge of a PP or IU — or the phrase-stress PEAK (nucleus) of its PP —
1325
+ * resists the citation-floor reduction, while interior / left-edge function
1326
+ * words reduce as before. Reads the Phrase-Stress phase (word.phraseStress).
1327
+ */
1328
+ function phraseEdgeSets(ius) {
1329
+ const ppFinal = new Set();
1330
+ const iuFinal = new Set();
1331
+ const ppPeak = new Set();
1332
+ for (const iu of ius) {
1333
+ let iuLast = null;
1334
+ for (const pp of iu.phonologicalPhrases) {
1335
+ const toks = collectPPTokens(pp)
1336
+ .filter(w => !isPunctuation(w.lexicalClass))
1337
+ .sort((a, b) => a.index - b.index);
1338
+ if (toks.length === 0)
1339
+ continue;
1340
+ const last = toks[toks.length - 1];
1341
+ ppFinal.add(last);
1342
+ iuLast = last;
1343
+ let peak = toks[0];
1344
+ let peakVal = peak.phraseStress ?? 0;
1345
+ for (const t of toks) {
1346
+ const v = t.phraseStress ?? 0;
1347
+ if (v > peakVal) {
1348
+ peak = t;
1349
+ peakVal = v;
1350
+ }
1351
+ }
1352
+ // Only protect a genuine nuclear ramp peak (phraseStress ≥ 2): an all-floor
1353
+ // PP (all function words, or the Phase-Stress phase not run) has no nucleus
1354
+ // to protect, so we must not spuriously shield its first token.
1355
+ if (peakVal >= 2)
1356
+ ppPeak.add(peak);
1357
+ }
1358
+ if (iuLast)
1359
+ iuFinal.add(iuLast);
1360
+ }
1361
+ return { ppFinal, iuFinal, ppPeak };
1362
+ }
920
1363
  /**
921
1364
  * Convert numeric per‑syllable stress to McAleese’s symbolic levels
922
1365
  * (w, n, m, s) and resolve adjacent identical stresses using dependency
923
1366
  * information.
924
1367
  */
925
1368
  export function assignRelativeStresses(words, ius) {
1369
+ // Phrase-edge sets for the endings-strict floor gate (read from Phase-Stress).
1370
+ const edges = phraseEdgeSets(ius);
1371
+ // Syllables raised by dependency-mined prominence (stranded preposition,
1372
+ // contrastive possessive, vocative): protected from the per-PP trailing-run
1373
+ // flatten so the recovered beat survives.
1374
+ const prominenceProtected = new Set();
926
1375
  // First pass: numeric → symbolic (0→w, 1→n, 2→m, 3+→s)
927
1376
  // Use lexicalStress (pre-nuclear) so nuclear stress doesn't corrupt meter detection.
928
1377
  for (const word of words) {
@@ -966,7 +1415,12 @@ export function assignRelativeStresses(words, ius) {
966
1415
  // wh-word/coordinator is reduced in running speech; flooring it at 'n' is
967
1416
  // what produced the flat function-word runs ("So on my", "where strange").
968
1417
  // (Pure clitics the/a/of already read 'x' via the val===0 branch.)
969
- if (syl.relativeStress === 'n' && isMonosyllabicClitic(word)) {
1418
+ if (syl.relativeStress === 'n'
1419
+ && (isMonosyllabicClitic(word) || word.word.toLowerCase() === 'am')) {
1420
+ // "am" (1sg copula) is reliably reduced — far more so than beat-bearing
1421
+ // is/are/was/were — so it floors at 'w' (still Attridge-promotable) rather
1422
+ // than surfacing at 'm' as a spurious beat in "As I am BLOOD…". Kept to
1423
+ // this one form: flooring all be-verbs regressed Wyatt's accentual + corpus.
970
1424
  syl.relativeStress = 'w';
971
1425
  }
972
1426
  // Downgrade extrametrical syllables by one level. We do NOT push a weak
@@ -982,6 +1436,55 @@ export function assignRelativeStresses(words, ius) {
982
1436
  syl.relativeStress = 'm';
983
1437
  }
984
1438
  }
1439
+ // Honest baseline prominence: floor a monosyllabic function word to its true
1440
+ // reading prominence ('x' schwa-clitic / 'w' overt-weak), never raising it.
1441
+ // The dictionary's citation stress on "and"/"in"/"my"/"could" is an artefact;
1442
+ // the meter layer re-promotes these where the metre needs a beat.
1443
+ //
1444
+ // ENDINGS STRICT / BEGINNINGS LOOSE (McAleese; Selkirk): withhold the floor
1445
+ // for a function word at the right edge of a PP or IU, or one that is its
1446
+ // PP's phrase-stress peak — it resists reduction there (a stranded/clause-
1447
+ // final "to"/"you"/"of" is not crushed to a clitic). Oblique object
1448
+ // pronouns (me/him/thee…) are excepted: they stay default-deaccented even
1449
+ // phrase-finally ("I gave it to HIM" only under focus, handled later).
1450
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
1451
+ const edgeProtected = (edges.iuFinal.has(word) || edges.ppFinal.has(word) || edges.ppPeak.has(word))
1452
+ && !OBLIQUE_PRONOUNS.has(lemma);
1453
+ const floor = relativeFloorFor(word);
1454
+ if (floor && !edgeProtected) {
1455
+ const fr = STRESS_RANK[floor];
1456
+ for (const syl of word.syllables) {
1457
+ if (STRESS_RANK[syl.relativeStress ?? 'w'] > fr)
1458
+ syl.relativeStress = floor;
1459
+ }
1460
+ }
1461
+ // Dependency-mined prominence (the parse IS the semantic layer): recover the
1462
+ // beat a flat POS floor would crush. A STRANDED preposition ("waiting FOR",
1463
+ // "stare AT") and a CONTRASTIVE possessive ("THY choice, not mine") bear
1464
+ // real stress → raise the peak to at least 'n' (promotable); a VOCATIVE
1465
+ // address ("Sing, O GODDESS") to at least 'm'. Raise-only; the raised peak
1466
+ // is protected from the per-PP trailing-run flatten below.
1467
+ let promoteTo = null;
1468
+ if (isVocative(word, words) || isDeicticLocative(word, words))
1469
+ promoteTo = 'm';
1470
+ else if (isStrandedPreposition(word, words) || isContrastivePossessive(word, words))
1471
+ promoteTo = 'n';
1472
+ if (promoteTo) {
1473
+ const pk = wordPeak(word);
1474
+ if (pk && STRESS_RANK[pk.relativeStress ?? 'w'] < STRESS_RANK[promoteTo]) {
1475
+ pk.relativeStress = promoteTo;
1476
+ }
1477
+ if (pk)
1478
+ prominenceProtected.add(pk);
1479
+ }
1480
+ // Exclamatory interjection ("O", "Oh", "Ah", "Lo", "Alas"): emphatic, never
1481
+ // reduced — raise its peak to at least 'n' (corrects the mis-tag that floored
1482
+ // vocative "O"→IN→'x'). Raise-only; an exclaimed one is lifted further below.
1483
+ if (EXCLAM_INTERJECTIONS.has(word.word.toLowerCase())) {
1484
+ const pk = wordPeak(word);
1485
+ if (pk && STRESS_RANK[pk.relativeStress ?? 'w'] < STRESS_RANK.n)
1486
+ pk.relativeStress = 'n';
1487
+ }
985
1488
  }
986
1489
  // Apply nuclear stress boosts to relative stress.
987
1490
  // `syl.stress` may be higher than `syl.lexicalStress` after applyNuclearStress
@@ -1011,7 +1514,7 @@ export function assignRelativeStresses(words, ius) {
1011
1514
  for (const iu of ius) {
1012
1515
  for (const pp of iu.phonologicalPhrases) {
1013
1516
  const ppWords = collectPPTokens(pp);
1014
- resolveAdjacentClashes(ppWords);
1517
+ resolveAdjacentClashes(ppWords, prominenceProtected);
1015
1518
  }
1016
1519
  }
1017
1520
  // Third pass: resolve clashes across prosodic boundaries (PP and IU).
@@ -1034,6 +1537,178 @@ export function assignRelativeStresses(words, ius) {
1034
1537
  // waste shore" leaving "waste"/"shore" non-adjacent in the tree though
1035
1538
  // contiguous in speech). Catch any residual cardinal s–s clash here.
1036
1539
  resolveLinearClashes(words);
1540
+ // Exclaimed interjection: an interjection immediately followed by "!" (Oh!, Ah!,
1541
+ // O!, Lo!) is an emphatic peak — raise it one tier so it stands out from a flat
1542
+ // run of neighbouring function words ("But—Oh! ye lords…" was a monotone n·n·n,
1543
+ // with the interjection no louder than the conjunction beside it). Narrow by
1544
+ // construction: only an UH whose very next token is "!".
1545
+ for (let i = 0; i < words.length - 1; i++) {
1546
+ const w = words[i];
1547
+ if (w.lexicalClass !== 'UH' || w.syllables.length === 0)
1548
+ continue;
1549
+ if (words[i + 1].word !== '!')
1550
+ continue;
1551
+ const pk = wordPeak(w);
1552
+ if (!pk)
1553
+ continue;
1554
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1555
+ if (r < STRESS_RANK.s)
1556
+ pk.relativeStress = STRESS_LEVELS[r + 1];
1557
+ }
1558
+ }
1559
+ /**
1560
+ * Surface-order post-processing passes that re-assert forestress and resolve
1561
+ * residual clashes AFTER the main relativisation. These run in the Clio engine
1562
+ * inside `assignRelativeStresses`; the Calliope engine calls this separately
1563
+ * after `computeRelativeStress` so the same repairs reach both pipelines.
1564
+ *
1565
+ * The passes are:
1566
+ * 1. resolveCompoundForestress — re-assert left-stress on surface-adjacent
1567
+ * N+N/J+N compounds the tagger mislabels (WASTE·shore, SEA·shore).
1568
+ * 2. resolveCollocationForestress — forestress lexicalised collocations
1569
+ * (GOOD old, END-all, OLD days).
1570
+ * 3. resolveHyphenCompounds — resolve dual-strong clashes at hyphen seams
1571
+ * (torch-flames, blood-red).
1572
+ * 4. resolveLinearClashes — catch residual s-s / m-m / n-n surface clashes
1573
+ * with the full 7-level demotion cascade (lexical integrity → prominence →
1574
+ * syntactic headedness → content/function → weight → Rhythm Rule → default).
1575
+ * 5. Exclaimed interjection raise — an interjection immediately followed by
1576
+ * "!" is lifted one tier ("But—Oh! ye lords…").
1577
+ *
1578
+ * All passes are DEMOTE-ONLY (or forestress re-assertions that raise the LEFT
1579
+ * element of a known compound); none inflate the contour beyond what the lexicon
1580
+ * and phrase-stress rules already established.
1581
+ */
1582
+ export function applySurfacePostProcessing(words) {
1583
+ resolveCompoundForestress(words);
1584
+ resolveCollocationForestress(words);
1585
+ resolveHyphenCompounds(words);
1586
+ resolveLinearClashes(words);
1587
+ resolvePhrasalVerbParticle(words);
1588
+ raiseInterrogativePronounFocus(words);
1589
+ // Exclaimed interjection: an interjection immediately followed by "!" (Oh!, Ah!,
1590
+ // O!, Lo!) is an emphatic peak — raise it one tier so it stands out from a flat
1591
+ // run of neighbouring function words. Only an UH whose very next token is "!".
1592
+ for (let i = 0; i < words.length - 1; i++) {
1593
+ const w = words[i];
1594
+ if (w.lexicalClass !== 'UH' || w.syllables.length === 0)
1595
+ continue;
1596
+ if (words[i + 1].word !== '!')
1597
+ continue;
1598
+ const pk = wordPeak(w);
1599
+ if (!pk)
1600
+ continue;
1601
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1602
+ if (r < STRESS_RANK.s)
1603
+ pk.relativeStress = STRESS_LEVELS[r + 1];
1604
+ }
1605
+ }
1606
+ /** Interrogative copula pronoun focus: in "Is it YOU?", "Was it HIM?", the
1607
+ * pronoun is the FOCUSED element — the question asks about identity, so the
1608
+ * complement pronoun carries the focus stress. UDPipe mis-tags this as
1609
+ * NSUBJ (it parses "it" as ROOT, "you" as nsubj of "it"), but the real
1610
+ * structure is: "it" is the dummy subject, the pronoun is the predicate
1611
+ * complement.
1612
+ *
1613
+ * Detection (very narrow, will NOT fire on the counter-examples):
1614
+ * 1. A PRP (personal pronoun, NOT PRP$ possessive) tagged NSUBJ
1615
+ * 2. Its governor is "it" (PRP, ROOT)
1616
+ * 3. "it" has an AUX dependent that is a be-form (is/are/was/were/be/been)
1617
+ * 4. The be-form is the first word of the sentence
1618
+ *
1619
+ * Counter-examples that are correctly EXCLUDED:
1620
+ * - "I'm asleep" → "I" is NSUBJ of "asleep" (not of "it") → not matched
1621
+ * - "his mouth" → "his" is PRP$ (not PRP) → not matched
1622
+ * - "our youth" → "our" is PRP$ → not matched
1623
+ * - "we're awake" → "we" governor is "awake" (not "it") → not matched
1624
+ * - "I would give you" → "you" is DOBJ (not NSUBJ) of "give" → not matched */
1625
+ function raiseInterrogativePronounFocus(words) {
1626
+ const BE_FORMS = new Set(['is', 'are', 'was', 'were', 'be', 'been', 'being',
1627
+ "'s", "'re"]);
1628
+ const real = words.filter(w => w.syllables.length > 0 && !isPunctuation(w.lexicalClass));
1629
+ if (real.length === 0)
1630
+ return;
1631
+ for (const w of real) {
1632
+ if (w.lexicalClass !== 'PRP')
1633
+ continue;
1634
+ if ((w.canonicalRel ?? '') !== 'NSUBJ')
1635
+ continue;
1636
+ const gov = w.dependency?.governor;
1637
+ if (!gov || gov === w)
1638
+ continue;
1639
+ if (gov.word.toLowerCase() !== 'it' || gov.lexicalClass !== 'PRP')
1640
+ continue;
1641
+ if ((gov.canonicalRel ?? '') !== 'ROOT')
1642
+ continue;
1643
+ // Find the be-form AUX dependent of "it" that is the first word
1644
+ const aux = real.find(x => (x.canonicalRel ?? '') === 'AUX' &&
1645
+ x.dependency?.governor === gov &&
1646
+ BE_FORMS.has(x.word.toLowerCase()));
1647
+ if (!aux)
1648
+ continue;
1649
+ // The be-form must be the first real word of the sentence (interrogative
1650
+ // verb-subject inversion)
1651
+ const firstWord = real.sort((a, b) => a.absoluteIndex - b.absoluteIndex)[0];
1652
+ if (aux !== firstWord)
1653
+ continue;
1654
+ // Found the interrogative copula construction: "Is it YOU?".
1655
+ // 1. Raise the focused pronoun to 'm' — it is the information-seeking
1656
+ // element, the strongest stress in the question.
1657
+ const pk = wordPeak(w);
1658
+ if (pk) {
1659
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1660
+ if (r < STRESS_RANK.m)
1661
+ pk.relativeStress = 'm';
1662
+ }
1663
+ // 2. Lower the dummy subject "it" to 'w' — it is an expletive, not a real
1664
+ // content word, and must not sit at 'n' creating a flat n-n-n chain with
1665
+ // the pronoun and the following adverb.
1666
+ const itPk = wordPeak(gov);
1667
+ if (itPk) {
1668
+ const r = STRESS_RANK[itPk.relativeStress ?? 'w'];
1669
+ if (r > STRESS_RANK.w)
1670
+ itPk.relativeStress = 'w';
1671
+ }
1672
+ }
1673
+ }
1674
+ /** Phrasal-verb particle stress: in a VB+RP pair ("come ON", "take OFF", "give
1675
+ * UP"), the PARTICLE bears the stress — English phonology places the phrasal
1676
+ * accent on the particle, not the verb. UDPipe confirms the relation via the
1677
+ * `compound:prt` dependency (canonicalRel='VPRT') or the RP POS tag. When the
1678
+ * verb currently outranks the particle, swap their peaks: demote the verb one
1679
+ * rung, promote the particle to the verb's former level. Only fires when they
1680
+ * are surface-adjacent (the particle immediately follows the verb). */
1681
+ function resolvePhrasalVerbParticle(words) {
1682
+ const content = words.filter(w => w.syllables.length > 0 && !isPunctuation(w.lexicalClass));
1683
+ for (let i = 0; i < content.length - 1; i++) {
1684
+ const verb = content[i];
1685
+ const particle = content[i + 1];
1686
+ if (Math.abs(verb.absoluteIndex - particle.absoluteIndex) !== 1)
1687
+ continue;
1688
+ if (!/^VB/.test(verb.lexicalClass))
1689
+ continue;
1690
+ // The partner must be a phrasal-verb particle: RP POS, or has a prt/VPRT
1691
+ // dependency on the verb, or is recovered by isPhrasalParticle.
1692
+ const isParticle = particle.lexicalClass === 'RP' ||
1693
+ (particle.canonicalRel ?? '') === 'VPRT' ||
1694
+ particle.dependency?.dependentType === 'compound:prt' ||
1695
+ (PARTICLE_LEMMAS.has(particle.word.toLowerCase()) &&
1696
+ (particle.dependency?.dependentType === 'prt' ||
1697
+ particle.dependency?.dependentType === 'advmod'));
1698
+ if (!isParticle)
1699
+ continue;
1700
+ const vPeak = wordPeak(verb);
1701
+ const pPeak = wordPeak(particle);
1702
+ if (!vPeak || !pPeak)
1703
+ continue;
1704
+ const rv = STRESS_RANK[vPeak.relativeStress ?? 'w'];
1705
+ const rp = STRESS_RANK[pPeak.relativeStress ?? 'w'];
1706
+ if (rv <= rp)
1707
+ continue; // particle already ≥ verb → nothing to do
1708
+ // Swap: particle gets the verb's level, verb drops one rung
1709
+ pPeak.relativeStress = STRESS_LEVELS[rv];
1710
+ vPeak.relativeStress = STRESS_LEVELS[Math.max(0, rv - 1)];
1711
+ }
1037
1712
  }
1038
1713
  /** Ascending rank of the 5 relative-stress tiers, for level arithmetic. */
1039
1714
  const STRESS_RANK = { x: 0, w: 1, n: 2, m: 3, s: 4 };
@@ -1082,6 +1757,11 @@ function resolveCollocationForestress(words) {
1082
1757
  const seq = words
1083
1758
  .filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0)
1084
1759
  .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
1760
+ // Track words demoted as the RIGHT element of a collocation — a word just
1761
+ // demoted (e.g. "old" in "GOOD old") must NOT be re-forestressed as the LEFT
1762
+ // element of a following collocation ("old days"), or it would be raised back
1763
+ // up and "days" would be wrongly demoted, destroying "GOOD old DAYS".
1764
+ const demoted = new Set();
1085
1765
  for (let i = 0; i < seq.length - 1; i++) {
1086
1766
  const w1 = seq[i];
1087
1767
  const w2 = seq[i + 1];
@@ -1089,6 +1769,8 @@ function resolveCollocationForestress(words) {
1089
1769
  continue; // truly adjacent
1090
1770
  if (!isLeftStressedCollocation(w1, w2))
1091
1771
  continue;
1772
+ if (demoted.has(w1))
1773
+ continue; // w1 was a prior collocation's right element — don't re-forestress
1092
1774
  const s1 = wordPeak(w1);
1093
1775
  const s2 = wordPeak(w2);
1094
1776
  if (!s1 || !s2)
@@ -1098,6 +1780,7 @@ function resolveCollocationForestress(words) {
1098
1780
  const hi = Math.max(r1, r2);
1099
1781
  s1.relativeStress = STRESS_LEVELS[hi]; // left element ≥ both
1100
1782
  s2.relativeStress = STRESS_LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote-only
1783
+ demoted.add(w2);
1101
1784
  }
1102
1785
  }
1103
1786
  /**
@@ -1133,45 +1816,119 @@ function resolveHyphenCompounds(words) {
1133
1816
  }
1134
1817
  }
1135
1818
  /**
1136
- * Resolve cardinal stress clashes on the linear surface sequence: two
1137
- * *contiguous* syllables both at the strongest level ('s') with no weaker
1138
- * syllable between them. Per McAleese/Hayes, retract the LEFT stress one level
1139
- * (s→m), with within-word-peak protection so a polysyllable's own peak is never
1140
- * demoted for an adjacent monosyllable (the monosyllable yields instead).
1819
+ * THE CLASH FILTER an absolute surface well-formedness constraint.
1820
+ *
1821
+ * On the STRESSED tier {n, m, s} no two *contiguous* syllables may carry the SAME
1822
+ * level: that is a stress clash (two equal prominences with no gradation between
1823
+ * them), which English categorically disallows. Gradient pairs (sm/ms/sn/ns/mn/nm)
1824
+ * are fine — there is still a step down — as are runs of the unstressed tiers
1825
+ * {w, x} (an unstressed sequence is tolerated, if not ideal). This generalises
1826
+ * McAleese's Appendix-A step 3d-ii ("stress clashes (ss, ms) > s-s") and Liberman
1827
+ * & Prince's (1977) grid alternation to every level of the strong tier.
1141
1828
  *
1142
- * Deliberately limited to s–s (the cardinal clash the methodology resolves
1143
- * unconditionally): m–m and lower are often metrically meaningful (spondaic /
1144
- * emphatic substitutions) and are left for the meter layer to weigh.
1829
+ * Resolution is DEMOTE-ONLY (never promote promotion is the meter layer's job,
1830
+ * McAleese Test 2), so the contour is never inflated to break a clash; we iterate
1831
+ * to a fixed point (each change strictly lowers total stress mass, so it
1832
+ * terminates). Which member yields is decided by `demoteRightOfClash` (grid-based
1833
+ * relative prominence). Runs on the LINEAR surface order because a clash is a
1834
+ * property of contiguous *pronounced* syllables (Hayes), which a mis-grouped
1835
+ * dependency parse can scatter across phrases.
1145
1836
  */
1146
- function resolveLinearClashes(words) {
1837
+ export function resolveLinearClashes(words) {
1147
1838
  const flat = [];
1148
1839
  for (const w of words)
1149
1840
  for (const s of w.syllables)
1150
1841
  flat.push({ word: w, syl: s });
1151
- for (let i = 0; i < flat.length - 1; i++) {
1152
- const a = flat[i];
1153
- const b = flat[i + 1];
1154
- if (a.syl.relativeStress !== 's' || b.syl.relativeStress !== 's')
1155
- continue;
1156
- if (a.word === b.word)
1157
- continue; // intra-word clashes silent beat downstream
1158
- const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
1159
- const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
1160
- // Protect a polysyllable's peak: if only the left is a peak and the right is
1161
- // a lone monosyllable, retract the monosyllable instead.
1162
- if (aPeak && !bPeak && b.word.syllables.length === 1) {
1163
- demoteOneLevel(b.syl);
1164
- }
1165
- else {
1166
- demoteOneLevel(a.syl); // Hayes: retract the left stress
1842
+ let changed = true;
1843
+ let guard = 0;
1844
+ while (changed && guard++ < 24) {
1845
+ changed = false;
1846
+ for (let i = 0; i < flat.length - 1; i++) {
1847
+ const a = flat[i];
1848
+ const b = flat[i + 1];
1849
+ const lvl = a.syl.relativeStress;
1850
+ if (lvl !== b.syl.relativeStress)
1851
+ continue;
1852
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's')
1853
+ continue; // only the stressed tier clashes
1854
+ // Intra-word clashes are NOT skipped: a hyphenated-compound seam
1855
+ // (deep·voiced, snow·storm, gate·bolts) or any word with two adjacent equal
1856
+ // stresses is still a clash, and must be made gradient like any other.
1857
+ if (demoteRightOfClash(flat, i))
1858
+ demoteOneLevel(b.syl);
1859
+ else
1860
+ demoteOneLevel(a.syl);
1861
+ changed = true;
1167
1862
  }
1168
1863
  }
1169
1864
  }
1865
+ /**
1866
+ * Decide which member of an equal-stress clash at (i, i+1) yields. Rather than a
1867
+ * blunt leftward retraction, a layered cascade consults — in descending
1868
+ * authority — lexical, syntactic, and phonological context, so the demotion is
1869
+ * theory-grounded and the arbitrary default fires only as a last resort. Returns
1870
+ * true to demote the RIGHT member (i+1), false to demote the LEFT (i).
1871
+ *
1872
+ * 1. Lexical integrity (Kiparsky): never demote a polysyllable's own stress
1873
+ * peak for an adjacent monosyllable — the monosyllable yields.
1874
+ * 2. Inherent lexical prominence: a primary-stressed syllable outranks a
1875
+ * secondary one — the lower-lexical-stress member yields.
1876
+ * 3. Syntactic headedness (Nuclear Stress Rule; Liberman & Prince 1977; Cinque
1877
+ * 1993): when the two words stand in a direct head–dependent relation, the
1878
+ * DEPENDENT yields to its governor (the phrasal head is the more prominent).
1879
+ * 4. Content/function asymmetry — a coarse proxy for headedness when no direct
1880
+ * dependency links the pair: the function word yields.
1881
+ * 5. Phonological weight: a light (open, short-vowel) syllable reduces more
1882
+ * readily than a heavy (closed / long-vowel) one — the lighter member yields.
1883
+ * 6. Rhythm Rule (Liberman & Prince 1977; Hayes 1984): the clash member abutting
1884
+ * the stronger OUTER beat is that beat's off-beat, so it retracts — "wet
1885
+ * CHURCH" retracts wet (church is nuclear), "LATE last NIGHT" retracts last
1886
+ * onto late (night is the following beat).
1887
+ * 7. Default (Hayes): retract the LEFT stress.
1888
+ */
1889
+ function demoteRightOfClash(flat, i) {
1890
+ const a = flat[i];
1891
+ const b = flat[i + 1];
1892
+ // (1) lexical integrity
1893
+ const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
1894
+ const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
1895
+ if (aPeak && !bPeak && b.word.syllables.length === 1)
1896
+ return true;
1897
+ if (bPeak && !aPeak && a.word.syllables.length === 1)
1898
+ return false;
1899
+ // (2) inherent lexical prominence
1900
+ const la = a.syl.lexicalStress ?? a.syl.stress ?? 0;
1901
+ const lb = b.syl.lexicalStress ?? b.syl.stress ?? 0;
1902
+ if (la !== lb)
1903
+ return la > lb;
1904
+ // (3) syntactic headedness — the dependent yields to its governor
1905
+ if (a.word !== b.word) {
1906
+ const gov = getGovernor(a.word, b.word);
1907
+ if (gov === a.word)
1908
+ return true; // a heads b → demote b
1909
+ if (gov === b.word)
1910
+ return false; // b heads a → demote a
1911
+ }
1912
+ // (4) content/function asymmetry — the function word yields
1913
+ if (a.word.isContent !== b.word.isContent)
1914
+ return a.word.isContent;
1915
+ // (5) phonological weight — the lighter syllable yields
1916
+ if (a.syl.weight && b.syl.weight && a.syl.weight !== b.syl.weight) {
1917
+ return a.syl.weight === 'H'; // a heavy → demote the lighter b
1918
+ }
1919
+ // (6) Rhythm Rule — yield to the stronger adjacent (outer) beat
1920
+ const outerA = i - 1 >= 0 ? STRESS_RANK[flat[i - 1].syl.relativeStress ?? 'w'] : -1;
1921
+ const outerB = i + 2 < flat.length ? STRESS_RANK[flat[i + 2].syl.relativeStress ?? 'w'] : -1;
1922
+ if (outerA !== outerB)
1923
+ return outerB > outerA;
1924
+ // (7) default: retract the left
1925
+ return false;
1926
+ }
1170
1927
  /**
1171
1928
  * Scan across the linear sequence of syllables and adjust any adjacent
1172
1929
  * identical relative stress levels using syntactic governance.
1173
1930
  */
1174
- function resolveAdjacentClashes(words) {
1931
+ function resolveAdjacentClashes(words, protect) {
1175
1932
  // "Endings strict": when a phrase ends in a run of two or more bare function
1176
1933
  // words (e.g. "…fast as you MIGHT"), the metrical beat gravitates to one of
1177
1934
  // them; the others are upbeat. Demote the others so a leftward governance
@@ -1199,6 +1956,9 @@ function resolveAdjacentClashes(words) {
1199
1956
  // internal stress (be·HIND) to 'w' just because the word is functional.
1200
1957
  if (w.syllables.length > 1 && s === peak && (s.lexicalStress ?? s.stress) >= 1)
1201
1958
  continue;
1959
+ // Protect a dependency-mined prominence (stranded preposition etc.).
1960
+ if (protect && protect.has(s))
1961
+ continue;
1202
1962
  s.relativeStress = 'w';
1203
1963
  }
1204
1964
  }
@@ -1216,6 +1976,11 @@ function resolveAdjacentClashes(words) {
1216
1976
  const b = flat[i + 1];
1217
1977
  if (a.syl.relativeStress !== b.syl.relativeStress)
1218
1978
  continue;
1979
+ // Only the stressed tier {n,m,s} clashes; {w,x} may repeat (maintainer's rule:
1980
+ // an unstressed run is tolerated, never "resolved" by demoting a clitic to 'x').
1981
+ const lvl = a.syl.relativeStress;
1982
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's')
1983
+ continue;
1219
1984
  // Within-word strictness (Kiparsky): a polysyllabic word's own stress peak
1220
1985
  // must not be demoted below its word-mates by a clash with an adjacent
1221
1986
  // monosyllable. Protect the peak; demote the monosyllable instead.
@@ -1333,6 +2098,10 @@ function resolveCrossBoundaryClashes(words, ius) {
1333
2098
  const b = flat[i + 1];
1334
2099
  if (a.syl.relativeStress !== b.syl.relativeStress)
1335
2100
  continue;
2101
+ // Only the stressed tier {n,m,s} clashes; {w,x} may repeat (maintainer's rule).
2102
+ const lvl = a.syl.relativeStress;
2103
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's')
2104
+ continue;
1336
2105
  // Only adjust if they span a prosodic boundary
1337
2106
  if (a.ppKey === b.ppKey)
1338
2107
  continue;
@@ -1345,7 +2114,11 @@ function resolveCrossBoundaryClashes(words, ius) {
1345
2114
  demoteOneLevel(a.syl);
1346
2115
  }
1347
2116
  else {
1348
- // Both same content/function type — try dependency relationship
2117
+ // Both same content/function type — try dependency relationship.
2118
+ // (A blanket Selkirk "demote the PP-initial" was tried and regressed
2119
+ // litlab/prosodic: the linear-clash cascade's nuanced resolution — weight,
2120
+ // Rhythm Rule, governance — captures "beginnings loose" better than a flat
2121
+ // directional rule, so a clash with no governance is left for it.)
1349
2122
  const governor = getGovernor(a.word, b.word);
1350
2123
  if (governor === a.word) {
1351
2124
  adjustAdjacent(a.syl, b.syl, governorDependentDirection);