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
@@ -0,0 +1,1731 @@
1
+ // stress.ts — Lexical, compound, nuclear stress assignment using nounsing-pro
2
+ // (augmented CMU dictionary with 52+ columns), then conversion to McAleese's
3
+ // 4‑level relative system.
4
+
5
+ import * as nounsing from 'nounsing-pro';
6
+ import { ClsWord, Syllable, StressLevel, IntonationalUnit, PhonologicalPhrase } from '../types.js';
7
+ import { isPunctuation } from './parser.js';
8
+ import { collectPPTokens, syllabifyWord } from './phonological.js';
9
+ import { isStrandedPreposition, isContrastivePossessive, isVocative, isDeicticLocative } from './semantics.js';
10
+
11
+ // ─── CONSTANTS & CLASSIFICATIONS ──────────────────────────────────
12
+
13
+ /**
14
+ * Content‑word POS tags (nouns, adjectives, lexical verbs, adverbs).
15
+ * Excludes:
16
+ * - determiners (including demonstratives) – function words
17
+ * - possessive pronouns (PRP$) – function words
18
+ * - Wh‑words (WDT, WP, WP$, WRB) – function words
19
+ * - prepositions, conjunctions, particles, etc.
20
+ */
21
+ const CONTENT_POS = new Set([
22
+ 'NN', 'NNS', 'NNP', 'NNPS', // nouns
23
+ 'JJ', 'JJR', 'JJS', // adjectives
24
+ 'VB', 'VBD', 'VBG', 'VBN', 'VBP', 'VBZ', // lexical verbs (excludes modals MD)
25
+ 'RB', 'RBR', 'RBS', // adverbs
26
+ 'CD', // cardinal numbers (content-like)
27
+ 'PDT', // predeterminers / quantifiers ("all", "both", "half") — carry quantificational stress
28
+ 'RP', // phrasal-verb particles ("coming IN", "take OFF") — they bear the phrasal stress
29
+ ]);
30
+
31
+ /**
32
+ * Spatial words that act as phrasal-verb PARTICLES (stress-bearing: "coming IN",
33
+ * "moving ON", "give UP") as opposed to prepositions ("in the house" → reduced).
34
+ * The parser usually tags a true particle `RP` (handled by CONTENT_POS above),
35
+ * but often mis-tags it `IN`/`RB` with an adverbial/particle dependency on the
36
+ * verb — `isPhrasalParticle` recovers those. A genuine preposition keeps a
37
+ * `prep`/`pobj` dependency on a NOUN and is (correctly) left as a function word.
38
+ */
39
+ const PARTICLE_LEMMAS = new Set([
40
+ 'in', 'on', 'out', 'off', 'up', 'down', 'over', 'away', 'back',
41
+ 'along', 'around', 'about', 'through', 'apart', 'aside', 'forth', 'together',
42
+ ]);
43
+
44
+ /** A phrasal-verb particle the parser tagged IN/RB (not RP): stress-bearing. */
45
+ function isPhrasalParticle(word: ClsWord): boolean {
46
+ if (word.lexicalClass === 'RP') return true;
47
+ const dep = word.dependency?.dependentType;
48
+ return PARTICLE_LEMMAS.has(word.word.toLowerCase())
49
+ && (dep === 'prt' || dep === 'advmod');
50
+ }
51
+
52
+ /** Demonstratives that, used pronominally (not determining a following noun),
53
+ * are a stressed focus: "What's THAT?", "Give me THIS." */
54
+ const DEMONSTRATIVE_LEMMAS = new Set(['that', 'this', 'these', 'those']);
55
+
56
+ /**
57
+ * A demonstrative used as a *pronoun* (the clause-final focus), rather than as a
58
+ * determiner of a following noun. The parser often tags focus "that" as `IN`
59
+ * (complementizer) and it then reduces to `x` — but "What's THAT?" puts the
60
+ * sentence's prominence on it. Detected as a demonstrative lemma that is the
61
+ * last non-punctuation word of the line (so "Is THAT grass" — a determiner — is
62
+ * untouched).
63
+ */
64
+ function isFocusDemonstrative(words: ClsWord[], wi: number): boolean {
65
+ if (!DEMONSTRATIVE_LEMMAS.has(words[wi].word.toLowerCase())) return false;
66
+ for (let k = wi + 1; k < words.length; k++) {
67
+ if (!isPunctuation(words[k].lexicalClass)) return false; // a word follows → determiner use
68
+ }
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Clitic POS categories: function words that are *proclitic* — prepositions /
74
+ * subordinators (IN), infinitival "to" (TO), possessive determiners (PRP$) and
75
+ * wh-determiners/possessives/adverbs (WDT/WP$/WRB). A
76
+ * *monosyllabic* word in one of these classes is reduced in running speech
77
+ * (Selkirk's clitic; McAleese's "beginnings free") and should floor at 'w'
78
+ * (overt-weak, *promotable*), never 'n'. Leaving a CMU-primary monosyllable
79
+ * like "on"/"my"/"where"/"from" at 'n' produced flat function-word runs (Pound's
80
+ * "So on my" = n·n·n, "where strange" = n·n).
81
+ *
82
+ * Deliberately EXCLUDES:
83
+ * - modals (MD: "shall"/"might") and personal pronouns (PRP: "I"/"thee"/"you")
84
+ * — they carry real stress (the clause-final beat in "…fast as you MIGHT").
85
+ * - determiners (DT) entirely. The quantificational / negative / demonstrative
86
+ * ones ("no one", "all verse", "this", "each") carry stress (cf. the
87
+ * maintainer's PDT-as-content rule); and flooring even the pure articles
88
+ * a/an tips Tarlinskaja's razor-thin iambic↔anapestic line ("…else a
89
+ * laugher's license", margin 0.009) into a wrong meter. "the" already reads
90
+ * 'x' via its CMU-0 stress, so determiners need no extra handling here.
91
+ * - coordinators (CC: and/but/or). "and" already reads 'x' (CMU-0); flooring
92
+ * "but"→w fires earlier than its baseline path and ripples to suppress an
93
+ * adjacent pronoun's clash-promotion, tipping Tarlinskaja's razor-thin
94
+ * iambic↔anapestic line — and coordinators bear no part of the n-run problem.
95
+ * Polysyllabic function words are also untouched, so their internal contour is
96
+ * preserved (be·NEATH = x·n, un·der·NEATH = x·x·n).
97
+ */
98
+ // NB: deliberately NOT including DT/PRP/MD/WP here. Monosyllabic pronouns,
99
+ // determiners, and modals at 'n' are PROMOTABLE into metrical beats — which
100
+ // real iambic verse exploits constantly ("but HE gave NO one ELSE",
101
+ // "fast as you MIGHT"). Flooring them to 'w' was tried (2026-06-12) and
102
+ // flipped the Mandelstam anapest and the Tarlinskaja iambic: reverted.
103
+ const CLITIC_POS = new Set(['IN', 'TO', 'PRP$', 'WDT', 'WP$', 'WRB']);
104
+
105
+ /** A reducible monosyllabic proclitic (see CLITIC_POS). */
106
+ function isMonosyllabicClitic(word: ClsWord): boolean {
107
+ return !word.isContent
108
+ && word.syllables.length === 1
109
+ && CLITIC_POS.has(word.lexicalClass);
110
+ }
111
+
112
+ /**
113
+ * Temporal, locative, and discourse adverbs that behave as function words
114
+ * in verse — they typically occupy weak metrical positions and should not
115
+ * receive the primary-stress treatment of content adverbs.
116
+ */
117
+ const FUNCTION_ADVERBS = new Set([
118
+ 'then', 'so', 'here', 'there', 'where', 'when', 'why', 'how',
119
+ 'thus', 'hence', 'thence', 'whence',
120
+ 'now', 'ago', 'afterwards', 'afterward', 'beforehand',
121
+ 'meanwhile', 'nevertheless', 'nonetheless', 'however',
122
+ 'therefore', 'furthermore', 'moreover',
123
+ 'besides', 'instead', 'rather',
124
+ 'quite', 'almost', 'nearly', 'just', 'only',
125
+ 'even', 'also', 'too', 'very', 'indeed',
126
+ 'already', 'yet', 'still', 'again', 'ever', 'never',
127
+ 'always', 'often', 'sometimes', 'usually',
128
+ 'today', 'tomorrow', 'yesterday', 'tonight',
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
+
149
+ /**
150
+ * Oblique (object/dative) pronouns. In clause-final position these are
151
+ * canonically unstressed and do NOT attract the beat ("…and beHIND me", not
152
+ * "…and behind ME"), unlike a clause-final modal or content word. Used to keep
153
+ * the "endings strict" upbeat rule from promoting a final object pronoun.
154
+ */
155
+ const OBLIQUE_PRONOUNS = new Set([
156
+ 'me', 'him', 'her', 'us', 'them', 'thee', 'ye',
157
+ ]);
158
+
159
+ /**
160
+ * Subject-pronoun contractions (pronoun + auxiliary). These are a known data
161
+ * anomaly: FinNLP mis-tags the line-initial "I"-forms as FW, and nounsing records
162
+ * "i'm" with stress 0 while its sibling "i'll" gets 1 — so "I'm" sank to 'x'
163
+ * (Zero-Provision) whereas "I'll" read 'n'. A contracted subject pronoun is an
164
+ * overt syllable, never a maximally-reduced clitic, so a dictionary-zero one is
165
+ * restamped to its siblings' weak stress (below). This is a TARGETED fix for that
166
+ * specific inconsistency — it does NOT change how clitics, prepositions, articles,
167
+ * or bare pronouns floor (those keep their broad 'x'/contour behaviour).
168
+ */
169
+ const PRONOUN_SUBJECT_CONTRACTIONS = new Set([
170
+ "i'm", "i'll", "i've", "i'd",
171
+ "you're", "you'll", "you've", "you'd",
172
+ "he'll", "he'd", "she'll", "she'd", "it'll",
173
+ "we're", "we'll", "we've", "we'd",
174
+ "they're", "they'll", "they've", "they'd",
175
+ ]);
176
+
177
+ /**
178
+ * Poetic aphaeresis / clipping forms (apostrophe-stripped, lowercased) of
179
+ * function words — prepositions and adverbs. These are OOV, so without special
180
+ * handling they default to a *stressed content* reading (the parser tags
181
+ * "'neath" as NNP → primary stress!). Only applied when an apostrophe is
182
+ * actually present (so a literal "mid"/"side"/"cross" is left alone).
183
+ */
184
+ const APHAERESIS_CLITICS = new Set([
185
+ 'neath', // beneath
186
+ 'gainst', // against
187
+ 'twixt', // betwixt
188
+ 'mid', // amid
189
+ 'midst', // amidst
190
+ 'mongst', // amongst
191
+ 'tween', // between
192
+ 'pon', // upon
193
+ 'oer', // o'er = over
194
+ 'neer', // ne'er = never
195
+ 'eer', // e'er = ever
196
+ 'tis', // 'tis = it is (apostrophe-guarded, so a literal "tis" is untouched)
197
+ 'twas', // 'twas = it was
198
+ 'twere', // 'twere = it were
199
+ 'twill', // 'twill = it will (guard protects the fabric "twill")
200
+ ]);
201
+
202
+ /**
203
+ * Augmented-CMU data anomalies: for a couple of very common monosyllables the
204
+ * dictionary's ONLY profile is the letter-name spelling pronunciation of an
205
+ * abbreviation homograph — "am" → "EY1 EH1 M" (= A.M.), "us" → "Y UW1 EH1 S"
206
+ * (= U.S.) — inflating the syllable count of any line containing them. We
207
+ * restore the ordinary CMU citation form (AE1 M / AH1 S: one heavy syllable,
208
+ * citation stress 1) before the dictionary is consulted.
209
+ */
210
+ const ANOMALOUS_MONOSYLLABLES: Record<string, { syllab: string; stress?: number; weight?: 'H' | 'L' }> = {
211
+ am: { syllab: '(AE m)' },
212
+ us: { syllab: '(AH s)' },
213
+ // "a" = letter-name "EY1" in the augmented dictionary; the article is the
214
+ // canonical Zero-Provision clitic (schwa, open syllable) → stress 0, light.
215
+ a: { syllab: '(AH)', stress: 0, weight: 'L' },
216
+ };
217
+
218
+ /**
219
+ * Copula, auxiliary, aspectual, and light verbs that act as function words
220
+ * in verse — they do not carry the main semantic or prosodic weight of a phrase
221
+ * and should not be treated as content words for stress rules.
222
+ */
223
+ const FUNCTION_VERBS = new Set([
224
+ 'be', 'am', 'is', 'are', 'was', 'were', 'been', 'being',
225
+ 'have', 'has', 'had', 'having',
226
+ 'do', 'does', 'did', 'done', 'doing',
227
+ 'get', 'gets', 'got', 'getting', 'gotten',
228
+ 'start', 'starts', 'started', 'starting',
229
+ 'begin', 'begins', 'began', 'beginning', 'begun',
230
+ 'keep', 'keeps', 'kept', 'keeping',
231
+ 'stop', 'stops', 'stopped', 'stopping',
232
+ 'continue', 'continues', 'continued', 'continuing',
233
+ 'let', 'lets', "let's"
234
+ ]);
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
+
298
+ /** Left‑stressed compound categories with example first‑word lists. */
299
+ const LEFT_STRESS_MATERIAL = new Set([
300
+ 'metal', 'wood', 'silk', 'cotton', 'glass', 'stone', 'iron', 'steel',
301
+ 'paper', 'plastic', 'gold', 'silver'
302
+ ]);
303
+ const LEFT_STRESS_TIME = new Set([
304
+ 'morning', 'evening', 'summer', 'winter', 'spring', 'autumn',
305
+ 'christmas', 'easter', 'night', 'day'
306
+ ]);
307
+ const LEFT_STRESS_MEASURE = new Set(['pint', 'dollar', 'foot', 'mile']);
308
+ const LEFT_STRESS_LOCATION = new Set([
309
+ 'city', 'mountain', 'river', 'street', 'valley', 'island',
310
+ 'town', 'village', 'country'
311
+ ]);
312
+ const LEFT_STRESS_SELF = new Set(['self']);
313
+
314
+ // "Discard / ruin / spectral" noun-modifiers (N1) that reliably forestress as
315
+ // compounds: WASTE·land, SCRAP·yard, JUNK·yard, GHOST·town, DEAD·line,
316
+ // DUST·bowl, GRAVE·yard, BONE·yard, DEATH·bed. (Eliot's "WASTE shore".)
317
+ const LEFT_STRESS_DISCARD = new Set([
318
+ 'waste', 'scrap', 'junk', 'ghost', 'dead', 'dust', 'grave', 'bone',
319
+ 'death', 'trash', 'garbage', 'ash', 'blood', 'rust', 'wreck',
320
+ ]);
321
+ // Elemental / landscape noun-modifiers (N1) that reliably forestress:
322
+ // SEA·shore, MOON·light, STORM·cloud, WIND·mill, FIRE·place, SALT·marsh,
323
+ // FROST·bite, SAND·bar, SNOW·flake, TIDE·water, SHADOW·land.
324
+ const LEFT_STRESS_ELEMENTAL = new Set([
325
+ 'sea', 'moon', 'sun', 'star', 'storm', 'wind', 'fire', 'rain', 'snow',
326
+ 'ice', 'tide', 'wave', 'frost', 'mist', 'fog', 'mud', 'sand', 'salt',
327
+ 'earth', 'sky', 'dawn', 'dusk', 'shadow', 'flame', 'ember', 'smoke',
328
+ 'cloud', 'water', 'dew', 'hail', 'marsh', 'moor', 'flood', 'foam',
329
+ ]);
330
+ // Fire / light-source N1 modifiers that forestress like the elemental set:
331
+ // TORCH·light, CANDLE·light, LAMP·light, LANTERN·light, BEACON·fire,
332
+ // HEARTH·stone, COAL·fire, GAS·light — and Pound's hyphenated TORCH·flames
333
+ // (parallel to WASTE·shore; "flames" is the head, "torch" the modifier).
334
+ const LEFT_STRESS_FIRELIGHT = new Set([
335
+ 'torch', 'candle', 'lamp', 'lantern', 'beacon', 'hearth', 'coal', 'gas',
336
+ ]);
337
+ // Vehicle / conveyance N1 modifiers that forestress: SLEIGH·bells/blades,
338
+ // CART·wheel, WAGON·train, CAR·door, TRAIN·station, BOAT·house, TROLLEY·tickets.
339
+ // (Endocentric N+N where N1 is the conveyance the N2 belongs to / is part of.)
340
+ const LEFT_STRESS_VEHICLE = new Set([
341
+ 'sleigh', 'sled', 'cart', 'wagon', 'carriage', 'coach', 'train', 'tram',
342
+ 'trolley', 'car', 'boat', 'ship', 'plane', 'truck', 'bus', 'bike', 'bicycle',
343
+ ]);
344
+ // Head nouns (N2) that keep phrasal/right stress even after a forestress
345
+ // modifier — chiefly food "made of N1" and a few lexical exceptions:
346
+ // apple PIE, summer DAY, Fifth AVenue. These carve-outs keep the rule honest
347
+ // (a wrong forestress would mis-teach learners), so they OVERRIDE the N1 sets.
348
+ const RIGHT_STRESS_HEADS = new Set([
349
+ 'pie', 'cake', 'tart', 'pudding', 'mousse', 'soup', 'salad', 'sauce',
350
+ 'juice', 'avenue', 'day',
351
+ ]);
352
+
353
+ /** Check if a pair of words forms a left‑stressed compound. */
354
+ export function isLeftStressedPair(w1: string, w2: string): boolean {
355
+ const first = w1.toLowerCase();
356
+ const second = w2.toLowerCase().replace(/'s$/, '');
357
+ // A right-stressing head overrides any forestress modifier (apple PIE).
358
+ if (RIGHT_STRESS_HEADS.has(second)) return false;
359
+ if (LEFT_STRESS_MATERIAL.has(first)) return true;
360
+ if (LEFT_STRESS_TIME.has(first)) return true;
361
+ if (LEFT_STRESS_MEASURE.has(first)) return true;
362
+ if (LEFT_STRESS_LOCATION.has(first)) return true;
363
+ if (LEFT_STRESS_SELF.has(first)) return true;
364
+ if (LEFT_STRESS_DISCARD.has(first)) return true;
365
+ if (LEFT_STRESS_ELEMENTAL.has(first)) return true;
366
+ if (LEFT_STRESS_FIRELIGHT.has(first)) return true;
367
+ if (LEFT_STRESS_VEHICLE.has(first)) return true;
368
+ return false;
369
+ }
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
+
411
+ /**
412
+ * Lexicalised forestress COLLOCATIONS — fixed two-word phrases that stress the
413
+ * LEFT element, even though the second word is not a noun (so the N+N/J+N
414
+ * Compound Stress Rule does not reach them). "GOOD old days/friend";
415
+ * "the be-all and END-all". Each entry's optional guard suppresses spurious
416
+ * firing (e.g. "End ALL the wars" — the *verb* "end" + quantifier "all the
417
+ * wars" — must NOT forestress; there "all" is a predeterminer PDT).
418
+ */
419
+ const LEFT_STRESS_COLLOCATIONS: { w1: string; w2: string; ok?: (b: ClsWord) => boolean }[] = [
420
+ { w1: 'good', w2: 'old' }, // GOOD old days
421
+ { w1: 'end', w2: 'all', ok: w => w.lexicalClass !== 'PDT' }, // END-all (idiom), not "end ALL the wars"
422
+ ];
423
+
424
+ /** True if (w1,w2) is a lexicalised forestress collocation in this context. */
425
+ function isLeftStressedCollocation(w1: ClsWord, w2: ClsWord): boolean {
426
+ const b1 = w1.word.toLowerCase().replace(/[^a-z]/g, '');
427
+ const b2 = w2.word.toLowerCase().replace(/[^a-z]/g, '');
428
+ for (const c of LEFT_STRESS_COLLOCATIONS) {
429
+ if (b1 === c.w1 && b2 === c.w2 && (!c.ok || c.ok(w2))) return true;
430
+ }
431
+ return false;
432
+ }
433
+
434
+ // ─── LEXICAL STRESS (pronouncingjs) ───────────────────────────────
435
+
436
+ const VOWEL_CHARS = new Set('aeiouyAEIOUY');
437
+
438
+ /** Archaic/locative pronominal compounds whose first element ends in a MEDIAL
439
+ * silent 'e' ("where·fore", "there·in"): the plain vowel-group count reads the
440
+ * 'e' as a nucleus and over-counts. Count the parts instead. */
441
+ const SILENT_E_COMPOUND_RE = /^(where|there|here)(fore|in|by|of|on|upon|at|to|with|out|after|under|unto|abouts?|soever)$/;
442
+
443
+ function countVowelGroups(word: string): number {
444
+ {
445
+ const m = word.toLowerCase().replace(/[^a-z]/g, '').match(SILENT_E_COMPOUND_RE);
446
+ // Closed-class second elements; counted directly ("fore" would otherwise
447
+ // read 2 — the small-word guard blocks the final-silent-e deduction).
448
+ if (m) return 1 + (m[2] === 'soever' ? 3
449
+ : /^(upon|after|under|unto|about)/.test(m[2]) ? 2 : 1);
450
+ }
451
+ const lower = word.toLowerCase().replace(/-/g, '').replace(/'s/g, '').replace(/'/g, '');
452
+ const n = lower.length;
453
+ let groups = 0;
454
+ let inVowel = false;
455
+ const vowelPositions: number[] = [];
456
+ for (let i = 0; i < n; i++) {
457
+ if (VOWEL_CHARS.has(lower[i])) {
458
+ if (!inVowel) { groups++; vowelPositions.push(i); inVowel = true; }
459
+ } else {
460
+ inVowel = false;
461
+ }
462
+ }
463
+ if (groups >= 3 && n > 2 && lower[n - 1] === 'e' && VOWEL_CHARS.has(lower[n - 1])) {
464
+ const lastVowelStart = vowelPositions[vowelPositions.length - 1];
465
+ if (lastVowelStart === n - 1) {
466
+ groups--;
467
+ }
468
+ }
469
+ return groups;
470
+ }
471
+
472
+ // ─── OUT-OF-VOCABULARY STRESS (two-tier fallback) ─────────────────
473
+ //
474
+ // When a word is absent from the augmented CMU dictionary, the old fallback
475
+ // blindly forestressed it (primary on syllable 0). That mis-stresses the most
476
+ // common OOV case — *inflected/derived forms of common words* whose base IS in
477
+ // the lexicon ("voyaging" OOV, "voyage" present) — and many true OOV words too
478
+ // ("anfractuous" → AN·fractuous rather than an·FRAC·tuous). We replace it with:
479
+ // (1) MORPHOLOGICAL decomposition — strip a stress-neutral productive suffix,
480
+ // reconstruct the stem's orthography, look it up, and reuse the stem's
481
+ // *real* lexical stress (the suffix syllables are unstressed).
482
+ // (2) the English Stress Rule (quantity-sensitive) for the genuine residual
483
+ // (names, neologisms) with no recognisable stem.
484
+ // Both run ONLY in the OOV branch, so in-vocabulary scansion is untouched.
485
+
486
+ /** Strip one trailing doubled consonant (run·ning → run, stop·ped → stop). */
487
+ function deDouble(b: string): string {
488
+ const m = b.match(/([^aeiou])\1$/i);
489
+ return m ? b.slice(0, -1) : b;
490
+ }
491
+
492
+ /** True if a stem ends in a sibilant/affricate, so a following -s/-es is its own
493
+ * syllable (kiss·es, box·es, voy·a·ges) rather than a bare coda (cats). */
494
+ function isSibilantEnd(s: string): boolean {
495
+ return /(s|z|x|sh|ch|ce|ge|se|ze|dge|tch)$/i.test(s);
496
+ }
497
+
498
+ /**
499
+ * Stress-neutral productive suffixes (Hayes: these do not shift the stem's
500
+ * stress). `stems(base)` lists candidate stem spellings to try (order = most
501
+ * likely first); `added(stem)` is how many *syllables* the suffix contributes.
502
+ * Stress-SHIFTING suffixes (-ion/-ity/-ic/-ial/-ious/-ify…) are deliberately
503
+ * omitted — treating them as neutral would mis-place the peak; they fall through
504
+ * to the English Stress Rule (and are common enough to usually be in-lexicon).
505
+ */
506
+ const SUFFIX_RULES: { suffix: string; stems: (b: string) => string[]; added: (stem: string) => number }[] = [
507
+ { suffix: 'iness', stems: b => [b + 'y'], added: () => 1 }, // happi·ness ← happy
508
+ { suffix: 'ily', stems: b => [b + 'y'], added: () => 1 }, // happi·ly ← happy
509
+ { suffix: 'ies', stems: b => [b + 'y'], added: () => 0 }, // car·ries ← carry
510
+ { suffix: 'ied', stems: b => [b + 'y'], added: () => 0 }, // car·ried ← carry
511
+ { suffix: 'ness', stems: b => [b], added: () => 1 },
512
+ { suffix: 'ment', stems: b => [b], added: () => 1 },
513
+ { suffix: 'less', stems: b => [b], added: () => 1 },
514
+ { suffix: 'ful', stems: b => [b], added: () => 1 },
515
+ { suffix: 'ings', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 },
516
+ { suffix: 'ing', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 }, // voy·a·ging ← voyage
517
+ { suffix: 'est', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 },
518
+ { suffix: 'ed', stems: b => [b + 'e', b, deDouble(b)], added: stem => /[td]$/.test(stem) ? 1 : 0 },
519
+ { suffix: 'eth', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 }, // archaic 3sg: go·eth, fall·eth, mak·eth
520
+ { suffix: 'ith', stems: b => [b + 'y', b + 'e', b], added: () => 1 }, // archaic 3sg of -y verbs: sa·ith ← say
521
+ { suffix: 'er', stems: b => [b + 'e', b, deDouble(b)], added: () => 1 },
522
+ { suffix: 'ly', stems: b => [b], added: () => 1 }, // soft·ly ← soft
523
+ { suffix: 'es', stems: b => [b, b + 'e'], added: stem => isSibilantEnd(stem) ? 1 : 0 },
524
+ { suffix: 's', stems: b => [b, b + 'e'], added: stem => isSibilantEnd(stem) ? 1 : 0 },
525
+ ];
526
+
527
+ /**
528
+ * Tier 1 — derive an OOV word's numeric stress (2=primary, 1=secondary, 0=none)
529
+ * by stripping a stress-neutral suffix and reusing the in-lexicon stem's stress.
530
+ * Returns null if no productive suffix yields a known stem.
531
+ */
532
+ function morphologicalStress(w: string): { pattern: number[]; suffix: string } | null {
533
+ for (const rule of SUFFIX_RULES) {
534
+ if (!w.endsWith(rule.suffix)) continue;
535
+ const base = w.slice(0, w.length - rule.suffix.length);
536
+ if (base.length < 2) continue; // guard tiny stems (sing → s+ing)
537
+ for (const stem of rule.stems(base)) {
538
+ if (stem.length < 2) continue;
539
+ const data = nounsing.all(stem);
540
+ const raw = data && data.length ? (data[0].stress?.stressTrans || '') : '';
541
+ if (!raw) continue;
542
+ const stemNumeric = [...raw].map(c => mapCMUStress(parseInt(c, 10)));
543
+ if (stemNumeric.length === 0) continue;
544
+ const added = rule.added(stem);
545
+ return { pattern: [...stemNumeric, ...new Array(added).fill(0)], suffix: added >= 1 ? rule.suffix : '' };
546
+ }
547
+ }
548
+ return null;
549
+ }
550
+
551
+ /** Archaic verbal suffixes whose orthographic peel cleanly separates a silent-
552
+ * consonant stem from the suffix for DISPLAY (know·est not kno·west). Other
553
+ * suffixes keep the default orthographic syllabifier (it handles them well). */
554
+ const DISPLAY_PEEL_SUFFIXES = new Set(['est', 'eth', 'ith']);
555
+
556
+ /** Heavy syllable (orthographic estimate): long vowel (digraph/VCe) or closed
557
+ * by a coda consonant. Light = open with a single short vowel. */
558
+ function syllableIsHeavy(syl: string): boolean {
559
+ const s = syl.toLowerCase();
560
+ if (/[aeiouy]{2}/.test(s)) return true; // vowel digraph / diphthong → long
561
+ if (/[aeiou][^aeiouy]e$/.test(s)) return true; // V·C·e → long ("ate", "ime")
562
+ if (/[^aeiouy]$/.test(s)) return true; // closed syllable (coda present)
563
+ return false;
564
+ }
565
+
566
+ /**
567
+ * Pre-stressing derivational suffixes (Hayes' "pre-stress 1/2"): they fix the
568
+ * primary on a syllable counted from the word's end (`offset` = syllables back,
569
+ * so primary index = n − offset). -ic/-tion fix the penult (offset 2),
570
+ * -ity/-graphy/-ical fix the antepenult (offset 3). Longest-match-first
571
+ * (enforced by the length sort below).
572
+ *
573
+ * The 2026-06-10 batch was DERIVED from the augmented CMU data itself
574
+ * (nounsing's `suffixType` shift classes cross-checked against the `mainStress`
575
+ * column over 3+-syllable words; every adopted ending ≥ 0.90 purity, most ≥ 0.96,
576
+ * N ≥ 60). This includes onomastic endings (-ski/-sky/-son/-berg/-gton …) that
577
+ * matter for OOV proper names — frequent in translation work. `-ary` is
578
+ * preantepenult and only fires on 4+-syllable words (the n ≥ offset guard),
579
+ * so BI-na-ry / ca-NA-ry style 3-syllable words fall through safely.
580
+ * NOTE: vowel-hiatus suffixes (-ion/-ial/-ious) can be undercounted by the
581
+ * orthographic syllable counter, so those stay approximate (documented limit).
582
+ */
583
+ const PRESTRESS_SUFFIXES: { suffix: string; offset: number }[] = [
584
+ // hand-curated originals (Hayes)
585
+ { suffix: 'graphy', offset: 3 }, { suffix: 'ically', offset: 4 },
586
+ { suffix: 'ation', offset: 2 }, { suffix: 'ition', offset: 2 },
587
+ { suffix: 'itude', offset: 3 }, { suffix: 'ical', offset: 3 },
588
+ { suffix: 'logy', offset: 3 }, { suffix: 'nomy', offset: 3 },
589
+ { suffix: 'cracy', offset: 3 }, { suffix: 'pathy', offset: 3 },
590
+ { suffix: 'meter', offset: 3 }, { suffix: 'tion', offset: 2 },
591
+ { suffix: 'sion', offset: 2 }, { suffix: 'ity', offset: 3 },
592
+ { suffix: 'ety', offset: 3 }, { suffix: 'ify', offset: 3 },
593
+ { suffix: 'ics', offset: 2 }, { suffix: 'ic', offset: 2 },
594
+ // data-derived 2026-06-10: final-stressing (ultShift)
595
+ { suffix: 'ette', offset: 1 }, { suffix: 'ese', offset: 1 },
596
+ { suffix: 'eer', offset: 1 }, { suffix: 'ique', offset: 1 },
597
+ // -oon is a reliable final-stresser (bal·LOON, car·TOON, co·COON, after·NOON);
598
+ // OOV-only. -ee/-ade are deliberately NOT added: they are impure (COF·fee,
599
+ // com·RADE, DEC·ade) and would mis-stress more than they fix.
600
+ { suffix: 'oon', offset: 1 },
601
+ // data-derived: penult-stressing
602
+ { suffix: 'ion', offset: 2 }, { suffix: 'sive', offset: 2 },
603
+ { suffix: 'lla', offset: 2 }, { suffix: 'llo', offset: 2 },
604
+ { suffix: 'lli', offset: 2 }, { suffix: 'tti', offset: 2 },
605
+ { suffix: 'ina', offset: 2 }, { suffix: 'ino', offset: 2 },
606
+ { suffix: 'ano', offset: 2 }, { suffix: 'ana', offset: 2 },
607
+ { suffix: 'ini', offset: 2 },
608
+ { suffix: 'ski', offset: 2 }, { suffix: 'sky', offset: 2 },
609
+ // data-derived: antepenult-stressing
610
+ { suffix: 'ate', offset: 3 }, { suffix: 'cal', offset: 3 },
611
+ { suffix: 'onal', offset: 3 }, { suffix: 'nger', offset: 3 },
612
+ { suffix: 'son', offset: 3 }, { suffix: 'man', offset: 3 },
613
+ { suffix: 'berg', offset: 3 }, { suffix: 'gton', offset: 3 },
614
+ // data-derived: preantepenult-stressing (4+ syllables only via the guard)
615
+ { suffix: 'ary', offset: 4 },
616
+ ].sort((a, b) => b.suffix.length - a.suffix.length);
617
+
618
+ /**
619
+ * Tier 2 — the English Stress Rule for genuine OOV (no recognisable stem).
620
+ * First honours a pre-stressing derivational suffix (terRIF·ic, ac·TIV·i·ty,
621
+ * pho·TOG·ra·phy). Otherwise it is quantity-sensitive with final-syllable
622
+ * extrametricality: monosyllables take primary; disyllables keep the English
623
+ * forestress default; for 3+ syllables the final is extrametrical and stress
624
+ * falls on a heavy penult, else the antepenult (Hayes 1982). This fixes e.g.
625
+ * an·FRAC·tuous / e·NIG·ma where blind forestress erred.
626
+ */
627
+ function englishStressRule(w: string, isContent: boolean): number[] {
628
+ const n = countVowelGroups(w);
629
+ const primary = isContent ? 2 : 1;
630
+ if (n <= 1) return [primary];
631
+ for (const { suffix, offset } of PRESTRESS_SUFFIXES) {
632
+ if (w.endsWith(suffix) && n >= offset) {
633
+ const pattern = new Array(n).fill(0);
634
+ pattern[n - offset] = primary;
635
+ return pattern;
636
+ }
637
+ }
638
+ if (n === 2) return [primary, 0]; // English disyllabic default (trochaic)
639
+ const sylls = syllabifyWord(w, n);
640
+ const pattern = new Array(n).fill(0);
641
+ const penult = n - 2; // final (n-1) is extrametrical
642
+ const heavyPenult = sylls[penult] ? syllableIsHeavy(sylls[penult]) : true;
643
+ pattern[heavyPenult ? penult : Math.max(0, n - 3)] = primary;
644
+ return pattern;
645
+ }
646
+
647
+ /**
648
+ * Per-syllable heaviness from nounsing's `syllStruct` CV transcription
649
+ * ("L.CL.CLC": C = consonant, L = lax/short nucleus, T = tense/long nucleus).
650
+ * Heavy = tense nucleus OR closed syllable (a coda consonant after the nucleus).
651
+ * Returns undefined when the segment count doesn't match the syllable count, so
652
+ * callers fall back to the orthographic estimate.
653
+ */
654
+ function heavyFromSyllStruct(syllStruct: string | undefined, n: number): boolean[] | undefined {
655
+ if (!syllStruct) return undefined;
656
+ const segs = syllStruct.split('.');
657
+ if (segs.length !== n) return undefined;
658
+ return segs.map(seg => {
659
+ const vi = seg.search(/[LT]/);
660
+ if (vi < 0) return false;
661
+ return seg[vi] === 'T' || vi < seg.length - 1;
662
+ });
663
+ }
664
+
665
+ /**
666
+ * The syllable index that should bear the default stress of a polysyllabic word
667
+ * whose dictionary entry records NO stress at all (an all-zero pattern — the
668
+ * maximally-reduced citation form of a few function words, chiefly "into"=00).
669
+ * Every lexical word bears at least one stress, so we restore it: a pre-stressing
670
+ * suffix fixes the count-from-end syllable; otherwise the English forestress
671
+ * default for disyllables (IN-to, ON-to), and the quantity-sensitive penult/
672
+ * antepenult (Hayes) for longer words. Mirrors englishStressRule's placement.
673
+ * `heavyFlags` (real per-syllable quantity from nounsing's syllStruct) replaces
674
+ * the orthographic heaviness guess when the word is in-vocabulary.
675
+ */
676
+ function defaultStressIndex(word: string, n: number, heavyFlags?: boolean[]): number {
677
+ for (const { suffix, offset } of PRESTRESS_SUFFIXES) {
678
+ if (word.endsWith(suffix) && n >= offset) return n - offset;
679
+ }
680
+ if (n <= 2) return 0; // English disyllabic forestress default
681
+ const penult = n - 2; // final (n-1) extrametrical
682
+ const heavyPenult = heavyFlags
683
+ ? heavyFlags[penult]
684
+ : (() => { const sylls = syllabifyWord(word, n); return sylls[penult] ? syllableIsHeavy(sylls[penult]) : true; })();
685
+ return heavyPenult ? penult : Math.max(0, n - 3);
686
+ }
687
+
688
+ /**
689
+ * Map CMU stress (0=unstressed, 1=primary, 2=secondary) to
690
+ * McAleese's numeric scale: 0=unstressed, 1=secondary, 2=primary.
691
+ */
692
+ function mapCMUStress(cmuStress: number): number {
693
+ if (cmuStress === 1) return 2; // primary → 2
694
+ if (cmuStress === 2) return 1; // secondary → 1
695
+ return 0; // unstressed → 0
696
+ }
697
+
698
+ /**
699
+ * Assign per‑syllable lexical stress to each word in a sentence.
700
+ *
701
+ * Uses the first CMU pronunciation. Function words have their
702
+ * primary stress downgraded to secondary (2 → 1).
703
+ */
704
+ export function assignLexicalStress(words: ClsWord[]): void {
705
+ for (let wi = 0; wi < words.length; wi++) {
706
+ const word = words[wi];
707
+ if (isPunctuation(word.lexicalClass)) {
708
+ word.syllables = [];
709
+ continue;
710
+ }
711
+
712
+ // Explicitly assign 0 syllables to possessive/contraction clitic "'s"
713
+ if (word.word === "'s") {
714
+ word.syllables = [];
715
+ continue;
716
+ }
717
+
718
+ // Poetic aphaeresis clipping ('neath, o'er, 'gainst…) → treat as the reduced
719
+ // function word it stands for, instead of the OOV default (NNP → stressed).
720
+ // Guard on an actual apostrophe (split off as the prior token, or internal),
721
+ // so a literal "mid"/"side"/"cross" is untouched.
722
+ {
723
+ const bare = word.word.toLowerCase().replace(/['’]/g, '');
724
+ const hasApostrophe = /['’]/.test(word.word)
725
+ || (wi > 0 && (words[wi - 1].word === "'" || words[wi - 1].word === '’'));
726
+ if (hasApostrophe && APHAERESIS_CLITICS.has(bare)) {
727
+ word.isContent = false;
728
+ // One weak monosyllable; lexical 0 + function ⇒ maps to 'x' (reduced clitic).
729
+ word.syllables = [{ text: word.word, phones: '', stress: 0, lexicalStress: 0 }];
730
+ continue;
731
+ }
732
+ }
733
+
734
+ let lookupWord = word.word.toLowerCase();
735
+
736
+ // Letter-name dictionary anomalies ("am" = A.M., "us" = U.S.): stamp the
737
+ // ordinary citation monosyllable directly (see ANOMALOUS_MONOSYLLABLES).
738
+ {
739
+ const fix = ANOMALOUS_MONOSYLLABLES[lookupWord];
740
+ if (fix) {
741
+ const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
742
+ word.isContent = isContent;
743
+ const numeric = fix.stress ?? (isContent ? 2 : 1); // citation primary; function words reduce to secondary
744
+ word.syllables = [{ text: word.word, phones: fix.syllab, weight: fix.weight ?? 'H', stress: numeric, lexicalStress: numeric }];
745
+ continue;
746
+ }
747
+ }
748
+
749
+ // Elided article fused to its host (th'expense, th'inconstant): "th'" is
750
+ // non-syllabic, so the HOST word's dictionary entry is the right source for
751
+ // stress and syllable count — otherwise the fused token goes OOV and takes
752
+ // the disyllabic forestress default (TH'EX-pense instead of th'ex-PENSE).
753
+ {
754
+ const m = lookupWord.match(/^th['’](.+)$/);
755
+ if (m && m[1].length >= 2) lookupWord = m[1];
756
+ }
757
+
758
+ let allData = nounsing.all(lookupWord);
759
+ if (!allData && lookupWord.includes('-')) {
760
+ const noHyphen = lookupWord.replace(/-/g, '');
761
+ allData = nounsing.all(noHyphen);
762
+ }
763
+ if ((!allData || allData.length === 0) && lookupWord.includes('-')) {
764
+ const parts = lookupWord.split('-');
765
+ const partStresses: string[] = [];
766
+ const partWeights: string[] = [];
767
+ for (const part of parts) {
768
+ const partData = nounsing.all(part);
769
+ if (partData && partData.length > 0) {
770
+ partStresses.push(partData[0].stress.stressTrans || '');
771
+ partWeights.push(partData[0].weightPattern || '');
772
+ }
773
+ }
774
+ if (partStresses.length === parts.length && partStresses.every(s => s.length > 0)) {
775
+ const combinedStress = partStresses.join('');
776
+ const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
777
+ word.isContent = isContent;
778
+ const syls: Syllable[] = [];
779
+ for (let i = 0; i < combinedStress.length; i++) {
780
+ const cmu = parseInt(combinedStress[i], 10);
781
+ let numeric = mapCMUStress(cmu);
782
+ if (!isContent && numeric === 2) numeric = 1;
783
+ syls.push({ text: word.word, phones: '', stress: numeric, lexicalStress: numeric });
784
+ }
785
+ word.syllables = syls;
786
+ continue;
787
+ }
788
+ }
789
+ if (!allData || allData.length === 0) {
790
+ const cleanWord = word.word.toLowerCase().replace(/-/g, '').replace(/['’]/g, '');
791
+ const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
792
+ word.isContent = isContent;
793
+ // Tier 1: morphological stem (reuse real lexical stress); Tier 2: ESR.
794
+ const morph = morphologicalStress(cleanWord);
795
+ const pattern = morph ? morph.pattern : englishStressRule(cleanWord, isContent);
796
+ // Record an archaic verbal suffix so the display splits know·est, not kno·west.
797
+ if (morph && DISPLAY_PEEL_SUFFIXES.has(morph.suffix)) word.morphSuffix = morph.suffix;
798
+ const syls: Syllable[] = pattern.map(numeric => {
799
+ // Mirror the in-vocab function-word reduction (primary → secondary).
800
+ const n = (!isContent && numeric === 2) ? 1 : numeric;
801
+ return { text: word.word, phones: '', stress: n, lexicalStress: n };
802
+ });
803
+ word.syllables = syls;
804
+ continue;
805
+ }
806
+
807
+ // For nouns with multiple pronunciations, prefer front‑stressed (noun form).
808
+ let profile = allData[0];
809
+ if (allData.length > 1 && word.lexicalClass.startsWith('N')) {
810
+ for (const p of allData) {
811
+ const stressStr = p.stress.stressTrans;
812
+ if (stressStr && stressStr.length > 0 && (stressStr[0] === '1' || stressStr[0] === '2')) {
813
+ profile = p;
814
+ break;
815
+ }
816
+ }
817
+ }
818
+
819
+ let rawStress = profile.stress.stressTrans || ''; // e.g., "010"
820
+
821
+ // The CMU syllabification is authoritative for the syllable count. The
822
+ // orthographic vowel-group count UNDER-counts vowel-hiatus / glide words
823
+ // (goo·ey, play·ers, be·ing each read as a single vowel run), so it must NOT
824
+ // truncate the dictionary's count — doing so collapsed those to one syllable.
825
+ // Only clamp when stressTrans is genuinely LONGER than the CMU
826
+ // syllabification (a rare data inconsistency).
827
+ const syllsMatch = (profile.phonology.syllabification || '').match(/\([^)]+\)/g) || [];
828
+ if (syllsMatch.length > 0 && rawStress.length > syllsMatch.length) {
829
+ rawStress = rawStress.slice(0, syllsMatch.length);
830
+ }
831
+
832
+ // Synaeresis (verse vowel-gliding): an UNSTRESSED open syllable ending in a
833
+ // high-front vowel (IY/IH), followed by an UNSTRESSED vowel-initial syllable,
834
+ // glides into one syllable in verse — As·syr·i·an → as·syr·yan, var·i·ous →
835
+ // var·yous, glor·i·ous → glor·yous. It does NOT fire on a stressed nucleus
836
+ // (be·ing, i·DE·a) or before a stressed vowel (cre·ATE), so those keep their
837
+ // full count. Distinct from the (removed) orthographic truncation: it merges
838
+ // only genuine glide pairs, leaving goo·ey / play·ers / po·et intact.
839
+ if (syllsMatch.length === rawStress.length && rawStress.length >= 2) {
840
+ const tokensOf = (s: string) => s.replace(/[()]/g, '').trim().split(/\s+/).filter(Boolean);
841
+ const mStress: string[] = [];
842
+ const mSylls: string[] = [];
843
+ for (let i = 0; i < rawStress.length; i++) {
844
+ const cur = tokensOf(syllsMatch[i]);
845
+ const last = cur[cur.length - 1] ?? '';
846
+ const next = i + 1 < rawStress.length ? tokensOf(syllsMatch[i + 1]) : [];
847
+ if (i + 1 < rawStress.length
848
+ && rawStress[i] === '0' && rawStress[i + 1] === '0'
849
+ && (last === 'IY' || last === 'IH')
850
+ && /^[AEIOU]/.test(next[0] ?? '')) {
851
+ mStress.push('0');
852
+ mSylls.push('(' + cur.concat(next).join(' ') + ')');
853
+ i++; // absorb the glided syllable
854
+ } else {
855
+ mStress.push(rawStress[i]);
856
+ mSylls.push(syllsMatch[i]);
857
+ }
858
+ }
859
+ rawStress = mStress.join('');
860
+ syllsMatch.splice(0, syllsMatch.length, ...mSylls);
861
+ }
862
+
863
+ // All-zero CMU pattern on a polysyllabic word: restore the default stress.
864
+ // A handful of reduced function words (chiefly "into"=00) are recorded with
865
+ // NO stress at all, which left every syllable at 'x' (in·to = x·x) — both
866
+ // unlike careful usage (IN-to) and metrically inert. Every lexical word
867
+ // bears a stress, so we re-stamp a CMU primary on the default-stress syllable
868
+ // (forestress for disyllables); function-word demotion downstream turns this
869
+ // into a secondary, giving the natural IN-to contour. Only fires on the
870
+ // genuine all-zero artifact, so words that already carry a peak are untouched.
871
+ if (rawStress.length >= 2 && /^0+$/.test(rawStress)) {
872
+ const cw = word.word.toLowerCase().replace(/-/g, '').replace(/['’]/g, '');
873
+ const heavy = heavyFromSyllStruct(profile.phonology.syllStruct, rawStress.length);
874
+ // Rising/iambic function words (be·CAUSE, a·BOUT) recorded fully-reduced
875
+ // take FINAL-syllable stress; everything else keeps the forestress default
876
+ // (IN·to, and polysyllabic content artifacts).
877
+ const idx = RISING_FUNCTION_WORDS.has(cw)
878
+ ? rawStress.length - 1
879
+ : defaultStressIndex(cw, rawStress.length, heavy);
880
+ rawStress = rawStress.split('').map((c, i) => (i === idx ? '1' : '0')).join('');
881
+ }
882
+
883
+ // Targeted fix for the "I'm" anomaly: a subject-pronoun contraction the
884
+ // dictionary records as fully unstressed ("i'm"=0, while "i'll"=1) is restamped
885
+ // to a weak (function) stress, so it reads like its siblings ('n') rather than
886
+ // sinking to Zero-Provision 'x'. Narrow by construction — only fires on a
887
+ // monosyllabic, genuinely all-zero pronoun contraction; everything else is
888
+ // left exactly as it was.
889
+ if (rawStress === '0' && PRONOUN_SUBJECT_CONTRACTIONS.has(lookupWord)) {
890
+ rawStress = '1';
891
+ }
892
+
893
+ const isContent = isContentWord(word.lexicalClass, word.word) || isPhrasalParticle(word) || isFocusDemonstrative(words, wi);
894
+ word.isContent = isContent;
895
+
896
+ const syllables: Syllable[] = [];
897
+ const weightsArray = (profile.weightPattern || '').split(' ').filter(x => x === 'H' || x === 'L');
898
+
899
+ // Determine extrametricality classification for the final syllable.
900
+ // Uses Hayes (1980) constraints: only Light edge syllables, only noun final syllables,
901
+ // morphological s/z (plural/tense) markers, and derivational suffixes in adjectives.
902
+ const sClassifier = profile.S ?? '';
903
+ // Extrametricality is a property of nouns / derived adjectives. Key it off
904
+ // the word's actual sentence POS (from the parser), NOT nounsing's lexical
905
+ // pos — otherwise function words like the preposition "underneath" (which the
906
+ // CMU data may tag nominally) wrongly lose the stress on their final syllable.
907
+ const isNoun = word.lexicalClass.startsWith('N');
908
+ const isAdj = word.lexicalClass.startsWith('JJ');
909
+ const finalWeight = profile.weight.find(w => w.syllable === 'final')?.heaviness ?? '';
910
+ const nsylls = rawStress.length;
911
+
912
+ let extrametricalType: Syllable['extrametrical'] = undefined;
913
+ if (nsylls >= 2) {
914
+ if ((sClassifier === 'S' || sClassifier === 'SCluster') && isNoun) {
915
+ extrametricalType = 'morphological';
916
+ } else if (isNoun && finalWeight === 'L' && nsylls >= 3) {
917
+ extrametricalType = 'light_noun';
918
+ } else if (isAdj && profile.morphology.suffix === 'suffix') {
919
+ extrametricalType = 'derivational';
920
+ }
921
+ }
922
+
923
+ const phonesTokens = profile.phonology.phones.split(' ');
924
+ let phoneIdx = 0;
925
+
926
+ for (let i = 0; i < rawStress.length; i++) {
927
+ const ch = rawStress[i];
928
+ const cmu = parseInt(ch, 10);
929
+ let numeric = mapCMUStress(cmu);
930
+ // Function words are reduced in running speech, but their INTERNAL stress
931
+ // contour must be preserved: demote the primary syllable to secondary AND
932
+ // the secondary syllables to none, so the lexical peak stays the peak.
933
+ // (Flattening primary→secondary alone would tie "un" and "neath" in
934
+ // "underneath", letting a later clash invert it to ÚN-der-neath.)
935
+ if (!isContent) {
936
+ if (numeric === 2) numeric = 1;
937
+ else if (numeric === 1) numeric = 0;
938
+ }
939
+
940
+ const wPatLen = weightsArray.length;
941
+ const rLen = rawStress.length;
942
+ const wIdx = wPatLen - (rLen - i);
943
+ const weight = wIdx >= 0 && wIdx < wPatLen ? weightsArray[wIdx] as 'H' | 'L' : 'L';
944
+
945
+ const sylTextMatch = syllsMatch[i];
946
+ const sylText = sylTextMatch ? sylTextMatch.replace(/[()]/g, '') : word.word;
947
+
948
+ const sylPhonesMatch = syllsMatch[i] || '';
949
+ const isLastSyl = i === rawStress.length - 1;
950
+
951
+ syllables.push({
952
+ text: sylText,
953
+ phones: sylPhonesMatch,
954
+ weight,
955
+ stress: numeric,
956
+ lexicalStress: numeric,
957
+ relativeStress: undefined,
958
+ extrametrical: isLastSyl ? extrametricalType : undefined,
959
+ });
960
+ }
961
+
962
+ if (extrametricalType) {
963
+ word.lexicalDetails = `extrametrical_${extrametricalType}`;
964
+ }
965
+
966
+ // A focus demonstrative ("What's THAT?", "Give me THIS.") carries PRIMARY
967
+ // stress; CMU lists the weak/reduced (complementizer) form, which would leave
968
+ // it merely 'n' after the nuclear boost. Force its peak to primary so the
969
+ // sentence's prominence lands on it.
970
+ if (isFocusDemonstrative(words, wi) && syllables.length > 0) {
971
+ const pk = syllables.reduce((a, b) => (b.stress >= a.stress ? b : a));
972
+ pk.stress = 2;
973
+ pk.lexicalStress = 2;
974
+ }
975
+
976
+ word.syllables = syllables;
977
+ }
978
+ }
979
+
980
+ // ─── COMPOUND STRESS RULE ─────────────────────────────────────────
981
+
982
+ /**
983
+ * Adjust stresses for nominal compounds.
984
+ *
985
+ * Fore-stressed by default: an N+N compound puts primary (2) on the first
986
+ * element, secondary (1) on the second — the Compound Stress Rule (Chomsky–
987
+ * Halle; McAleese marks ICE cream with primary on "ice", KITCHen table,
988
+ * WINdow frame). The marked right-stress exceptions — food/temporal "made of"
989
+ * heads (apple PIE), Adj+N phrases (sweet CREAM), and proper-name sequences
990
+ * (New YORK) — reverse it. All of this lives in `compoundStressSide`.
991
+ */
992
+ export function applyCompoundStress(ius: IntonationalUnit[]): void {
993
+ for (const iu of ius) {
994
+ for (const pp of iu.phonologicalPhrases) {
995
+ const words = collectPPTokens(pp);
996
+ // We don't want compound stress applied between arbitrary words across a phrase!
997
+ // Only apply to ADJACENT content words!
998
+ const contentWords = words.filter(w => w.isContent);
999
+ for (let i = 0; i < contentWords.length - 1; i++) {
1000
+ const w1 = contentWords[i];
1001
+ const w2 = contentWords[i + 1];
1002
+
1003
+ // Wait, they must be adjacent in the sentence!
1004
+ if (Math.abs(w1.absoluteIndex - w2.absoluteIndex) !== 1) continue;
1005
+
1006
+ const side = compoundStressSide(w1.word, w1.lexicalClass, w2.word, w2.lexicalClass);
1007
+ if (side === null) continue;
1008
+
1009
+ if (side === 'left') {
1010
+ setPrimaryStress(w1, 2);
1011
+ setPrimaryStress(w2, 1);
1012
+ } else {
1013
+ setPrimaryStress(w1, 1);
1014
+ setPrimaryStress(w2, 2);
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+
1021
+ /** Locate the syllable with the highest stress and set it to `value`. */
1022
+ export function setPrimaryStress(word: ClsWord, value: number): void {
1023
+ let maxIdx = -1;
1024
+ let maxVal = -1;
1025
+ for (let i = 0; i < word.syllables.length; i++) {
1026
+ if (word.syllables[i].stress > maxVal) {
1027
+ maxVal = word.syllables[i].stress;
1028
+ maxIdx = i;
1029
+ }
1030
+ }
1031
+ if (maxIdx >= 0) {
1032
+ word.syllables[maxIdx].stress = value;
1033
+ }
1034
+ }
1035
+
1036
+ // ─── NUCLEAR STRESS RULE ──────────────────────────────────────────
1037
+
1038
+ /**
1039
+ * Recursively assign higher stress to content words from right to left.
1040
+ * Only the rightmost content word receives a boost (+1 above lexical primary).
1041
+ * All other content words keep their lexical stress.
1042
+ * This preserves lexical stress for meter detection while still indicating
1043
+ * the nuclear accent for phonological phrasing.
1044
+ */
1045
+ export function applyNuclearStress(ius: IntonationalUnit[]): void {
1046
+ for (const iu of ius) {
1047
+ for (const pp of iu.phonologicalPhrases) {
1048
+ const words = collectPPTokens(pp).sort((a, b) => a.index - b.index);
1049
+ // The nuclear accent normally lands on the rightmost CONTENT word.
1050
+ let target: ClsWord | null = null;
1051
+ for (let i = words.length - 1; i >= 0; i--) {
1052
+ if (words[i].isContent) { target = words[i]; break; }
1053
+ }
1054
+ // But a content-less phrase ending in a non-oblique personal pronoun puts
1055
+ // the nuclear accent on that final pronoun (NSR: the accent falls on the
1056
+ // last accentable item; a clause-final addressee/focus pronoun bears it —
1057
+ // "…to YOU"). Only when there is no content word to carry it, so "I KNOW
1058
+ // you" is untouched; oblique objects (me/him/them) stay deaccented.
1059
+ if (!target) {
1060
+ let lastIdx = -1;
1061
+ for (let i = words.length - 1; i >= 0; i--) {
1062
+ if (!isPunctuation(words[i].lexicalClass)) { lastIdx = i; break; }
1063
+ }
1064
+ if (lastIdx >= 0) {
1065
+ const w = words[lastIdx];
1066
+ if (w.lexicalClass === 'PRP'
1067
+ && !OBLIQUE_PRONOUNS.has(w.word.toLowerCase().replace(/['’]/g, ''))) {
1068
+ target = w;
1069
+ }
1070
+ }
1071
+ }
1072
+ if (target) {
1073
+ let maxIdx = -1;
1074
+ let maxVal = -1;
1075
+ for (let j = 0; j < target.syllables.length; j++) {
1076
+ if (target.syllables[j].stress > maxVal) {
1077
+ maxVal = target.syllables[j].stress;
1078
+ maxIdx = j;
1079
+ }
1080
+ }
1081
+ if (maxIdx >= 0) target.syllables[maxIdx].stress += 1;
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ // ─── RELATIVE STRESS ASSIGNMENT (4‑LEVEL) ─────────────────────────
1088
+
1089
+ /**
1090
+ * Phrase-edge sets used for the "endings strict / beginnings loose" floor gate.
1091
+ * McAleese (after Hayes & Kaun): metrical and lexical stress coincide at the
1092
+ * ENDS of phonological units (clitic phrase 90% / phonological phrase 97% /
1093
+ * intonational unit 99%); Selkirk notes a function word is not reduced at the
1094
+ * end of a clitic phrase ("of" never → "o'" there). So a function word at the
1095
+ * right edge of a PP or IU — or the phrase-stress PEAK (nucleus) of its PP —
1096
+ * resists the citation-floor reduction, while interior / left-edge function
1097
+ * words reduce as before. Reads the Phrase-Stress phase (word.phraseStress).
1098
+ */
1099
+ function phraseEdgeSets(ius: IntonationalUnit[]): {
1100
+ ppFinal: Set<ClsWord>; iuFinal: Set<ClsWord>; ppPeak: Set<ClsWord>;
1101
+ } {
1102
+ const ppFinal = new Set<ClsWord>();
1103
+ const iuFinal = new Set<ClsWord>();
1104
+ const ppPeak = new Set<ClsWord>();
1105
+ for (const iu of ius) {
1106
+ let iuLast: ClsWord | null = null;
1107
+ for (const pp of iu.phonologicalPhrases) {
1108
+ const toks = collectPPTokens(pp)
1109
+ .filter(w => !isPunctuation(w.lexicalClass))
1110
+ .sort((a, b) => a.index - b.index);
1111
+ if (toks.length === 0) continue;
1112
+ const last = toks[toks.length - 1];
1113
+ ppFinal.add(last);
1114
+ iuLast = last;
1115
+ let peak = toks[0];
1116
+ let peakVal = peak.phraseStress ?? 0;
1117
+ for (const t of toks) {
1118
+ const v = t.phraseStress ?? 0;
1119
+ if (v > peakVal) { peak = t; peakVal = v; }
1120
+ }
1121
+ // Only protect a genuine nuclear ramp peak (phraseStress ≥ 2): an all-floor
1122
+ // PP (all function words, or the Phase-Stress phase not run) has no nucleus
1123
+ // to protect, so we must not spuriously shield its first token.
1124
+ if (peakVal >= 2) ppPeak.add(peak);
1125
+ }
1126
+ if (iuLast) iuFinal.add(iuLast);
1127
+ }
1128
+ return { ppFinal, iuFinal, ppPeak };
1129
+ }
1130
+
1131
+ /**
1132
+ * Convert numeric per‑syllable stress to McAleese’s symbolic levels
1133
+ * (w, n, m, s) and resolve adjacent identical stresses using dependency
1134
+ * information.
1135
+ */
1136
+ export function assignRelativeStresses(words: ClsWord[], ius: IntonationalUnit[]): void {
1137
+ // Phrase-edge sets for the endings-strict floor gate (read from Phase-Stress).
1138
+ const edges = phraseEdgeSets(ius);
1139
+ // Syllables raised by dependency-mined prominence (stranded preposition,
1140
+ // contrastive possessive, vocative): protected from the per-PP trailing-run
1141
+ // flatten so the recovered beat survives.
1142
+ const prominenceProtected = new Set<Syllable>();
1143
+
1144
+ // First pass: numeric → symbolic (0→w, 1→n, 2→m, 3+→s)
1145
+ // Use lexicalStress (pre-nuclear) so nuclear stress doesn't corrupt meter detection.
1146
+ for (const word of words) {
1147
+ for (const syl of word.syllables) {
1148
+ const val = syl.lexicalStress ?? syl.stress;
1149
+ if (val === 0) {
1150
+ // Zero-Provision (`x`) for a maximally-reduced clitic: a stressless
1151
+ // syllable of a function word reads *below* a stressless content
1152
+ // syllable (the/a/of/and… vs. the weak syllable of a content word).
1153
+ // EXCEPTION: an aphaeresis clipping ('neath/o'er/'gainst…) is the
1154
+ // *lexically-stressed* syllable of its base word surviving the clip — an
1155
+ // overt syllable carrying real stress, merely reduced in context. `x`
1156
+ // means extrametrical (Hayes' zero-provision), which it is NOT; so it
1157
+ // floors at `w` (overt weak), promotable like any weak syllable.
1158
+ const bare = word.word.toLowerCase().replace(/['’]/g, '');
1159
+ // Function VERBS (copula/aux/aspectual: be/is/keeps/began…) and
1160
+ // function ADVERBS (deictic/scalar: just/now/then/here/there…) floor
1161
+ // at 'w', not 'x': both classes carry full, unreducible vowels —
1162
+ // 'x' is for schwa-able clitics (the/a/of/and). At 'w' they remain
1163
+ // Attridge-promotable, recovering e.g. the dactylic opening beat of
1164
+ // "JUST for a riband to STICK in his coat" (Browning).
1165
+ if (word.isContent || APHAERESIS_CLITICS.has(bare)
1166
+ || FUNCTION_VERBS.has(bare) || FUNCTION_ADVERBS.has(bare)) {
1167
+ syl.relativeStress = 'w';
1168
+ } else {
1169
+ syl.relativeStress = 'x';
1170
+ }
1171
+ } else if (val === 1) {
1172
+ syl.relativeStress = 'n';
1173
+ } else if (val === 2) {
1174
+ syl.relativeStress = 'm';
1175
+ } else {
1176
+ syl.relativeStress = 's';
1177
+ }
1178
+ // Monosyllabic function clitic → floor at 'w' (overt-weak, promotable),
1179
+ // never 'n'. A CMU-primary monosyllabic preposition/determiner/possessive/
1180
+ // wh-word/coordinator is reduced in running speech; flooring it at 'n' is
1181
+ // what produced the flat function-word runs ("So on my", "where strange").
1182
+ // (Pure clitics the/a/of already read 'x' via the val===0 branch.)
1183
+ if (syl.relativeStress === 'n'
1184
+ && (isMonosyllabicClitic(word) || word.word.toLowerCase() === 'am')) {
1185
+ // "am" (1sg copula) is reliably reduced — far more so than beat-bearing
1186
+ // is/are/was/were — so it floors at 'w' (still Attridge-promotable) rather
1187
+ // than surfacing at 'm' as a spurious beat in "As I am BLOOD…". Kept to
1188
+ // this one form: flooring all be-verbs regressed Wyatt's accentual + corpus.
1189
+ syl.relativeStress = 'w';
1190
+ }
1191
+ // Downgrade extrametrical syllables by one level. We do NOT push a weak
1192
+ // syllable to 'x' here: 'x' (zero-provision) is reserved for maximally-
1193
+ // reduced *clitics*, whereas a weak *content* syllable (e.g. the feminine
1194
+ // ending "li·cense") stays 'w' per the maintainer's tier semantics.
1195
+ if (syl.extrametrical === 'morphological') {
1196
+ if (syl.relativeStress === 'n') syl.relativeStress = 'w';
1197
+ else if (syl.relativeStress === 'm') syl.relativeStress = 'n';
1198
+ else if (syl.relativeStress === 's') syl.relativeStress = 'm';
1199
+ }
1200
+ }
1201
+
1202
+ // Honest baseline prominence: floor a monosyllabic function word to its true
1203
+ // reading prominence ('x' schwa-clitic / 'w' overt-weak), never raising it.
1204
+ // The dictionary's citation stress on "and"/"in"/"my"/"could" is an artefact;
1205
+ // the meter layer re-promotes these where the metre needs a beat.
1206
+ //
1207
+ // ENDINGS STRICT / BEGINNINGS LOOSE (McAleese; Selkirk): withhold the floor
1208
+ // for a function word at the right edge of a PP or IU, or one that is its
1209
+ // PP's phrase-stress peak — it resists reduction there (a stranded/clause-
1210
+ // final "to"/"you"/"of" is not crushed to a clitic). Oblique object
1211
+ // pronouns (me/him/thee…) are excepted: they stay default-deaccented even
1212
+ // phrase-finally ("I gave it to HIM" only under focus, handled later).
1213
+ const lemma = word.word.toLowerCase().replace(/['’]/g, '');
1214
+ const edgeProtected =
1215
+ (edges.iuFinal.has(word) || edges.ppFinal.has(word) || edges.ppPeak.has(word))
1216
+ && !OBLIQUE_PRONOUNS.has(lemma);
1217
+ const floor = relativeFloorFor(word);
1218
+ if (floor && !edgeProtected) {
1219
+ const fr = STRESS_RANK[floor];
1220
+ for (const syl of word.syllables) {
1221
+ if (STRESS_RANK[syl.relativeStress ?? 'w'] > fr) syl.relativeStress = floor;
1222
+ }
1223
+ }
1224
+
1225
+ // Dependency-mined prominence (the parse IS the semantic layer): recover the
1226
+ // beat a flat POS floor would crush. A STRANDED preposition ("waiting FOR",
1227
+ // "stare AT") and a CONTRASTIVE possessive ("THY choice, not mine") bear
1228
+ // real stress → raise the peak to at least 'n' (promotable); a VOCATIVE
1229
+ // address ("Sing, O GODDESS") to at least 'm'. Raise-only; the raised peak
1230
+ // is protected from the per-PP trailing-run flatten below.
1231
+ let promoteTo: StressLevel | null = null;
1232
+ if (isVocative(word, words) || isDeicticLocative(word, words)) promoteTo = 'm';
1233
+ else if (isStrandedPreposition(word, words) || isContrastivePossessive(word, words)) promoteTo = 'n';
1234
+ if (promoteTo) {
1235
+ const pk = wordPeak(word);
1236
+ if (pk && STRESS_RANK[pk.relativeStress ?? 'w'] < STRESS_RANK[promoteTo]) {
1237
+ pk.relativeStress = promoteTo;
1238
+ }
1239
+ if (pk) prominenceProtected.add(pk);
1240
+ }
1241
+
1242
+ // Exclamatory interjection ("O", "Oh", "Ah", "Lo", "Alas"): emphatic, never
1243
+ // reduced — raise its peak to at least 'n' (corrects the mis-tag that floored
1244
+ // vocative "O"→IN→'x'). Raise-only; an exclaimed one is lifted further below.
1245
+ if (EXCLAM_INTERJECTIONS.has(word.word.toLowerCase())) {
1246
+ const pk = wordPeak(word);
1247
+ if (pk && STRESS_RANK[pk.relativeStress ?? 'w'] < STRESS_RANK.n) pk.relativeStress = 'n';
1248
+ }
1249
+ }
1250
+
1251
+ // Apply nuclear stress boosts to relative stress.
1252
+ // `syl.stress` may be higher than `syl.lexicalStress` after applyNuclearStress
1253
+ // boosted the rightmost content word. Each level of increase promotes the
1254
+ // relative stress by one tier: w→n, n→m, m→s.
1255
+ for (const word of words) {
1256
+ for (const syl of word.syllables) {
1257
+ const base = syl.lexicalStress ?? 0;
1258
+ const boost = syl.stress - base;
1259
+ if (boost > 0) {
1260
+ let current = syl.relativeStress ?? 'w';
1261
+ for (let i = 0; i < boost; i++) {
1262
+ if (current === 'x') current = 'w';
1263
+ else if (current === 'w') current = 'n';
1264
+ else if (current === 'n') current = 'm';
1265
+ else if (current === 'm') current = 's';
1266
+ }
1267
+ syl.relativeStress = current;
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ // Second pass: resolve adjacent identical stresses within each phonological phrase
1273
+ for (const iu of ius) {
1274
+ for (const pp of iu.phonologicalPhrases) {
1275
+ const ppWords = collectPPTokens(pp);
1276
+ resolveAdjacentClashes(ppWords, prominenceProtected);
1277
+ }
1278
+ }
1279
+
1280
+ // Third pass: resolve clashes across prosodic boundaries (PP and IU).
1281
+ // McAleese: when two adjacent syllables at a prosodic boundary have equal stress
1282
+ // and one is a function word, demote the function word (beginnings-free principle).
1283
+ resolveCrossBoundaryClashes(words, ius);
1284
+
1285
+ // Compound forestress (linear surface order): a left-stressed compound's
1286
+ // prominence sits on its LEFT element (WASTE·shore, SEA·shore, GHOST·town).
1287
+ // The phrasal compound/nuclear rules run in hierarchy order, which a mis-
1288
+ // grouped parse can split (e.g. "a cavernous waste shore" separating
1289
+ // waste/shore), so we re-assert forestress here on true surface adjacency,
1290
+ // after the clash passes, so it survives the rightmost-content nuclear boost.
1291
+ resolveCompoundForestress(words);
1292
+ resolveCollocationForestress(words);
1293
+ resolveHyphenCompounds(words);
1294
+
1295
+ // Fourth pass: resolve clashes on the LINEAR SURFACE order. A stress clash is
1296
+ // a property of *contiguous pronounced* syllables (Hayes' "two contiguous
1297
+ // syllables"), i.e. surface order — but the phrasal passes above run in
1298
+ // hierarchy order, which a mis-grouped parse can scramble (e.g. "a cavernous
1299
+ // waste shore" leaving "waste"/"shore" non-adjacent in the tree though
1300
+ // contiguous in speech). Catch any residual cardinal s–s clash here.
1301
+ resolveLinearClashes(words);
1302
+
1303
+ // Exclaimed interjection: an interjection immediately followed by "!" (Oh!, Ah!,
1304
+ // O!, Lo!) is an emphatic peak — raise it one tier so it stands out from a flat
1305
+ // run of neighbouring function words ("But—Oh! ye lords…" was a monotone n·n·n,
1306
+ // with the interjection no louder than the conjunction beside it). Narrow by
1307
+ // construction: only an UH whose very next token is "!".
1308
+ for (let i = 0; i < words.length - 1; i++) {
1309
+ const w = words[i];
1310
+ if (w.lexicalClass !== 'UH' || w.syllables.length === 0) continue;
1311
+ if (words[i + 1].word !== '!') continue;
1312
+ const pk = wordPeak(w);
1313
+ if (!pk) continue;
1314
+ const r = STRESS_RANK[pk.relativeStress ?? 'w'];
1315
+ if (r < STRESS_RANK.s) pk.relativeStress = STRESS_LEVELS[r + 1];
1316
+ }
1317
+
1318
+ }
1319
+
1320
+ /** Ascending rank of the 5 relative-stress tiers, for level arithmetic. */
1321
+ const STRESS_RANK: Record<StressLevel, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
1322
+ const STRESS_LEVELS: StressLevel[] = ['x', 'w', 'n', 'm', 's'];
1323
+
1324
+ /**
1325
+ * Re-assert left-stress on forestressed compounds over the LINEAR surface
1326
+ * sequence (e.g. WASTE·shore, SEA·shore, GHOST·town, STORM·cloud). For each
1327
+ * pair of truly-adjacent content words (by absoluteIndex) that the Compound
1328
+ * Stress Rule marks left-stressed, the left element's peak is raised to the
1329
+ * pair's maximum prominence and the right element's peak is demoted one rung
1330
+ * below it — never raising the subordinate. Runs on surface order so it works
1331
+ * even when the parse mis-groups the two into different phrases.
1332
+ */
1333
+ function resolveCompoundForestress(words: ClsWord[]): void {
1334
+ const content = words.filter(w => w.isContent && !isPunctuation(w.lexicalClass));
1335
+ for (let i = 0; i < content.length - 1; i++) {
1336
+ const w1 = content[i];
1337
+ const w2 = content[i + 1];
1338
+ if (Math.abs(w1.absoluteIndex - w2.absoluteIndex) !== 1) continue; // truly adjacent
1339
+ const pos1 = w1.lexicalClass, pos2 = w2.lexicalClass;
1340
+ if (!(pos2.startsWith('N') && (pos1.startsWith('N') || pos1.startsWith('J')))) continue;
1341
+ if (!isLeftStressedPair(w1.word, w2.word)) continue;
1342
+
1343
+ const s1 = wordPeak(w1);
1344
+ const s2 = wordPeak(w2);
1345
+ if (!s1 || !s2) continue;
1346
+ const r1 = STRESS_RANK[s1.relativeStress ?? 'w'];
1347
+ const r2 = STRESS_RANK[s2.relativeStress ?? 'w'];
1348
+ const hi = Math.max(r1, r2);
1349
+ s1.relativeStress = STRESS_LEVELS[hi]; // head ≥ both
1350
+ s2.relativeStress = STRESS_LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote-only
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * Forestress lexicalised collocations (GOOD old, END-all) over the LINEAR
1356
+ * surface sequence. Unlike `resolveCompoundForestress` this iterates ALL words
1357
+ * (not just content), because a collocation's second element may be a function
1358
+ * word ("end ALL" — "all" is a determiner): raise the left element's peak to the
1359
+ * pair maximum and demote the right one rung (demote-only, never raises the
1360
+ * subordinate).
1361
+ */
1362
+ function resolveCollocationForestress(words: ClsWord[]): void {
1363
+ const seq = words
1364
+ .filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0)
1365
+ .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
1366
+ for (let i = 0; i < seq.length - 1; i++) {
1367
+ const w1 = seq[i];
1368
+ const w2 = seq[i + 1];
1369
+ if (w2.absoluteIndex - w1.absoluteIndex !== 1) continue; // truly adjacent
1370
+ if (!isLeftStressedCollocation(w1, w2)) continue;
1371
+
1372
+ const s1 = wordPeak(w1);
1373
+ const s2 = wordPeak(w2);
1374
+ if (!s1 || !s2) continue;
1375
+ const r1 = STRESS_RANK[s1.relativeStress ?? 'w'];
1376
+ const r2 = STRESS_RANK[s2.relativeStress ?? 'w'];
1377
+ const hi = Math.max(r1, r2);
1378
+ s1.relativeStress = STRESS_LEVELS[hi]; // left element ≥ both
1379
+ s2.relativeStress = STRESS_LEVELS[Math.min(r2, Math.max(0, hi - 1))]; // demote-only
1380
+ }
1381
+ }
1382
+
1383
+ /**
1384
+ * Resolve the dual-strong clash at a hyphen seam inside a compound word
1385
+ * ("torch-flames", "blood-red"). The parser keeps a hyphenated compound as a
1386
+ * single token, so the word-level compound and clash passes never see its two
1387
+ * halves — left alone, both keep primary stress (s·s). For a hyphenated content
1388
+ * word whose hyphen parts align 1:1 with its syllables, an adjacent s·s seam is
1389
+ * resolved with the same logic as a two-word compound: forestress the left if it
1390
+ * is a known forestress modifier, otherwise retract the left (the nuclear /
1391
+ * right-stress default, e.g. torch-FLAMES).
1392
+ */
1393
+ function resolveHyphenCompounds(words: ClsWord[]): void {
1394
+ for (const w of words) {
1395
+ if (!w.isContent || !w.word.includes('-')) continue;
1396
+ const parts = w.word.split('-').filter(p => p.length > 0);
1397
+ if (parts.length < 2 || parts.length !== w.syllables.length) continue;
1398
+ for (let i = 0; i < w.syllables.length - 1; i++) {
1399
+ const a = w.syllables[i];
1400
+ const b = w.syllables[i + 1];
1401
+ // An equal-strong seam (s·s or m·m) is the unresolved compound clash.
1402
+ const equalStrong = a.relativeStress === b.relativeStress
1403
+ && (a.relativeStress === 's' || a.relativeStress === 'm');
1404
+ if (equalStrong) {
1405
+ if (isLeftStressedPair(parts[i], parts[i + 1])) demoteOneLevel(b); // BLOOD-red
1406
+ else demoteOneLevel(a); // torch-FLAMES
1407
+ }
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * THE CLASH FILTER — an absolute surface well-formedness constraint.
1414
+ *
1415
+ * On the STRESSED tier {n, m, s} no two *contiguous* syllables may carry the SAME
1416
+ * level: that is a stress clash (two equal prominences with no gradation between
1417
+ * them), which English categorically disallows. Gradient pairs (sm/ms/sn/ns/mn/nm)
1418
+ * are fine — there is still a step down — as are runs of the unstressed tiers
1419
+ * {w, x} (an unstressed sequence is tolerated, if not ideal). This generalises
1420
+ * McAleese's Appendix-A step 3d-ii ("stress clashes (ss, ms) > s-s") and Liberman
1421
+ * & Prince's (1977) grid alternation to every level of the strong tier.
1422
+ *
1423
+ * Resolution is DEMOTE-ONLY (never promote — promotion is the meter layer's job,
1424
+ * McAleese Test 2), so the contour is never inflated to break a clash; we iterate
1425
+ * to a fixed point (each change strictly lowers total stress mass, so it
1426
+ * terminates). Which member yields is decided by `demoteRightOfClash` (grid-based
1427
+ * relative prominence). Runs on the LINEAR surface order because a clash is a
1428
+ * property of contiguous *pronounced* syllables (Hayes), which a mis-grouped
1429
+ * dependency parse can scatter across phrases.
1430
+ */
1431
+ export function resolveLinearClashes(words: ClsWord[]): void {
1432
+ const flat: { word: ClsWord; syl: Syllable }[] = [];
1433
+ for (const w of words) for (const s of w.syllables) flat.push({ word: w, syl: s });
1434
+
1435
+ let changed = true;
1436
+ let guard = 0;
1437
+ while (changed && guard++ < 24) {
1438
+ changed = false;
1439
+ for (let i = 0; i < flat.length - 1; i++) {
1440
+ const a = flat[i];
1441
+ const b = flat[i + 1];
1442
+ const lvl = a.syl.relativeStress;
1443
+ if (lvl !== b.syl.relativeStress) continue;
1444
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's') continue; // only the stressed tier clashes
1445
+ // Intra-word clashes are NOT skipped: a hyphenated-compound seam
1446
+ // (deep·voiced, snow·storm, gate·bolts) or any word with two adjacent equal
1447
+ // stresses is still a clash, and must be made gradient like any other.
1448
+
1449
+ if (demoteRightOfClash(flat, i)) demoteOneLevel(b.syl);
1450
+ else demoteOneLevel(a.syl);
1451
+ changed = true;
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * Decide which member of an equal-stress clash at (i, i+1) yields. Rather than a
1458
+ * blunt leftward retraction, a layered cascade consults — in descending
1459
+ * authority — lexical, syntactic, and phonological context, so the demotion is
1460
+ * theory-grounded and the arbitrary default fires only as a last resort. Returns
1461
+ * true to demote the RIGHT member (i+1), false to demote the LEFT (i).
1462
+ *
1463
+ * 1. Lexical integrity (Kiparsky): never demote a polysyllable's own stress
1464
+ * peak for an adjacent monosyllable — the monosyllable yields.
1465
+ * 2. Inherent lexical prominence: a primary-stressed syllable outranks a
1466
+ * secondary one — the lower-lexical-stress member yields.
1467
+ * 3. Syntactic headedness (Nuclear Stress Rule; Liberman & Prince 1977; Cinque
1468
+ * 1993): when the two words stand in a direct head–dependent relation, the
1469
+ * DEPENDENT yields to its governor (the phrasal head is the more prominent).
1470
+ * 4. Content/function asymmetry — a coarse proxy for headedness when no direct
1471
+ * dependency links the pair: the function word yields.
1472
+ * 5. Phonological weight: a light (open, short-vowel) syllable reduces more
1473
+ * readily than a heavy (closed / long-vowel) one — the lighter member yields.
1474
+ * 6. Rhythm Rule (Liberman & Prince 1977; Hayes 1984): the clash member abutting
1475
+ * the stronger OUTER beat is that beat's off-beat, so it retracts — "wet
1476
+ * CHURCH" retracts wet (church is nuclear), "LATE last NIGHT" retracts last
1477
+ * onto late (night is the following beat).
1478
+ * 7. Default (Hayes): retract the LEFT stress.
1479
+ */
1480
+ function demoteRightOfClash(
1481
+ flat: { word: ClsWord; syl: Syllable }[],
1482
+ i: number
1483
+ ): boolean {
1484
+ const a = flat[i];
1485
+ const b = flat[i + 1];
1486
+
1487
+ // (1) lexical integrity
1488
+ const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
1489
+ const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
1490
+ if (aPeak && !bPeak && b.word.syllables.length === 1) return true;
1491
+ if (bPeak && !aPeak && a.word.syllables.length === 1) return false;
1492
+
1493
+ // (2) inherent lexical prominence
1494
+ const la = a.syl.lexicalStress ?? a.syl.stress ?? 0;
1495
+ const lb = b.syl.lexicalStress ?? b.syl.stress ?? 0;
1496
+ if (la !== lb) return la > lb;
1497
+
1498
+ // (3) syntactic headedness — the dependent yields to its governor
1499
+ if (a.word !== b.word) {
1500
+ const gov = getGovernor(a.word, b.word);
1501
+ if (gov === a.word) return true; // a heads b → demote b
1502
+ if (gov === b.word) return false; // b heads a → demote a
1503
+ }
1504
+
1505
+ // (4) content/function asymmetry — the function word yields
1506
+ if (a.word.isContent !== b.word.isContent) return a.word.isContent;
1507
+
1508
+ // (5) phonological weight — the lighter syllable yields
1509
+ if (a.syl.weight && b.syl.weight && a.syl.weight !== b.syl.weight) {
1510
+ return a.syl.weight === 'H'; // a heavy → demote the lighter b
1511
+ }
1512
+
1513
+ // (6) Rhythm Rule — yield to the stronger adjacent (outer) beat
1514
+ const outerA = i - 1 >= 0 ? STRESS_RANK[flat[i - 1].syl.relativeStress ?? 'w'] : -1;
1515
+ const outerB = i + 2 < flat.length ? STRESS_RANK[flat[i + 2].syl.relativeStress ?? 'w'] : -1;
1516
+ if (outerA !== outerB) return outerB > outerA;
1517
+
1518
+ // (7) default: retract the left
1519
+ return false;
1520
+ }
1521
+
1522
+ /**
1523
+ * Scan across the linear sequence of syllables and adjust any adjacent
1524
+ * identical relative stress levels using syntactic governance.
1525
+ */
1526
+ function resolveAdjacentClashes(words: ClsWord[], protect?: Set<Syllable>): void {
1527
+ // "Endings strict": when a phrase ends in a run of two or more bare function
1528
+ // words (e.g. "…fast as you MIGHT"), the metrical beat gravitates to one of
1529
+ // them; the others are upbeat. Demote the others so a leftward governance
1530
+ // clash can't promote a medial off-beat ("you") over the phrase-final beat.
1531
+ // Phrases ending in a content word are untouched.
1532
+ {
1533
+ let runStart = words.length;
1534
+ while (runStart > 0 && !words[runStart - 1].isContent) runStart--;
1535
+ if (words.length - runStart >= 2) {
1536
+ // The beat is normally the run's last word, UNLESS that is a clause-final
1537
+ // oblique pronoun (me/him/them…), which is canonically unstressed — then
1538
+ // the beat falls on the preceding member ("and beHIND me", not "behind ME").
1539
+ let beatIdx = words.length - 1;
1540
+ if (OBLIQUE_PRONOUNS.has(words[beatIdx].word.toLowerCase()) && beatIdx > runStart) {
1541
+ beatIdx--;
1542
+ }
1543
+ for (let wi = runStart; wi < words.length; wi++) {
1544
+ if (wi === beatIdx) continue;
1545
+ const w = words[wi];
1546
+ const peak = wordPeak(w);
1547
+ for (const s of w.syllables) {
1548
+ // Protect a polysyllabic word's own lexical peak: never flatten a real
1549
+ // internal stress (be·HIND) to 'w' just because the word is functional.
1550
+ if (w.syllables.length > 1 && s === peak && (s.lexicalStress ?? s.stress) >= 1) continue;
1551
+ // Protect a dependency-mined prominence (stranded preposition etc.).
1552
+ if (protect && protect.has(s)) continue;
1553
+ s.relativeStress = 'w';
1554
+ }
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ // Flatten all syllables with reference to their owning word.
1560
+ const flat: { word: ClsWord; syl: Syllable }[] = [];
1561
+ for (const w of words) {
1562
+ for (const s of w.syllables) {
1563
+ flat.push({ word: w, syl: s });
1564
+ }
1565
+ }
1566
+
1567
+ for (let i = 0; i < flat.length - 1; i++) {
1568
+ const a = flat[i];
1569
+ const b = flat[i + 1];
1570
+ if (a.syl.relativeStress !== b.syl.relativeStress) continue;
1571
+ // Only the stressed tier {n,m,s} clashes; {w,x} may repeat (maintainer's rule:
1572
+ // an unstressed run is tolerated, never "resolved" by demoting a clitic to 'x').
1573
+ const lvl = a.syl.relativeStress;
1574
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's') continue;
1575
+
1576
+ // Within-word strictness (Kiparsky): a polysyllabic word's own stress peak
1577
+ // must not be demoted below its word-mates by a clash with an adjacent
1578
+ // monosyllable. Protect the peak; demote the monosyllable instead.
1579
+ const aPeak = a.word.syllables.length > 1 && a.syl === wordPeak(a.word);
1580
+ const bPeak = b.word.syllables.length > 1 && b.syl === wordPeak(b.word);
1581
+ if (aPeak && b.word.syllables.length === 1) {
1582
+ adjustAdjacent(a.syl, b.syl, governorDependentDirection);
1583
+ continue;
1584
+ }
1585
+ if (bPeak && a.word.syllables.length === 1) {
1586
+ adjustAdjacent(b.syl, a.syl, governorDependentDirection);
1587
+ continue;
1588
+ }
1589
+
1590
+ // Otherwise use the syntactic governor relationship.
1591
+ const governor = getGovernor(a.word, b.word);
1592
+ if (governor === a.word) {
1593
+ // a governs b → a stronger, b weaker
1594
+ adjustAdjacent(a.syl, b.syl, governorDependentDirection);
1595
+ } else if (governor === b.word) {
1596
+ // b governs a → b stronger, a weaker
1597
+ adjustAdjacent(b.syl, a.syl, governorDependentDirection);
1598
+ }
1599
+ // If no relationship, leave untouched.
1600
+ }
1601
+ }
1602
+
1603
+ /** The syllable bearing a word's lexical stress peak (used for within-word protection). */
1604
+ function wordPeak(word: ClsWord): Syllable | undefined {
1605
+ let best: Syllable | undefined;
1606
+ let bestVal = -Infinity;
1607
+ for (const s of word.syllables) {
1608
+ const v = s.lexicalStress ?? s.stress;
1609
+ if (v > bestVal) { bestVal = v; best = s; }
1610
+ }
1611
+ return best;
1612
+ }
1613
+
1614
+ /** Return the governor word if one directly governs the other, else null. */
1615
+ function getGovernor(w1: ClsWord, w2: ClsWord): ClsWord | null {
1616
+ const dep1 = w1.dependency;
1617
+ const dep2 = w2.dependency;
1618
+ if (!dep1 || !dep2) return null;
1619
+
1620
+ // Check if w2 is a dependent of w1.
1621
+ if (dep2.governor === w1) return w1;
1622
+ // Check if w1 is a dependent of w2.
1623
+ if (dep1.governor === w2) return w2;
1624
+ return null;
1625
+ }
1626
+
1627
+ /** Adjustment direction: governor stronger (promote), dependent weaker (demote). */
1628
+ function governorDependentDirection(gov: Syllable, dep: Syllable): void {
1629
+ const govStress = gov.relativeStress!;
1630
+ const depStress = dep.relativeStress!;
1631
+
1632
+ // Promote governor (if possible)
1633
+ if (govStress === 'n') gov.relativeStress = 'm';
1634
+ else if (govStress === 'm') gov.relativeStress = 's';
1635
+ // 'w' or 's' stay the same (can't promote 's', can't easily promote 'w' to 'n' without risking equal)
1636
+
1637
+ // Demote dependent (if possible)
1638
+ if (depStress === 's') dep.relativeStress = 'm';
1639
+ else if (depStress === 'm') dep.relativeStress = 'n';
1640
+ else if (depStress === 'n') dep.relativeStress = 'w';
1641
+ else if (depStress === 'w') dep.relativeStress = 'x';
1642
+ }
1643
+
1644
+ /** Simple adjustment for two adjacent syllables. */
1645
+ function adjustAdjacent(
1646
+ stronger: Syllable,
1647
+ weaker: Syllable,
1648
+ direction: (s: Syllable, w: Syllable) => void
1649
+ ): void {
1650
+ direction(stronger, weaker);
1651
+ }
1652
+
1653
+ /** Demote a syllable's relative stress by one level: s→m, m→n, n→w, w→x, x stays x. */
1654
+ function demoteOneLevel(syl: Syllable): void {
1655
+ const cur = syl.relativeStress;
1656
+ if (cur === 's') syl.relativeStress = 'm';
1657
+ else if (cur === 'm') syl.relativeStress = 'n';
1658
+ else if (cur === 'n') syl.relativeStress = 'w';
1659
+ else if (cur === 'w') syl.relativeStress = 'x';
1660
+ }
1661
+
1662
+ /**
1663
+ * Resolve stress clashes across prosodic boundaries (PP and IU).
1664
+ * When adjacent syllables at a boundary have equal stress:
1665
+ * - If one word is function and the other content, demote the function word
1666
+ * (per "beginnings free": the start of a new unit can be weakened)
1667
+ * - If both are same type, use dependency relationship
1668
+ * - If no relationship exists, leave untouched
1669
+ */
1670
+ function resolveCrossBoundaryClashes(words: ClsWord[], ius: IntonationalUnit[]): void {
1671
+ // Build flat array with prosodic position tracking
1672
+ const flat: { word: ClsWord; syl: Syllable; ppKey: string }[] = [];
1673
+ for (let iuIdx = 0; iuIdx < ius.length; iuIdx++) {
1674
+ const iu = ius[iuIdx];
1675
+ for (let ppIdx = 0; ppIdx < iu.phonologicalPhrases.length; ppIdx++) {
1676
+ const pp = iu.phonologicalPhrases[ppIdx];
1677
+ const ppWords = collectPPTokens(pp);
1678
+ for (const w of ppWords) {
1679
+ for (const s of w.syllables) {
1680
+ flat.push({ word: w, syl: s, ppKey: `${iuIdx}:${ppIdx}` });
1681
+ }
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ for (let i = 0; i < flat.length - 1; i++) {
1687
+ const a = flat[i];
1688
+ const b = flat[i + 1];
1689
+ if (a.syl.relativeStress !== b.syl.relativeStress) continue;
1690
+ // Only the stressed tier {n,m,s} clashes; {w,x} may repeat (maintainer's rule).
1691
+ const lvl = a.syl.relativeStress;
1692
+ if (lvl !== 'n' && lvl !== 'm' && lvl !== 's') continue;
1693
+
1694
+ // Only adjust if they span a prosodic boundary
1695
+ if (a.ppKey === b.ppKey) continue;
1696
+
1697
+ const aContent = a.word.isContent;
1698
+ const bContent = b.word.isContent;
1699
+
1700
+ if (aContent && !bContent) {
1701
+ demoteOneLevel(b.syl);
1702
+ } else if (!aContent && bContent) {
1703
+ demoteOneLevel(a.syl);
1704
+ } else {
1705
+ // Both same content/function type — try dependency relationship.
1706
+ // (A blanket Selkirk "demote the PP-initial" was tried and regressed
1707
+ // litlab/prosodic: the linear-clash cascade's nuanced resolution — weight,
1708
+ // Rhythm Rule, governance — captures "beginnings loose" better than a flat
1709
+ // directional rule, so a clash with no governance is left for it.)
1710
+ const governor = getGovernor(a.word, b.word);
1711
+ if (governor === a.word) {
1712
+ adjustAdjacent(a.syl, b.syl, governorDependentDirection);
1713
+ } else if (governor === b.word) {
1714
+ adjustAdjacent(b.syl, a.syl, governorDependentDirection);
1715
+ }
1716
+ }
1717
+ }
1718
+ }
1719
+
1720
+ /** Check whether a POS tag belongs to a content word category. */
1721
+ function isContentWord(tag: string, word?: string): boolean {
1722
+ if (CONTENT_POS.has(tag)) {
1723
+ if (word) {
1724
+ const lower = word.toLowerCase();
1725
+ if (FUNCTION_ADVERBS.has(lower)) return false;
1726
+ if (FUNCTION_VERBS.has(lower)) return false;
1727
+ }
1728
+ return true;
1729
+ }
1730
+ return false;
1731
+ }