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/src/stress.ts CHANGED
@@ -6,6 +6,7 @@ import * as nounsing from 'nounsing-pro';
6
6
  import { ClsWord, Syllable, StressLevel, IntonationalUnit, PhonologicalPhrase } from './types.js';
7
7
  import { isPunctuation } from './parser.js';
8
8
  import { collectPPTokens, syllabifyWord } from './phonological.js';
9
+ import { isStrandedPreposition, isContrastivePossessive, isVocative, isDeicticLocative } from './semantics.js';
9
10
 
10
11
  // ─── CONSTANTS & CLASSIFICATIONS ──────────────────────────────────
11
12
 
@@ -127,6 +128,24 @@ const FUNCTION_ADVERBS = new Set([
127
128
  'today', 'tomorrow', 'yesterday', 'tonight',
128
129
  ]);
129
130
 
131
+ /**
132
+ * Rising (iambic) disyllabic function words — prepositions, conjunctions, and
133
+ * deictic adverbs that stress the SECOND syllable (be·CAUSE, a·BOUT, be·TWEEN).
134
+ * A handful are recorded fully-reduced ("00") in the augmented dictionary; for
135
+ * those the all-zero re-stamp must fix the FINAL syllable, NOT take the
136
+ * disyllabic forestress default (which mis-read be·CAUSE as BE·cause). The
137
+ * re-stamp only fires on a genuine all-zero entry, so listing a word that
138
+ * already carries a peak is harmless. Trochaic IN·to / UN·to / ON·to are
139
+ * deliberately excluded — they correctly keep forestress.
140
+ */
141
+ const RISING_FUNCTION_WORDS = new Set([
142
+ 'because', 'about', 'above', 'around', 'across', 'along', 'among', 'amongst',
143
+ 'against', 'amid', 'amidst', 'apart', 'ahead', 'aside', 'away', 'aloft',
144
+ 'alone', 'aloud', 'anew', 'awhile', 'ago',
145
+ 'before', 'behind', 'below', 'beneath', 'beside', 'besides', 'between',
146
+ 'beyond', 'within', 'without',
147
+ ]);
148
+
130
149
  /**
131
150
  * Oblique (object/dative) pronouns. In clause-final position these are
132
151
  * canonically unstressed and do NOT attract the beat ("…and beHIND me", not
@@ -214,6 +233,68 @@ const FUNCTION_VERBS = new Set([
214
233
  'let', 'lets', "let's"
215
234
  ]);
216
235
 
236
+ /** The copula / auxiliary forms of BE — they reduce in connected speech whether
237
+ * used as copula ("she IS fair" → reduced) or auxiliary ("is going"). */
238
+ const BE_FORMS = new Set(['be', 'am', 'is', 'are', 'was', 'were', 'being']);
239
+
240
+ /**
241
+ * Exclamatory / vocative interjections. These are emphatic, expressive elements —
242
+ * never zero-provision clitics — yet FinNLP routinely mis-tags vocative "O" as a
243
+ * preposition (IN) and "Lo" as a proper noun (NNP), which would otherwise floor
244
+ * them to 'x'. Keyed off the lemma (not the unreliable tag), they are raised to at
245
+ * least 'n'; an exclaimed one ("O!", "Oh!") is lifted a further tier by the
246
+ * interjection-emphasis pass at the end of assignRelativeStresses. Apostrophe
247
+ * forms (o'er = over, e'er = ever) are NOT here — they are aphaeresis clitics.
248
+ */
249
+ const EXCLAM_INTERJECTIONS = new Set([
250
+ 'o', 'oh', 'ah', 'ay', 'aye', 'lo', 'alas', 'alack', 'hark', 'fie', 'woe',
251
+ 'ho', 'oho', 'aha', 'ahoy', 'hurrah', 'huzza',
252
+ ]);
253
+
254
+ /**
255
+ * Honest baseline relative prominence for a FUNCTION word (McAleese step 1b:
256
+ * "w=weak, n=some stress, m=subordinate strong, s=strong"). A monosyllabic
257
+ * function word bears no lexical *some-stress*; the dictionary's citation stress
258
+ * (which marks "and"/"in"/"my"/"could" as primary) is a CITATION artefact, not
259
+ * connected-speech prominence. We floor it to its true reading prominence — the
260
+ * meter layer still PROMOTES it to a beat where the metre calls for it (McAleese's
261
+ * Appendix-A Test 2: stressless "than"/"in" stay weak in the contour; the metre
262
+ * lifts only "from", which carries latent stress). Returns the tier to floor to,
263
+ * or null to leave the word untouched.
264
+ *
265
+ * 'x' (zero-provision) — pure schwa-proclitics that fully reduce: coordinators
266
+ * (CC: and/or/but/nor), monosyllabic prepositions & subordinators (IN), the
267
+ * infinitival/prepositional "to" (TO), possessive determiners (PRP$/WP$:
268
+ * my/your/his/her/its/our/their/whose).
269
+ * 'w' (overt-weak, still meter-promotable) — full-vowel function words: personal
270
+ * pronouns (PRP), modals (MD), existential "there" (EX), the copula/auxiliary
271
+ * BE forms, the AUXILIARY uses of have/do (by dependency role — a main-verb
272
+ * "have"/"did" keeps its beat), and reduced deictic/discourse adverbs.
273
+ *
274
+ * Content words and the internal contour of POLYSYLLABIC function words
275
+ * (be·NEATH = x·n, with·OUT) are returned null (untouched).
276
+ */
277
+ function relativeFloorFor(word: ClsWord): StressLevel | null {
278
+ if (word.isContent) return null;
279
+ if (word.syllables.length !== 1) return null; // keep polysyllabic function contour
280
+ const pos = word.lexicalClass;
281
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
282
+
283
+ // Pure schwa-proclitics → zero-provision 'x'.
284
+ if (pos === 'CC' || pos === 'PRP$' || pos === 'WP$' || pos === 'TO') return 'x';
285
+ if (pos === 'IN') return 'x'; // prepositions / subordinators cliticise
286
+
287
+ // Full-vowel weak function words → overt-weak 'w' (meter-promotable).
288
+ if (pos === 'PRP' || pos === 'MD' || pos === 'EX') return 'w';
289
+ if (BE_FORMS.has(lemma)) return 'w'; // copula & auxiliary BE both reduce
290
+ // NB: have/do are deliberately NOT floored — unlike BE they routinely bear a
291
+ // beat (main-verb "have"/"do", emphatic "DID"), and even as auxiliaries they
292
+ // carry an iambic beat often enough ("what HAD I given") that flooring them
293
+ // mis-reads such lines. Their level is left to the clash filter + meter layer.
294
+ if (FUNCTION_ADVERBS.has(lemma)) return 'w'; // so/then/here/there/when/just…
295
+ return null;
296
+ }
297
+
217
298
  /** Left‑stressed compound categories with example first‑word lists. */
218
299
  const LEFT_STRESS_MATERIAL = new Set([
219
300
  'metal', 'wood', 'silk', 'cotton', 'glass', 'stone', 'iron', 'steel',
@@ -270,7 +351,7 @@ const RIGHT_STRESS_HEADS = new Set([
270
351
  ]);
271
352
 
272
353
  /** Check if a pair of words forms a left‑stressed compound. */
273
- function isLeftStressedPair(w1: string, w2: string): boolean {
354
+ export function isLeftStressedPair(w1: string, w2: string): boolean {
274
355
  const first = w1.toLowerCase();
275
356
  const second = w2.toLowerCase().replace(/'s$/, '');
276
357
  // A right-stressing head overrides any forestress modifier (apple PIE).
@@ -287,6 +368,46 @@ function isLeftStressedPair(w1: string, w2: string): boolean {
287
368
  return false;
288
369
  }
289
370
 
371
+ /** True if `w2` is a head noun that keeps phrasal/right stress against an N1
372
+ * modifier (apple PIE, summer DAY, Fifth AVenue) — the marked right-stress
373
+ * exceptions to the otherwise fore-stressing Compound Stress Rule. */
374
+ export function isRightStressedHead(w2: string): boolean {
375
+ return RIGHT_STRESS_HEADS.has(w2.toLowerCase().replace(/'s$/, ''));
376
+ }
377
+
378
+ /**
379
+ * Direction of primary stress for an adjacent two-word modification structure,
380
+ * the SINGLE source of truth shared by the lexical compound pass
381
+ * (`applyCompoundStress`) and the Phrase-Stress phase (`computePhraseStress`),
382
+ * so the two layers cannot disagree.
383
+ *
384
+ * 'left' = fore-stress, primary on w1 — the Compound Stress Rule default for
385
+ * an N+N compound (Chomsky–Halle; McAleese's worked example marks
386
+ * ICE cream with primary on "ice", not "cream"): KITCHen table,
387
+ * WINdow frame, BEDroom door, plus the curated LEFT_STRESS_* sets.
388
+ * 'right' = end-stress, primary on w2 — the marked exceptions: food/temporal
389
+ * "made of N1" heads (apple PIE, summer DAY), Adj+N which is phrasal
390
+ * not compound (sweet CREAM, red CAR), and proper-name sequences
391
+ * which carry their own right-headed prosody (New YORK, John SMITH).
392
+ * null = not a compound/modification pair at all.
393
+ *
394
+ * The fore-stress default is restricted to COMMON-noun N+N: proper-noun pairs
395
+ * (NNP/NNPS) are excluded because place- and personal-name sequences are not
396
+ * reliably fore-stressed, and flipping them would mis-teach New YORK / John SMITH.
397
+ */
398
+ export function compoundStressSide(
399
+ w1: string, pos1: string, w2: string, pos2: string,
400
+ ): 'left' | 'right' | null {
401
+ const isNN = pos1.startsWith('N') && pos2.startsWith('N');
402
+ const isAdjN = pos1.startsWith('J') && pos2.startsWith('N');
403
+ if (!isNN && !isAdjN) return null;
404
+ if (isLeftStressedPair(w1, w2)) return 'left'; // curated fore-stress modifier
405
+ if (isRightStressedHead(w2)) return 'right'; // apple PIE, Fifth AVenue
406
+ const proper = (p: string) => p === 'NNP' || p === 'NNPS';
407
+ if (isNN && !proper(pos1) && !proper(pos2)) return 'left'; // common-N+N compound default
408
+ return 'right'; // Adj+N phrasal / proper-name pair
409
+ }
410
+
290
411
  /**
291
412
  * Lexicalised forestress COLLOCATIONS — fixed two-word phrases that stress the
292
413
  * LEFT element, even though the second word is not a noun (so the N+N/J+N
@@ -298,6 +419,7 @@ function isLeftStressedPair(w1: string, w2: string): boolean {
298
419
  const LEFT_STRESS_COLLOCATIONS: { w1: string; w2: string; ok?: (b: ClsWord) => boolean }[] = [
299
420
  { w1: 'good', w2: 'old' }, // GOOD old days
300
421
  { w1: 'end', w2: 'all', ok: w => w.lexicalClass !== 'PDT' }, // END-all (idiom), not "end ALL the wars"
422
+ { w1: 'old', w2: 'days' }, // the OLD days (fixed expression: "days" is semantically light)
301
423
  ];
302
424
 
303
425
  /** True if (w1,w2) is a lexicalised forestress collocation in this context. */
@@ -319,7 +441,7 @@ const VOWEL_CHARS = new Set('aeiouyAEIOUY');
319
441
  * 'e' as a nucleus and over-counts. Count the parts instead. */
320
442
  const SILENT_E_COMPOUND_RE = /^(where|there|here)(fore|in|by|of|on|upon|at|to|with|out|after|under|unto|abouts?|soever)$/;
321
443
 
322
- function countVowelGroups(word: string): number {
444
+ function countVowelGroups(word: string, keepDisyllabicFinalE = false): number {
323
445
  {
324
446
  const m = word.toLowerCase().replace(/[^a-z]/g, '').match(SILENT_E_COMPOUND_RE);
325
447
  // Closed-class second elements; counted directly ("fore" would otherwise
@@ -339,9 +461,20 @@ function countVowelGroups(word: string): number {
339
461
  inVowel = false;
340
462
  }
341
463
  }
342
- if (groups >= 3 && n > 2 && lower[n - 1] === 'e' && VOWEL_CHARS.has(lower[n - 1])) {
464
+ // Final orthographic silent-e ("magic e"): a word-final 'e' that opens its own
465
+ // vowel group is silent in the default modern reading. Original rule fired only
466
+ // at 3+ groups; extended (2026-07-02) to DISYLLABIC shapes — archaic verse
467
+ // spellings ("seke", "fote", "nowe", "raunge", "chaunge") read as their modern
468
+ // monosyllables (seek, foot, now, range, change), which restored Wyatt's 4-beat
469
+ // accentual profile. Exclusions for the disyllabic case only:
470
+ // • -Cle/-Cre codas (obstruent+liquid+e): the liquid is syllabic and the 'e'
471
+ // is its written nucleus (table, sabre) — no deduction;
472
+ // • proper nouns (keepDisyllabicFinalE): foreign names pronounce it (Dante).
473
+ if (groups >= 2 && n > 2 && lower[n - 1] === 'e' && VOWEL_CHARS.has(lower[n - 1])) {
343
474
  const lastVowelStart = vowelPositions[vowelPositions.length - 1];
344
- if (lastVowelStart === n - 1) {
475
+ const syllabicLiquid = /[^aeiouy][lr]e$/.test(lower);
476
+ const allow = groups >= 3 || (!syllabicLiquid && !keepDisyllabicFinalE);
477
+ if (lastVowelStart === n - 1 && allow) {
345
478
  groups--;
346
479
  }
347
480
  }
@@ -408,7 +541,7 @@ const SUFFIX_RULES: { suffix: string; stems: (b: string) => string[]; added: (st
408
541
  * by stripping a stress-neutral suffix and reusing the in-lexicon stem's stress.
409
542
  * Returns null if no productive suffix yields a known stem.
410
543
  */
411
- function morphologicalStress(w: string): { pattern: number[]; suffix: string } | null {
544
+ function morphologicalStress(w: string): { pattern: number[]; suffix: string; prefix?: string } | null {
412
545
  for (const rule of SUFFIX_RULES) {
413
546
  if (!w.endsWith(rule.suffix)) continue;
414
547
  const base = w.slice(0, w.length - rule.suffix.length);
@@ -424,9 +557,194 @@ function morphologicalStress(w: string): { pattern: number[]; suffix: string } |
424
557
  return { pattern: [...stemNumeric, ...new Array(added).fill(0)], suffix: added >= 1 ? rule.suffix : '' };
425
558
  }
426
559
  }
560
+ // Tier 1b: PREFIX decomposition (Wagner §6.5.2 — a prefix forms its OWN prosodic
561
+ // domain; the STEM keeps its primary stress). Only fires on a genuinely OOV word
562
+ // (no suffix decomposition found and the bare word isn't in the lexicon — checked
563
+ // by the caller), and only when the prefix-stripped remainder IS a known stem, so
564
+ // it can only EXTEND coverage, never alter an in-lexicon word. Heavy separable
565
+ // prefixes (over-/under-/out-/anti-…) bear secondary stress; light ones
566
+ // (un-/re-/dis-…) are unstressed before the stem's primary.
567
+ const pre = prefixStress(w);
568
+ if (pre) return pre;
427
569
  return null;
428
570
  }
429
571
 
572
+ /** Productive prefixes: syllable count + whether the prefix's first syllable bears
573
+ * a SECONDARY stress (separable/heavy) or is unstressed (light Latinate). */
574
+ const PREFIX_RULES: { prefix: string; sylls: number; sec: boolean }[] = [
575
+ { prefix: 'counter', sylls: 2, sec: true },
576
+ { prefix: 'over', sylls: 2, sec: true }, { prefix: 'under', sylls: 2, sec: true },
577
+ { prefix: 'inter', sylls: 2, sec: true }, { prefix: 'super', sylls: 2, sec: true },
578
+ { prefix: 'anti', sylls: 2, sec: true }, { prefix: 'semi', sylls: 2, sec: true },
579
+ { prefix: 'multi', sylls: 2, sec: true }, { prefix: 'ultra', sylls: 2, sec: true },
580
+ { prefix: 'fore', sylls: 1, sec: true }, { prefix: 'out', sylls: 1, sec: true },
581
+ { prefix: 'non', sylls: 1, sec: true }, { prefix: 'self', sylls: 1, sec: true },
582
+ { prefix: 'un', sylls: 1, sec: false }, { prefix: 're', sylls: 1, sec: false },
583
+ { prefix: 'pre', sylls: 1, sec: false }, { prefix: 'dis', sylls: 1, sec: false },
584
+ { prefix: 'mis', sylls: 1, sec: false }, { prefix: 'de', sylls: 1, sec: false },
585
+ ];
586
+
587
+ function prefixStress(w: string): { pattern: number[]; suffix: string; prefix?: string } | null {
588
+ // longest prefix first (counter- before -); guard against tiny stems.
589
+ for (const rule of [...PREFIX_RULES].sort((a, b) => b.prefix.length - a.prefix.length)) {
590
+ if (!w.startsWith(rule.prefix)) continue;
591
+ const stem = w.slice(rule.prefix.length);
592
+ if (stem.length < 3) continue; // need a real stem (re+do too short)
593
+ const data = nounsing.all(stem);
594
+ const raw = data && data.length ? (data[0].stress?.stressTrans || '') : '';
595
+ if (!raw) continue;
596
+ const stemNumeric = [...raw].map(c => mapCMUStress(parseInt(c, 10)));
597
+ if (stemNumeric.length === 0 || !stemNumeric.some(n => n >= 2)) continue; // stem must carry a primary
598
+ const head = rule.sec ? 1 : 0;
599
+ const preNumeric = [head, ...new Array(Math.max(0, rule.sylls - 1)).fill(0)];
600
+ return { pattern: [...preNumeric, ...stemNumeric], suffix: '', prefix: rule.prefix };
601
+ }
602
+ return null;
603
+ }
604
+
605
+ /** Display-only prefix detection for ALL words (in-vocab AND OOV).
606
+ *
607
+ * The OOV-only `morphologicalStress` prefix path (Tier 1b) only fires when no
608
+ * suffix decomposition is found — so "disillusions" (OOV, suffix "-s" → stem
609
+ * "disillusion" IN-VOCAB) returns before the prefix path runs, and
610
+ * "uneducated" (IN-VOCAB) never hits the OOV branch at all. Neither gets
611
+ * `morphPrefix` set, and the display syllabifier's Maximal Onset principle
612
+ * pulls the prefix's final consonant into the next syllable (di·sil·lu·sions,
613
+ * u·ne·du·ca·ted).
614
+ *
615
+ * This pass runs AFTER `assignLexicalStress` for every word: if it starts with
616
+ * a known productive prefix (from `PREFIX_RULES`) and the stripped stem is in
617
+ * the nounsing-pro dictionary, set `word.morphPrefix`. The syllabifier then
618
+ * peels the prefix as the first syllable(s) and syllabifies the stem
619
+ * separately, respecting the morpheme boundary (dis·il·lu·sions,
620
+ * un·ed·u·ca·ted). DISPLAY-ONLY — never changes stress or meter.
621
+ *
622
+ * Guards: the stem must carry a primary stress (so we don't peel a prefix off
623
+ * a function word like "into"), and the prefix must be at least 2 characters
624
+ * (to avoid false positives on short words). */
625
+ export function detectDisplayPrefixes(words: ClsWord[]): void {
626
+ for (const word of words) {
627
+ if (word.morphPrefix) continue; // already set by OOV path
628
+ if (word.syllables.length < 2) continue; // monosyllables: no boundary to place
629
+ const clean = word.word.toLowerCase().replace(/-/g, '').replace(/[''']/g, '');
630
+ if (clean.length < 5) continue; // too short to have prefix + real stem
631
+ for (const rule of [...PREFIX_RULES].sort((a, b) => b.prefix.length - a.prefix.length)) {
632
+ if (!clean.startsWith(rule.prefix)) continue;
633
+ const stem = clean.slice(rule.prefix.length);
634
+ if (stem.length < 3) continue; // need a real stem
635
+ const data = nounsing.all(stem);
636
+ if (!data || data.length === 0) continue;
637
+ const raw = data[0].stress?.stressTrans || '';
638
+ if (!raw || !raw.split('').some(c => c === '1' || c === '2')) continue; // stem must carry stress
639
+ word.morphPrefix = rule.prefix;
640
+ break;
641
+ }
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Stress Shift — swap primary↔secondary when Nounsing-Pro confirms the word
647
+ * CAN shift (`suffixShiftPotential` returns `shiftLikely: true`) and the
648
+ * phonological context motivates it. This is NOT a global rule — it is
649
+ * grounded in the dictionary's own shift-likelihood assessment plus a
650
+ * syntactic/phonological context gate.
651
+ *
652
+ * Conditions (ALL must hold):
653
+ * 1. `suffixShiftPotential(word)` returns `shiftLikely: true`
654
+ * 2. The word's `stressTrans` has both `1` (primary) and `2` (secondary)
655
+ * 3. The LAST digit of `stressTrans` is `2` — secondary is on the final
656
+ * syllable, so swapping 1↔2 moves primary TO the final
657
+ * 4. Context gate (either):
658
+ * a. The word is a VB at the start of a phonological phrase (imperative
659
+ * at phrase start — "REcognize" → "recogNIZE")
660
+ * b. Rhythm Rule clash: a stressed syllable follows within 2 syllables
661
+ * (the shift avoids a clash with the following stress)
662
+ *
663
+ * Words like "realize" (shiftLikely=false) are correctly EXCLUDED —
664
+ * Nounsing-Pro says they cannot shift, and the code respects that.
665
+ *
666
+ * Effect: swap the `lexicalStress` values of the primary (2) and secondary (1)
667
+ * syllables, so `peakSyllable` and all downstream computation see the shifted
668
+ * peak. The syllables' `stress` (raw CMU) values are also swapped for
669
+ * consistency.
670
+ */
671
+ export function applyStressShift(words: ClsWord[], ius: IntonationalUnit[]): void {
672
+ // Build a set of words that are at the start of a phonological phrase
673
+ const ppInitials = new Set<ClsWord>();
674
+ for (const iu of ius) {
675
+ for (const pp of iu.phonologicalPhrases) {
676
+ const toks = collectPPTokens(pp)
677
+ .filter(w => w.syllables.length > 0)
678
+ .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
679
+ if (toks.length > 0) ppInitials.add(toks[0]);
680
+ }
681
+ }
682
+
683
+ const stressed = words.filter(w => w.syllables.length > 0 && !isPunctuation(w.lexicalClass));
684
+ const flat: { word: ClsWord; syl: Syllable }[] = [];
685
+ for (const w of [...stressed].sort((a, b) => a.absoluteIndex - b.absoluteIndex))
686
+ for (const s of w.syllables) flat.push({ word: w, syl: s });
687
+
688
+ for (const word of stressed) {
689
+ if (word.syllables.length < 3) continue; // need 3+ syllables for a meaningful shift
690
+ const data = nounsing.all(word.word.toLowerCase().replace(/[''']/g, ''));
691
+ if (!data || data.length === 0) continue;
692
+ const st = data[0].stress?.stressTrans ?? '';
693
+ if (!st) continue;
694
+
695
+ // Condition 2: has both primary (1) and secondary (2)
696
+ if (!st.includes('1') || !st.includes('2')) continue;
697
+ // Condition 3: the secondary (2) is at a HIGHER index than the primary
698
+ // (1) in the stressTrans string — the swap moves primary RIGHTWARD (toward
699
+ // the end of the word). This covers both "recognize" (102: 1→0, 2→2) and
700
+ // "dictating" (120: 1→0, 2→1). Words like "understand" (201: primary
701
+ // already on final) are correctly excluded — the swap would move primary
702
+ // leftward, which is wrong.
703
+ const idx1 = st.indexOf('1');
704
+ const idx2 = st.indexOf('2');
705
+ if (idx2 <= idx1) continue;
706
+
707
+ // Condition 1: shiftLikely from Nounsing-Pro
708
+ let shiftLikely = false;
709
+ try {
710
+ const shift = nounsing.suffixShiftPotential(word.word.toLowerCase().replace(/[''']/g, ''));
711
+ if (shift && shift.length > 0) shiftLikely = !!shift[0].shiftLikely;
712
+ } catch { /* graceful no-op */ }
713
+ if (!shiftLikely) continue;
714
+
715
+ // Condition 4: context gate — any verb form (VB*). The stress shift is a
716
+ // well-known phonological process for English verbs (Rhythm Rule, imperative
717
+ // emphasis, compound stress). Nounsing-Pro's shiftLikely already encodes
718
+ // the phonological possibility; restricting to verb forms excludes nouns
719
+ // like "potato" (NN, shiftLikely=true) that should NOT shift. No PP-initial
720
+ // requirement — the shift applies to verb forms in any position, matching
721
+ // how English speakers actually shift stress in running speech.
722
+ if (!/^VB/.test(word.lexicalClass)) continue;
723
+
724
+ // Execute the shift: swap lexicalStress (and stress) of the primary(2→1)
725
+ // and secondary(1→2) syllables. In our internal mapping:
726
+ // CMU/nounsing 1=primary → lexicalStress 2
727
+ // CMU/nounsing 2=secondary → lexicalStress 1
728
+ // CMU/nounsing 0=unstressed → lexicalStress 0
729
+ // So we swap the syllable with lexicalStress===2 and the one with ===1.
730
+ let primarySyl: Syllable | null = null;
731
+ let secondarySyl: Syllable | null = null;
732
+ for (const s of word.syllables) {
733
+ if ((s.lexicalStress ?? s.stress) === 2) primarySyl = s;
734
+ if ((s.lexicalStress ?? s.stress) === 1) secondarySyl = s;
735
+ }
736
+ if (!primarySyl || !secondarySyl) continue;
737
+ // Swap lexicalStress
738
+ const tmpLex = primarySyl.lexicalStress;
739
+ primarySyl.lexicalStress = secondarySyl.lexicalStress;
740
+ secondarySyl.lexicalStress = tmpLex;
741
+ // Swap raw stress too (for consistency with downstream that reads s.stress)
742
+ const tmpStress = primarySyl.stress;
743
+ primarySyl.stress = secondarySyl.stress;
744
+ secondarySyl.stress = tmpStress;
745
+ }
746
+ }
747
+
430
748
  /** Archaic verbal suffixes whose orthographic peel cleanly separates a silent-
431
749
  * consonant stem from the suffix for DISPLAY (know·est not kno·west). Other
432
750
  * suffixes keep the default orthographic syllabifier (it handles them well). */
@@ -473,6 +791,10 @@ const PRESTRESS_SUFFIXES: { suffix: string; offset: number }[] = [
473
791
  // data-derived 2026-06-10: final-stressing (ultShift)
474
792
  { suffix: 'ette', offset: 1 }, { suffix: 'ese', offset: 1 },
475
793
  { suffix: 'eer', offset: 1 }, { suffix: 'ique', offset: 1 },
794
+ // -oon is a reliable final-stresser (bal·LOON, car·TOON, co·COON, after·NOON);
795
+ // OOV-only. -ee/-ade are deliberately NOT added: they are impure (COF·fee,
796
+ // com·RADE, DEC·ade) and would mis-stress more than they fix.
797
+ { suffix: 'oon', offset: 1 },
476
798
  // data-derived: penult-stressing
477
799
  { suffix: 'ion', offset: 2 }, { suffix: 'sive', offset: 2 },
478
800
  { suffix: 'lla', offset: 2 }, { suffix: 'llo', offset: 2 },
@@ -499,8 +821,8 @@ const PRESTRESS_SUFFIXES: { suffix: string; offset: number }[] = [
499
821
  * falls on a heavy penult, else the antepenult (Hayes 1982). This fixes e.g.
500
822
  * an·FRAC·tuous / e·NIG·ma where blind forestress erred.
501
823
  */
502
- function englishStressRule(w: string, isContent: boolean): number[] {
503
- const n = countVowelGroups(w);
824
+ function englishStressRule(w: string, isContent: boolean, properNoun = false): number[] {
825
+ const n = countVowelGroups(w, properNoun);
504
826
  const primary = isContent ? 2 : 1;
505
827
  if (n <= 1) return [primary];
506
828
  for (const { suffix, offset } of PRESTRESS_SUFFIXES) {
@@ -516,9 +838,35 @@ function englishStressRule(w: string, isContent: boolean): number[] {
516
838
  const penult = n - 2; // final (n-1) is extrametrical
517
839
  const heavyPenult = sylls[penult] ? syllableIsHeavy(sylls[penult]) : true;
518
840
  pattern[heavyPenult ? penult : Math.max(0, n - 3)] = primary;
841
+ // A CONTENT polysyllable does not stay flat after its one primary — give it the
842
+ // rhythmic secondaries English requires (so an OOV 4+-syllable word reads with a
843
+ // real 'n'-tier beat, not a long unstressed tail).
844
+ if (isContent) {
845
+ const heavy = sylls.map(s => (s ? syllableIsHeavy(s) : false));
846
+ addSecondaryStresses(pattern, heavy);
847
+ }
519
848
  return pattern;
520
849
  }
521
850
 
851
+ /** Hayes-style rhythmic SECONDARY stresses for an OOV content polysyllable that the
852
+ * English Stress Rule has given a single primary (2). English does not leave a long
853
+ * word with one stress and a flat reduced tail: PRETONIC syllables alternating
854
+ * leftward from the primary bear a secondary (1) — the metrical grid's lower beats —
855
+ * e.g. Mìs·sis·SÌp·pi, à·pa·là·CHI·an. Only the PRETONIC pattern is added: English
856
+ * pre-stress secondaries are robust, whereas POST-tonic ones are weak and reduce, and
857
+ * adding them nudged the meter-fitter on a handful of corpus lines for no qualitative
858
+ * gain. Deliberately conservative — a secondary is placed only ≥ 2 syllables from the
859
+ * primary (so it can never clash with the primary or another secondary), so it can
860
+ * only ADD the 'n'-tier differentiation the contour was missing, never a competing
861
+ * beat. */
862
+ function addSecondaryStresses(pattern: number[], _heavy: boolean[]): void {
863
+ const p = pattern.indexOf(2);
864
+ if (p < 2) return; // need a primary with ≥ 2 pretonic syllables
865
+ for (let i = p - 2; i >= 0; i -= 2) { // pretonic alternation only
866
+ if (pattern[i] === 0) pattern[i] = 1;
867
+ }
868
+ }
869
+
522
870
  /**
523
871
  * Per-syllable heaviness from nounsing's `syllStruct` CV transcription
524
872
  * ("L.CL.CLC": C = consonant, L = lax/short nucleus, T = tense/long nucleus).
@@ -667,9 +1015,14 @@ export function assignLexicalStress(words: ClsWord[]): void {
667
1015
  word.isContent = isContent;
668
1016
  // Tier 1: morphological stem (reuse real lexical stress); Tier 2: ESR.
669
1017
  const morph = morphologicalStress(cleanWord);
670
- const pattern = morph ? morph.pattern : englishStressRule(cleanWord, isContent);
1018
+ const pattern = morph ? morph.pattern
1019
+ : englishStressRule(cleanWord, isContent, /^NNPS?$/.test(word.lexicalClass));
671
1020
  // Record an archaic verbal suffix so the display splits know·est, not kno·west.
672
1021
  if (morph && DISPLAY_PEEL_SUFFIXES.has(morph.suffix)) word.morphSuffix = morph.suffix;
1022
+ // Record a productive prefix so the display splits dis·il·lu·sions, not
1023
+ // di·sil·lu·sions (the Maximal Onset principle would otherwise pull the
1024
+ // prefix's final consonant into the next syllable).
1025
+ if (morph && morph.prefix) word.morphPrefix = morph.prefix;
673
1026
  const syls: Syllable[] = pattern.map(numeric => {
674
1027
  // Mirror the in-vocab function-word reduction (primary → secondary).
675
1028
  const n = (!isContent && numeric === 2) ? 1 : numeric;
@@ -746,7 +1099,12 @@ export function assignLexicalStress(words: ClsWord[]): void {
746
1099
  if (rawStress.length >= 2 && /^0+$/.test(rawStress)) {
747
1100
  const cw = word.word.toLowerCase().replace(/-/g, '').replace(/['’]/g, '');
748
1101
  const heavy = heavyFromSyllStruct(profile.phonology.syllStruct, rawStress.length);
749
- const idx = defaultStressIndex(cw, rawStress.length, heavy);
1102
+ // Rising/iambic function words (be·CAUSE, a·BOUT) recorded fully-reduced
1103
+ // take FINAL-syllable stress; everything else keeps the forestress default
1104
+ // (IN·to, and polysyllabic content artifacts).
1105
+ const idx = RISING_FUNCTION_WORDS.has(cw)
1106
+ ? rawStress.length - 1
1107
+ : defaultStressIndex(cw, rawStress.length, heavy);
750
1108
  rawStress = rawStress.split('').map((c, i) => (i === idx ? '1' : '0')).join('');
751
1109
  }
752
1110
 
@@ -852,10 +1210,12 @@ export function assignLexicalStress(words: ClsWord[]): void {
852
1210
  /**
853
1211
  * Adjust stresses for nominal compounds.
854
1212
  *
855
- * Right‑stressed by default: the second content word keeps primary (2),
856
- * the first is reduced to secondary (1).
857
- * Known left‑stressed compounds (material, time, measure, location, self)
858
- * reverse the pattern.
1213
+ * Fore-stressed by default: an N+N compound puts primary (2) on the first
1214
+ * element, secondary (1) on the second the Compound Stress Rule (Chomsky–
1215
+ * Halle; McAleese marks ICE cream with primary on "ice", KITCHen table,
1216
+ * WINdow frame). The marked right-stress exceptions — food/temporal "made of"
1217
+ * heads (apple PIE), Adj+N phrases (sweet CREAM), and proper-name sequences
1218
+ * (New YORK) — reverse it. All of this lives in `compoundStressSide`.
859
1219
  */
860
1220
  export function applyCompoundStress(ius: IntonationalUnit[]): void {
861
1221
  for (const iu of ius) {
@@ -867,19 +1227,14 @@ export function applyCompoundStress(ius: IntonationalUnit[]): void {
867
1227
  for (let i = 0; i < contentWords.length - 1; i++) {
868
1228
  const w1 = contentWords[i];
869
1229
  const w2 = contentWords[i + 1];
870
-
1230
+
871
1231
  // Wait, they must be adjacent in the sentence!
872
1232
  if (Math.abs(w1.absoluteIndex - w2.absoluteIndex) !== 1) continue;
873
1233
 
874
- const pos1 = w1.lexicalClass;
875
- const pos2 = w2.lexicalClass;
876
- const isCompound =
877
- (pos2.startsWith('N') && (pos1.startsWith('N') || pos1.startsWith('J')));
878
- if (!isCompound) continue;
879
-
880
- const leftStressed = isLeftStressedPair(w1.word, w2.word);
1234
+ const side = compoundStressSide(w1.word, w1.lexicalClass, w2.word, w2.lexicalClass);
1235
+ if (side === null) continue;
881
1236
 
882
- if (leftStressed) {
1237
+ if (side === 'left') {
883
1238
  setPrimaryStress(w1, 2);
884
1239
  setPrimaryStress(w2, 1);
885
1240
  } else {
@@ -892,7 +1247,7 @@ export function applyCompoundStress(ius: IntonationalUnit[]): void {
892
1247
  }
893
1248
 
894
1249
  /** Locate the syllable with the highest stress and set it to `value`. */
895
- function setPrimaryStress(word: ClsWord, value: number): void {
1250
+ export function setPrimaryStress(word: ClsWord, value: number): void {
896
1251
  let maxIdx = -1;
897
1252
  let maxVal = -1;
898
1253
  for (let i = 0; i < word.syllables.length; i++) {
@@ -919,24 +1274,39 @@ export function applyNuclearStress(ius: IntonationalUnit[]): void {
919
1274
  for (const iu of ius) {
920
1275
  for (const pp of iu.phonologicalPhrases) {
921
1276
  const words = collectPPTokens(pp).sort((a, b) => a.index - b.index);
922
- // Find the rightmost content word and boost its primary by 1 level.
1277
+ // The nuclear accent normally lands on the rightmost CONTENT word.
1278
+ let target: ClsWord | null = null;
923
1279
  for (let i = words.length - 1; i >= 0; i--) {
924
- if (words[i].isContent) {
925
- const word = words[i];
926
- let maxIdx = -1;
927
- let maxVal = -1;
928
- for (let j = 0; j < word.syllables.length; j++) {
929
- if (word.syllables[j].stress > maxVal) {
930
- maxVal = word.syllables[j].stress;
931
- maxIdx = j;
932
- }
1280
+ if (words[i].isContent) { target = words[i]; break; }
1281
+ }
1282
+ // But a content-less phrase ending in a non-oblique personal pronoun puts
1283
+ // the nuclear accent on that final pronoun (NSR: the accent falls on the
1284
+ // last accentable item; a clause-final addressee/focus pronoun bears it —
1285
+ // "…to YOU"). Only when there is no content word to carry it, so "I KNOW
1286
+ // you" is untouched; oblique objects (me/him/them) stay deaccented.
1287
+ if (!target) {
1288
+ let lastIdx = -1;
1289
+ for (let i = words.length - 1; i >= 0; i--) {
1290
+ if (!isPunctuation(words[i].lexicalClass)) { lastIdx = i; break; }
1291
+ }
1292
+ if (lastIdx >= 0) {
1293
+ const w = words[lastIdx];
1294
+ if (w.lexicalClass === 'PRP'
1295
+ && !OBLIQUE_PRONOUNS.has(w.word.toLowerCase().replace(/['’]/g, ''))) {
1296
+ target = w;
933
1297
  }
934
- if (maxIdx >= 0) {
935
- // Boost the rightmost content word's primary by 1.
936
- word.syllables[maxIdx].stress = word.syllables[maxIdx].stress + 1;
1298
+ }
1299
+ }
1300
+ if (target) {
1301
+ let maxIdx = -1;
1302
+ let maxVal = -1;
1303
+ for (let j = 0; j < target.syllables.length; j++) {
1304
+ if (target.syllables[j].stress > maxVal) {
1305
+ maxVal = target.syllables[j].stress;
1306
+ maxIdx = j;
937
1307
  }
938
- break; // Only the rightmost content word gets boosted.
939
1308
  }
1309
+ if (maxIdx >= 0) target.syllables[maxIdx].stress += 1;
940
1310
  }
941
1311
  }
942
1312
  }
@@ -944,12 +1314,61 @@ export function applyNuclearStress(ius: IntonationalUnit[]): void {
944
1314
 
945
1315
  // ─── RELATIVE STRESS ASSIGNMENT (4‑LEVEL) ─────────────────────────
946
1316
 
1317
+ /**
1318
+ * Phrase-edge sets used for the "endings strict / beginnings loose" floor gate.
1319
+ * McAleese (after Hayes & Kaun): metrical and lexical stress coincide at the
1320
+ * ENDS of phonological units (clitic phrase 90% / phonological phrase 97% /
1321
+ * intonational unit 99%); Selkirk notes a function word is not reduced at the
1322
+ * end of a clitic phrase ("of" never → "o'" there). So a function word at the
1323
+ * right edge of a PP or IU — or the phrase-stress PEAK (nucleus) of its PP —
1324
+ * resists the citation-floor reduction, while interior / left-edge function
1325
+ * words reduce as before. Reads the Phrase-Stress phase (word.phraseStress).
1326
+ */
1327
+ function phraseEdgeSets(ius: IntonationalUnit[]): {
1328
+ ppFinal: Set<ClsWord>; iuFinal: Set<ClsWord>; ppPeak: Set<ClsWord>;
1329
+ } {
1330
+ const ppFinal = new Set<ClsWord>();
1331
+ const iuFinal = new Set<ClsWord>();
1332
+ const ppPeak = new Set<ClsWord>();
1333
+ for (const iu of ius) {
1334
+ let iuLast: ClsWord | null = null;
1335
+ for (const pp of iu.phonologicalPhrases) {
1336
+ const toks = collectPPTokens(pp)
1337
+ .filter(w => !isPunctuation(w.lexicalClass))
1338
+ .sort((a, b) => a.index - b.index);
1339
+ if (toks.length === 0) 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) { peak = t; peakVal = v; }
1348
+ }
1349
+ // Only protect a genuine nuclear ramp peak (phraseStress ≥ 2): an all-floor
1350
+ // PP (all function words, or the Phase-Stress phase not run) has no nucleus
1351
+ // to protect, so we must not spuriously shield its first token.
1352
+ if (peakVal >= 2) ppPeak.add(peak);
1353
+ }
1354
+ if (iuLast) iuFinal.add(iuLast);
1355
+ }
1356
+ return { ppFinal, iuFinal, ppPeak };
1357
+ }
1358
+
947
1359
  /**
948
1360
  * Convert numeric per‑syllable stress to McAleese’s symbolic levels
949
1361
  * (w, n, m, s) and resolve adjacent identical stresses using dependency
950
1362
  * information.
951
1363
  */
952
1364
  export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]): void {
1365
+ // Phrase-edge sets for the endings-strict floor gate (read from Phase-Stress).
1366
+ const edges = phraseEdgeSets(ius);
1367
+ // Syllables raised by dependency-mined prominence (stranded preposition,
1368
+ // contrastive possessive, vocative): protected from the per-PP trailing-run
1369
+ // flatten so the recovered beat survives.
1370
+ const prominenceProtected = new Set<Syllable>();
1371
+
953
1372
  // First pass: numeric → symbolic (0→w, 1→n, 2→m, 3+→s)
954
1373
  // Use lexicalStress (pre-nuclear) so nuclear stress doesn't corrupt meter detection.
955
1374
  for (const word of words) {
@@ -989,7 +1408,12 @@ export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]
989
1408
  // wh-word/coordinator is reduced in running speech; flooring it at 'n' is
990
1409
  // what produced the flat function-word runs ("So on my", "where strange").
991
1410
  // (Pure clitics the/a/of already read 'x' via the val===0 branch.)
992
- if (syl.relativeStress === 'n' && isMonosyllabicClitic(word)) {
1411
+ if (syl.relativeStress === 'n'
1412
+ && (isMonosyllabicClitic(word) || word.word.toLowerCase() === 'am')) {
1413
+ // "am" (1sg copula) is reliably reduced — far more so than beat-bearing
1414
+ // is/are/was/were — so it floors at 'w' (still Attridge-promotable) rather
1415
+ // than surfacing at 'm' as a spurious beat in "As I am BLOOD…". Kept to
1416
+ // this one form: flooring all be-verbs regressed Wyatt's accentual + corpus.
993
1417
  syl.relativeStress = 'w';
994
1418
  }
995
1419
  // Downgrade extrametrical syllables by one level. We do NOT push a weak
@@ -1002,6 +1426,54 @@ export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]
1002
1426
  else if (syl.relativeStress === 's') syl.relativeStress = 'm';
1003
1427
  }
1004
1428
  }
1429
+
1430
+ // Honest baseline prominence: floor a monosyllabic function word to its true
1431
+ // reading prominence ('x' schwa-clitic / 'w' overt-weak), never raising it.
1432
+ // The dictionary's citation stress on "and"/"in"/"my"/"could" is an artefact;
1433
+ // the meter layer re-promotes these where the metre needs a beat.
1434
+ //
1435
+ // ENDINGS STRICT / BEGINNINGS LOOSE (McAleese; Selkirk): withhold the floor
1436
+ // for a function word at the right edge of a PP or IU, or one that is its
1437
+ // PP's phrase-stress peak — it resists reduction there (a stranded/clause-
1438
+ // final "to"/"you"/"of" is not crushed to a clitic). Oblique object
1439
+ // pronouns (me/him/thee…) are excepted: they stay default-deaccented even
1440
+ // phrase-finally ("I gave it to HIM" only under focus, handled later).
1441
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
1442
+ const edgeProtected =
1443
+ (edges.iuFinal.has(word) || edges.ppFinal.has(word) || edges.ppPeak.has(word))
1444
+ && !OBLIQUE_PRONOUNS.has(lemma);
1445
+ const floor = relativeFloorFor(word);
1446
+ if (floor && !edgeProtected) {
1447
+ const fr = STRESS_RANK[floor];
1448
+ for (const syl of word.syllables) {
1449
+ if (STRESS_RANK[syl.relativeStress ?? 'w'] > fr) syl.relativeStress = floor;
1450
+ }
1451
+ }
1452
+
1453
+ // Dependency-mined prominence (the parse IS the semantic layer): recover the
1454
+ // beat a flat POS floor would crush. A STRANDED preposition ("waiting FOR",
1455
+ // "stare AT") and a CONTRASTIVE possessive ("THY choice, not mine") bear
1456
+ // real stress → raise the peak to at least 'n' (promotable); a VOCATIVE
1457
+ // address ("Sing, O GODDESS") to at least 'm'. Raise-only; the raised peak
1458
+ // is protected from the per-PP trailing-run flatten below.
1459
+ let promoteTo: StressLevel | null = null;
1460
+ if (isVocative(word, words) || isDeicticLocative(word, words)) promoteTo = 'm';
1461
+ else if (isStrandedPreposition(word, words) || isContrastivePossessive(word, words)) promoteTo = 'n';
1462
+ if (promoteTo) {
1463
+ const pk = wordPeak(word);
1464
+ if (pk && STRESS_RANK[pk.relativeStress ?? 'w'] < STRESS_RANK[promoteTo]) {
1465
+ pk.relativeStress = promoteTo;
1466
+ }
1467
+ if (pk) prominenceProtected.add(pk);
1468
+ }
1469
+
1470
+ // Exclamatory interjection ("O", "Oh", "Ah", "Lo", "Alas"): emphatic, never
1471
+ // reduced — raise its peak to at least 'n' (corrects the mis-tag that floored
1472
+ // vocative "O"→IN→'x'). Raise-only; an exclaimed one is lifted further below.
1473
+ if (EXCLAM_INTERJECTIONS.has(word.word.toLowerCase())) {
1474
+ const pk = wordPeak(word);
1475
+ if (pk && STRESS_RANK[pk.relativeStress ?? 'w'] < STRESS_RANK.n) pk.relativeStress = 'n';
1476
+ }
1005
1477
  }
1006
1478
 
1007
1479
  // Apply nuclear stress boosts to relative stress.
@@ -1029,7 +1501,7 @@ export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]
1029
1501
  for (const iu of ius) {
1030
1502
  for (const pp of iu.phonologicalPhrases) {
1031
1503
  const ppWords = collectPPTokens(pp);
1032
- resolveAdjacentClashes(ppWords);
1504
+ resolveAdjacentClashes(ppWords, prominenceProtected);
1033
1505
  }
1034
1506
  }
1035
1507
 
@@ -1055,6 +1527,167 @@ export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]
1055
1527
  // waste shore" leaving "waste"/"shore" non-adjacent in the tree though
1056
1528
  // contiguous in speech). Catch any residual cardinal s–s clash here.
1057
1529
  resolveLinearClashes(words);
1530
+
1531
+ // Exclaimed interjection: an interjection immediately followed by "!" (Oh!, Ah!,
1532
+ // O!, Lo!) is an emphatic peak — raise it one tier so it stands out from a flat
1533
+ // run of neighbouring function words ("But—Oh! ye lords…" was a monotone n·n·n,
1534
+ // with the interjection no louder than the conjunction beside it). Narrow by
1535
+ // construction: only an UH whose very next token is "!".
1536
+ for (let i = 0; i < words.length - 1; i++) {
1537
+ const w = words[i];
1538
+ if (w.lexicalClass !== 'UH' || w.syllables.length === 0) continue;
1539
+ if (words[i + 1].word !== '!') continue;
1540
+ const pk = wordPeak(w);
1541
+ if (!pk) continue;
1542
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1543
+ if (r < STRESS_RANK.s) pk.relativeStress = STRESS_LEVELS[r + 1];
1544
+ }
1545
+
1546
+ }
1547
+
1548
+ /**
1549
+ * Surface-order post-processing passes that re-assert forestress and resolve
1550
+ * residual clashes AFTER the main relativisation. These run in the Clio engine
1551
+ * inside `assignRelativeStresses`; the Calliope engine calls this separately
1552
+ * after `computeRelativeStress` so the same repairs reach both pipelines.
1553
+ *
1554
+ * The passes are:
1555
+ * 1. resolveCompoundForestress — re-assert left-stress on surface-adjacent
1556
+ * N+N/J+N compounds the tagger mislabels (WASTE·shore, SEA·shore).
1557
+ * 2. resolveCollocationForestress — forestress lexicalised collocations
1558
+ * (GOOD old, END-all, OLD days).
1559
+ * 3. resolveHyphenCompounds — resolve dual-strong clashes at hyphen seams
1560
+ * (torch-flames, blood-red).
1561
+ * 4. resolveLinearClashes — catch residual s-s / m-m / n-n surface clashes
1562
+ * with the full 7-level demotion cascade (lexical integrity → prominence →
1563
+ * syntactic headedness → content/function → weight → Rhythm Rule → default).
1564
+ * 5. Exclaimed interjection raise — an interjection immediately followed by
1565
+ * "!" is lifted one tier ("But—Oh! ye lords…").
1566
+ *
1567
+ * All passes are DEMOTE-ONLY (or forestress re-assertions that raise the LEFT
1568
+ * element of a known compound); none inflate the contour beyond what the lexicon
1569
+ * and phrase-stress rules already established.
1570
+ */
1571
+ export function applySurfacePostProcessing(words: ClsWord[]): void {
1572
+ resolveCompoundForestress(words);
1573
+ resolveCollocationForestress(words);
1574
+ resolveHyphenCompounds(words);
1575
+ resolveLinearClashes(words);
1576
+ resolvePhrasalVerbParticle(words);
1577
+ raiseInterrogativePronounFocus(words);
1578
+
1579
+ // Exclaimed interjection: an interjection immediately followed by "!" (Oh!, Ah!,
1580
+ // O!, Lo!) is an emphatic peak — raise it one tier so it stands out from a flat
1581
+ // run of neighbouring function words. Only an UH whose very next token is "!".
1582
+ for (let i = 0; i < words.length - 1; i++) {
1583
+ const w = words[i];
1584
+ if (w.lexicalClass !== 'UH' || w.syllables.length === 0) continue;
1585
+ if (words[i + 1].word !== '!') continue;
1586
+ const pk = wordPeak(w);
1587
+ if (!pk) continue;
1588
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1589
+ if (r < STRESS_RANK.s) pk.relativeStress = STRESS_LEVELS[r + 1];
1590
+ }
1591
+ }
1592
+
1593
+ /** Interrogative copula pronoun focus: in "Is it YOU?", "Was it HIM?", the
1594
+ * pronoun is the FOCUSED element — the question asks about identity, so the
1595
+ * complement pronoun carries the focus stress. UDPipe mis-tags this as
1596
+ * NSUBJ (it parses "it" as ROOT, "you" as nsubj of "it"), but the real
1597
+ * structure is: "it" is the dummy subject, the pronoun is the predicate
1598
+ * complement.
1599
+ *
1600
+ * Detection (very narrow, will NOT fire on the counter-examples):
1601
+ * 1. A PRP (personal pronoun, NOT PRP$ possessive) tagged NSUBJ
1602
+ * 2. Its governor is "it" (PRP, ROOT)
1603
+ * 3. "it" has an AUX dependent that is a be-form (is/are/was/were/be/been)
1604
+ * 4. The be-form is the first word of the sentence
1605
+ *
1606
+ * Counter-examples that are correctly EXCLUDED:
1607
+ * - "I'm asleep" → "I" is NSUBJ of "asleep" (not of "it") → not matched
1608
+ * - "his mouth" → "his" is PRP$ (not PRP) → not matched
1609
+ * - "our youth" → "our" is PRP$ → not matched
1610
+ * - "we're awake" → "we" governor is "awake" (not "it") → not matched
1611
+ * - "I would give you" → "you" is DOBJ (not NSUBJ) of "give" → not matched */
1612
+ function raiseInterrogativePronounFocus(words: ClsWord[]): void {
1613
+ const BE_FORMS = new Set(['is', 'are', 'was', 'were', 'be', 'been', 'being',
1614
+ "'s", "'re"]);
1615
+ const real = words.filter(w => w.syllables.length > 0 && !isPunctuation(w.lexicalClass));
1616
+ if (real.length === 0) return;
1617
+
1618
+ for (const w of real) {
1619
+ if (w.lexicalClass !== 'PRP') continue;
1620
+ if ((w.canonicalRel ?? '') !== 'NSUBJ') continue;
1621
+ const gov = w.dependency?.governor;
1622
+ if (!gov || gov === w) continue;
1623
+ if (gov.word.toLowerCase() !== 'it' || gov.lexicalClass !== 'PRP') continue;
1624
+ if ((gov.canonicalRel ?? '') !== 'ROOT') continue;
1625
+
1626
+ // Find the be-form AUX dependent of "it" that is the first word
1627
+ const aux = real.find(x =>
1628
+ (x.canonicalRel ?? '') === 'AUX' &&
1629
+ x.dependency?.governor === gov &&
1630
+ BE_FORMS.has(x.word.toLowerCase()));
1631
+ if (!aux) continue;
1632
+
1633
+ // The be-form must be the first real word of the sentence (interrogative
1634
+ // verb-subject inversion)
1635
+ const firstWord = real.sort((a, b) => a.absoluteIndex - b.absoluteIndex)[0];
1636
+ if (aux !== firstWord) continue;
1637
+
1638
+ // Found the interrogative copula construction: "Is it YOU?".
1639
+ // 1. Raise the focused pronoun to 'm' — it is the information-seeking
1640
+ // element, the strongest stress in the question.
1641
+ const pk = wordPeak(w);
1642
+ if (pk) {
1643
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1644
+ if (r < STRESS_RANK.m) pk.relativeStress = 'm';
1645
+ }
1646
+ // 2. Lower the dummy subject "it" to 'w' — it is an expletive, not a real
1647
+ // content word, and must not sit at 'n' creating a flat n-n-n chain with
1648
+ // the pronoun and the following adverb.
1649
+ const itPk = wordPeak(gov);
1650
+ if (itPk) {
1651
+ const r = STRESS_RANK[itPk.relativeStress ?? 'w'];
1652
+ if (r > STRESS_RANK.w) itPk.relativeStress = 'w';
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ /** Phrasal-verb particle stress: in a VB+RP pair ("come ON", "take OFF", "give
1658
+ * UP"), the PARTICLE bears the stress — English phonology places the phrasal
1659
+ * accent on the particle, not the verb. UDPipe confirms the relation via the
1660
+ * `compound:prt` dependency (canonicalRel='VPRT') or the RP POS tag. When the
1661
+ * verb currently outranks the particle, swap their peaks: demote the verb one
1662
+ * rung, promote the particle to the verb's former level. Only fires when they
1663
+ * are surface-adjacent (the particle immediately follows the verb). */
1664
+ function resolvePhrasalVerbParticle(words: ClsWord[]): void {
1665
+ const content = words.filter(w => w.syllables.length > 0 && !isPunctuation(w.lexicalClass));
1666
+ for (let i = 0; i < content.length - 1; i++) {
1667
+ const verb = content[i];
1668
+ const particle = content[i + 1];
1669
+ if (Math.abs(verb.absoluteIndex - particle.absoluteIndex) !== 1) continue;
1670
+ if (!/^VB/.test(verb.lexicalClass)) continue;
1671
+ // The partner must be a phrasal-verb particle: RP POS, or has a prt/VPRT
1672
+ // dependency on the verb, or is recovered by isPhrasalParticle.
1673
+ const isParticle =
1674
+ particle.lexicalClass === 'RP' ||
1675
+ (particle.canonicalRel ?? '') === 'VPRT' ||
1676
+ particle.dependency?.dependentType === 'compound:prt' ||
1677
+ (PARTICLE_LEMMAS.has(particle.word.toLowerCase()) &&
1678
+ (particle.dependency?.dependentType === 'prt' ||
1679
+ particle.dependency?.dependentType === 'advmod'));
1680
+ if (!isParticle) continue;
1681
+ const vPeak = wordPeak(verb);
1682
+ const pPeak = wordPeak(particle);
1683
+ if (!vPeak || !pPeak) continue;
1684
+ const rv = STRESS_RANK[vPeak.relativeStress ?? 'w'];
1685
+ const rp = STRESS_RANK[pPeak.relativeStress ?? 'w'];
1686
+ if (rv <= rp) continue; // particle already ≥ verb → nothing to do
1687
+ // Swap: particle gets the verb's level, verb drops one rung
1688
+ pPeak.relativeStress = STRESS_LEVELS[rv];
1689
+ vPeak.relativeStress = STRESS_LEVELS[Math.max(0, rv - 1)];
1690
+ }
1058
1691
  }
1059
1692
 
1060
1693
  /** Ascending rank of the 5 relative-stress tiers, for level arithmetic. */
@@ -1103,11 +1736,17 @@ function resolveCollocationForestress(words: ClsWord[]): void {
1103
1736
  const seq = words
1104
1737
  .filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0)
1105
1738
  .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
1739
+ // Track words demoted as the RIGHT element of a collocation — a word just
1740
+ // demoted (e.g. "old" in "GOOD old") must NOT be re-forestressed as the LEFT
1741
+ // element of a following collocation ("old days"), or it would be raised back
1742
+ // up and "days" would be wrongly demoted, destroying "GOOD old DAYS".
1743
+ const demoted = new Set<ClsWord>();
1106
1744
  for (let i = 0; i < seq.length - 1; i++) {
1107
1745
  const w1 = seq[i];
1108
1746
  const w2 = seq[i + 1];
1109
1747
  if (w2.absoluteIndex - w1.absoluteIndex !== 1) continue; // truly adjacent
1110
1748
  if (!isLeftStressedCollocation(w1, w2)) continue;
1749
+ if (demoted.has(w1)) continue; // w1 was a prior collocation's right element — don't re-forestress
1111
1750
 
1112
1751
  const s1 = wordPeak(w1);
1113
1752
  const s2 = wordPeak(w2);
@@ -1117,6 +1756,7 @@ function resolveCollocationForestress(words: ClsWord[]): void {
1117
1756
  const hi = Math.max(r1, r2);
1118
1757
  s1.relativeStress = STRESS_LEVELS[hi]; // left element ≥ both
1119
1758
  s2.relativeStress = STRESS_LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote-only
1759
+ demoted.add(w2);
1120
1760
  }
1121
1761
  }
1122
1762
 
@@ -1150,43 +1790,120 @@ function resolveHyphenCompounds(words: ClsWord[]): void {
1150
1790
  }
1151
1791
 
1152
1792
  /**
1153
- * Resolve cardinal stress clashes on the linear surface sequence: two
1154
- * *contiguous* syllables both at the strongest level ('s') with no weaker
1155
- * syllable between them. Per McAleese/Hayes, retract the LEFT stress one level
1156
- * (s→m), with within-word-peak protection so a polysyllable's own peak is never
1157
- * demoted for an adjacent monosyllable (the monosyllable yields instead).
1793
+ * THE CLASH FILTER an absolute surface well-formedness constraint.
1794
+ *
1795
+ * On the STRESSED tier {n, m, s} no two *contiguous* syllables may carry the SAME
1796
+ * level: that is a stress clash (two equal prominences with no gradation between
1797
+ * them), which English categorically disallows. Gradient pairs (sm/ms/sn/ns/mn/nm)
1798
+ * are fine — there is still a step down — as are runs of the unstressed tiers
1799
+ * {w, x} (an unstressed sequence is tolerated, if not ideal). This generalises
1800
+ * McAleese's Appendix-A step 3d-ii ("stress clashes (ss, ms) > s-s") and Liberman
1801
+ * & Prince's (1977) grid alternation to every level of the strong tier.
1158
1802
  *
1159
- * Deliberately limited to s–s (the cardinal clash the methodology resolves
1160
- * unconditionally): m–m and lower are often metrically meaningful (spondaic /
1161
- * emphatic substitutions) and are left for the meter layer to weigh.
1803
+ * Resolution is DEMOTE-ONLY (never promote promotion is the meter layer's job,
1804
+ * McAleese Test 2), so the contour is never inflated to break a clash; we iterate
1805
+ * to a fixed point (each change strictly lowers total stress mass, so it
1806
+ * terminates). Which member yields is decided by `demoteRightOfClash` (grid-based
1807
+ * relative prominence). Runs on the LINEAR surface order because a clash is a
1808
+ * property of contiguous *pronounced* syllables (Hayes), which a mis-grouped
1809
+ * dependency parse can scatter across phrases.
1162
1810
  */
1163
- function resolveLinearClashes(words: ClsWord[]): void {
1811
+ export function resolveLinearClashes(words: ClsWord[]): void {
1164
1812
  const flat: { word: ClsWord; syl: Syllable }[] = [];
1165
1813
  for (const w of words) for (const s of w.syllables) flat.push({ word: w, syl: s });
1166
1814
 
1167
- for (let i = 0; i < flat.length - 1; i++) {
1168
- const a = flat[i];
1169
- const b = flat[i + 1];
1170
- if (a.syl.relativeStress !== 's' || b.syl.relativeStress !== 's') continue;
1171
- if (a.word === b.word) continue; // intra-word clashes silent beat downstream
1172
-
1173
- const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
1174
- const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
1175
- // Protect a polysyllable's peak: if only the left is a peak and the right is
1176
- // a lone monosyllable, retract the monosyllable instead.
1177
- if (aPeak && !bPeak && b.word.syllables.length === 1) {
1178
- demoteOneLevel(b.syl);
1179
- } else {
1180
- demoteOneLevel(a.syl); // Hayes: retract the left stress
1815
+ let changed = true;
1816
+ let guard = 0;
1817
+ while (changed && guard++ < 24) {
1818
+ changed = false;
1819
+ for (let i = 0; i < flat.length - 1; i++) {
1820
+ const a = flat[i];
1821
+ const b = flat[i + 1];
1822
+ const lvl = a.syl.relativeStress;
1823
+ if (lvl !== b.syl.relativeStress) continue;
1824
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's') continue; // only the stressed tier clashes
1825
+ // Intra-word clashes are NOT skipped: a hyphenated-compound seam
1826
+ // (deep·voiced, snow·storm, gate·bolts) or any word with two adjacent equal
1827
+ // stresses is still a clash, and must be made gradient like any other.
1828
+
1829
+ if (demoteRightOfClash(flat, i)) demoteOneLevel(b.syl);
1830
+ else demoteOneLevel(a.syl);
1831
+ changed = true;
1181
1832
  }
1182
1833
  }
1183
1834
  }
1184
1835
 
1836
+ /**
1837
+ * Decide which member of an equal-stress clash at (i, i+1) yields. Rather than a
1838
+ * blunt leftward retraction, a layered cascade consults — in descending
1839
+ * authority — lexical, syntactic, and phonological context, so the demotion is
1840
+ * theory-grounded and the arbitrary default fires only as a last resort. Returns
1841
+ * true to demote the RIGHT member (i+1), false to demote the LEFT (i).
1842
+ *
1843
+ * 1. Lexical integrity (Kiparsky): never demote a polysyllable's own stress
1844
+ * peak for an adjacent monosyllable — the monosyllable yields.
1845
+ * 2. Inherent lexical prominence: a primary-stressed syllable outranks a
1846
+ * secondary one — the lower-lexical-stress member yields.
1847
+ * 3. Syntactic headedness (Nuclear Stress Rule; Liberman & Prince 1977; Cinque
1848
+ * 1993): when the two words stand in a direct head–dependent relation, the
1849
+ * DEPENDENT yields to its governor (the phrasal head is the more prominent).
1850
+ * 4. Content/function asymmetry — a coarse proxy for headedness when no direct
1851
+ * dependency links the pair: the function word yields.
1852
+ * 5. Phonological weight: a light (open, short-vowel) syllable reduces more
1853
+ * readily than a heavy (closed / long-vowel) one — the lighter member yields.
1854
+ * 6. Rhythm Rule (Liberman & Prince 1977; Hayes 1984): the clash member abutting
1855
+ * the stronger OUTER beat is that beat's off-beat, so it retracts — "wet
1856
+ * CHURCH" retracts wet (church is nuclear), "LATE last NIGHT" retracts last
1857
+ * onto late (night is the following beat).
1858
+ * 7. Default (Hayes): retract the LEFT stress.
1859
+ */
1860
+ function demoteRightOfClash(
1861
+ flat: { word: ClsWord; syl: Syllable }[],
1862
+ i: number
1863
+ ): boolean {
1864
+ const a = flat[i];
1865
+ const b = flat[i + 1];
1866
+
1867
+ // (1) lexical integrity
1868
+ const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
1869
+ const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
1870
+ if (aPeak && !bPeak && b.word.syllables.length === 1) return true;
1871
+ if (bPeak && !aPeak && a.word.syllables.length === 1) return false;
1872
+
1873
+ // (2) inherent lexical prominence
1874
+ const la = a.syl.lexicalStress ?? a.syl.stress ?? 0;
1875
+ const lb = b.syl.lexicalStress ?? b.syl.stress ?? 0;
1876
+ if (la !== lb) return la > lb;
1877
+
1878
+ // (3) syntactic headedness — the dependent yields to its governor
1879
+ if (a.word !== b.word) {
1880
+ const gov = getGovernor(a.word, b.word);
1881
+ if (gov === a.word) return true; // a heads b → demote b
1882
+ if (gov === b.word) return false; // b heads a → demote a
1883
+ }
1884
+
1885
+ // (4) content/function asymmetry — the function word yields
1886
+ if (a.word.isContent !== b.word.isContent) return a.word.isContent;
1887
+
1888
+ // (5) phonological weight — the lighter syllable yields
1889
+ if (a.syl.weight && b.syl.weight && a.syl.weight !== b.syl.weight) {
1890
+ return a.syl.weight === 'H'; // a heavy → demote the lighter b
1891
+ }
1892
+
1893
+ // (6) Rhythm Rule — yield to the stronger adjacent (outer) beat
1894
+ const outerA = i - 1 >= 0 ? STRESS_RANK[flat[i - 1].syl.relativeStress ?? 'w'] : -1;
1895
+ const outerB = i + 2 < flat.length ? STRESS_RANK[flat[i + 2].syl.relativeStress ?? 'w'] : -1;
1896
+ if (outerA !== outerB) return outerB > outerA;
1897
+
1898
+ // (7) default: retract the left
1899
+ return false;
1900
+ }
1901
+
1185
1902
  /**
1186
1903
  * Scan across the linear sequence of syllables and adjust any adjacent
1187
1904
  * identical relative stress levels using syntactic governance.
1188
1905
  */
1189
- function resolveAdjacentClashes(words: ClsWord[]): void {
1906
+ function resolveAdjacentClashes(words: ClsWord[], protect?: Set<Syllable>): void {
1190
1907
  // "Endings strict": when a phrase ends in a run of two or more bare function
1191
1908
  // words (e.g. "…fast as you MIGHT"), the metrical beat gravitates to one of
1192
1909
  // them; the others are upbeat. Demote the others so a leftward governance
@@ -1211,6 +1928,8 @@ function resolveAdjacentClashes(words: ClsWord[]): void {
1211
1928
  // Protect a polysyllabic word's own lexical peak: never flatten a real
1212
1929
  // internal stress (be·HIND) to 'w' just because the word is functional.
1213
1930
  if (w.syllables.length > 1 && s === peak && (s.lexicalStress ?? s.stress) >= 1) continue;
1931
+ // Protect a dependency-mined prominence (stranded preposition etc.).
1932
+ if (protect && protect.has(s)) continue;
1214
1933
  s.relativeStress = 'w';
1215
1934
  }
1216
1935
  }
@@ -1229,6 +1948,10 @@ function resolveAdjacentClashes(words: ClsWord[]): void {
1229
1948
  const a = flat[i];
1230
1949
  const b = flat[i + 1];
1231
1950
  if (a.syl.relativeStress !== b.syl.relativeStress) continue;
1951
+ // Only the stressed tier {n,m,s} clashes; {w,x} may repeat (maintainer's rule:
1952
+ // an unstressed run is tolerated, never "resolved" by demoting a clitic to 'x').
1953
+ const lvl = a.syl.relativeStress;
1954
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's') continue;
1232
1955
 
1233
1956
  // Within-word strictness (Kiparsky): a polysyllabic word's own stress peak
1234
1957
  // must not be demoted below its word-mates by a clash with an adjacent
@@ -1344,6 +2067,9 @@ function resolveCrossBoundaryClashes(words: ClsWord[], ius: IntonationalUnit[]):
1344
2067
  const a = flat[i];
1345
2068
  const b = flat[i + 1];
1346
2069
  if (a.syl.relativeStress !== b.syl.relativeStress) continue;
2070
+ // Only the stressed tier {n,m,s} clashes; {w,x} may repeat (maintainer's rule).
2071
+ const lvl = a.syl.relativeStress;
2072
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's') continue;
1347
2073
 
1348
2074
  // Only adjust if they span a prosodic boundary
1349
2075
  if (a.ppKey === b.ppKey) continue;
@@ -1356,7 +2082,11 @@ function resolveCrossBoundaryClashes(words: ClsWord[], ius: IntonationalUnit[]):
1356
2082
  } else if (!aContent && bContent) {
1357
2083
  demoteOneLevel(a.syl);
1358
2084
  } else {
1359
- // Both same content/function type — try dependency relationship
2085
+ // Both same content/function type — try dependency relationship.
2086
+ // (A blanket Selkirk "demote the PP-initial" was tried and regressed
2087
+ // litlab/prosodic: the linear-clash cascade's nuanced resolution — weight,
2088
+ // Rhythm Rule, governance — captures "beginnings loose" better than a flat
2089
+ // directional rule, so a clash with no governance is left for it.)
1360
2090
  const governor = getGovernor(a.word, b.word);
1361
2091
  if (governor === a.word) {
1362
2092
  adjustAdjacent(a.syl, b.syl, governorDependentDirection);