calliope-ts 0.0.2 → 0.0.3

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 (159) hide show
  1. package/README.md +3 -13
  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/clio/caesura.ts +145 -0
  130. package/src/clio/depfix.ts +88 -0
  131. package/src/clio/display.ts +1042 -0
  132. package/src/clio/engine.ts +38 -0
  133. package/src/clio/parser.ts +845 -0
  134. package/src/clio/phonological.ts +849 -0
  135. package/src/clio/phrasestress.ts +108 -0
  136. package/src/clio/pipeline.ts +154 -0
  137. package/src/clio/rhyme.ts +740 -0
  138. package/src/clio/scandroid.ts +434 -0
  139. package/src/clio/scansion.ts +1130 -0
  140. package/src/clio/semantics.ts +134 -0
  141. package/src/clio/stress.ts +1731 -0
  142. package/src/clio/tagfix.ts +104 -0
  143. package/src/display.ts +321 -137
  144. package/src/engine.ts +22 -0
  145. package/src/index.ts +176 -32
  146. package/src/parser.ts +346 -682
  147. package/src/phonological.ts +72 -4
  148. package/src/phrasestress.ts +108 -0
  149. package/src/rhyme.ts +428 -8
  150. package/src/scansion.ts +136 -15
  151. package/src/semantics.ts +134 -0
  152. package/src/stress.ts +794 -64
  153. package/src/types.ts +39 -4
  154. package/tests/DataForHayesLinesOnly.txt +364 -0
  155. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  156. package/tests/basic.test.ts +443 -37
  157. package/tests/bench-hayes.mjs +72 -0
  158. package/tests/wagner-stress.test.ts +188 -0
  159. package/vitest.config.ts +0 -15
@@ -0,0 +1,688 @@
1
+ // calliope/relstress.ts — per-phonological-phrase relative stress for Calliope.
2
+ //
3
+ // This is the corrected relativisation McAleese A2 step 1b prescribes —
4
+ // "determine the relative stresses in EACH PP (w / n / m / s)" — and the direct
5
+ // answer to the maintainer's critique of the old pipeline (everything defaulting
6
+ // high, per-word boosts stacking linearly into adjacent reds, function words
7
+ // flattened into long w-runs).
8
+ //
9
+ // Principles:
10
+ // • Default is LOW. Each word's prominence is RAISED only with motivation.
11
+ // • Within a PP there is ONE nuclear peak (s) — the φ's MOST PROMINENT word per the
12
+ // genuine phrase stress (lowest `phraseStress`, the Compound + Nuclear Stress
13
+ // Rules computed in bracketing.ts). Other content words are subordinate (m); the
14
+ // rest ramp DOWN — nothing is boosted past the peak.
15
+ // • FUNCTION words take a category gradient, never a flat run:
16
+ // preposition / article / coordinator / complementiser → x (zero-provision)
17
+ // possessive / personal pronoun / auxiliary / modal → w (overt weak)
18
+ // quantifier / demonstrative / wh-word / negator → n (some stress)
19
+ // so "of all my days" reads x · n · w · s (of < my < all < days).
20
+ // • Compound fore-stress (SLATE roof, ICE cream) needs NO special case here: the
21
+ // Compound Stress Rule already gave the compound's LEFT element the lower
22
+ // phraseStress, so it wins the φ nuclear automatically.
23
+ // • The clash invariant is preserved: no two adjacent syllables share n / m / s
24
+ // (w and x may repeat); the less-prominent of a clashing pair is demoted.
25
+ //
26
+ // This stage DERIVES the symbolic contour FROM the integer phrase stress, per φ — but
27
+ // the two remain SEPARATE material signals: the integer ranking is global (one
28
+ // utterance nuclear), the symbolic contour is local (one beat per φ) and then
29
+ // modified (clash resolution), so they legitimately diverge — never forced equal.
30
+ import { collectPPTokens } from '../phonological.js';
31
+ import { isTransitiveFunctionWord, isPronoun, isInherentlyGiven } from './syntax.js';
32
+ const RANK = { x: 0, w: 1, n: 2, m: 3, s: 4 };
33
+ const LEVELS = ['x', 'w', 'n', 'm', 's'];
34
+ const CONTENT = /^(NN|NNS|NNP|NNPS|JJ|JJR|JJS|VB|VBD|VBG|VBN|VBP|VBZ|RB|RBR|RBS|CD|UH)$/;
35
+ const ARTICLES = new Set(['a', 'an', 'the']);
36
+ const QUANTIFIERS = new Set([
37
+ 'all', 'both', 'each', 'every', 'some', 'any', 'many', 'much', 'few', 'no',
38
+ 'most', 'half', 'several', 'either', 'neither', 'enough',
39
+ ]);
40
+ const DEMONSTRATIVES = new Set(['this', 'that', 'these', 'those']);
41
+ // "no" is a NEGATOR, not a reducible determiner: "made with NO loss of time" keeps
42
+ // a beat on "no". Listed here (ahead of the rel==='DET' → x floor in functionLevel)
43
+ // so the negative determiner is never flattened to the clitic tier.
44
+ const NEGATORS = new Set(['not', 'never', "n't", 'nor', 'no']);
45
+ // "be" reduces in the contour even as a copula ("the sky is BLUE"). "have"/"do"
46
+ // reduce only as AUXILIARIES — as main verbs ("the woods HAVE it", "DO your
47
+ // work") they are full content, so they are gated on the AUX relation below.
48
+ const BE_FORMS = new Set(['be', 'am', 'is', 'are', 'was', 'were', 'been', 'being']);
49
+ // Aphaeresis clitics: "'tis"/"'twas" = it+is / it+was, weak pronoun+copula clipped
50
+ // to a single proclitic syllable — never a stressed content peak (en-pos mis-tags
51
+ // the capitalised "'Tis" as NNP). Apostrophes already stripped by bare().
52
+ const APHAERESIS = new Set(['tis', 'twas', 'twere', 'twill', 'twould', 'tisnt']);
53
+ function bare(w) {
54
+ return w.word.toLowerCase().replace(/['’]/g, '');
55
+ }
56
+ function isAphaeresis(w) { return APHAERESIS.has(bare(w)); }
57
+ function isContent(w) {
58
+ return CONTENT.test(w.lexicalClass) && !isAphaeresis(w);
59
+ }
60
+ function isReducedVerb(w, words) {
61
+ if (isAphaeresis(w))
62
+ return true;
63
+ const rel = w.canonicalRel ?? '';
64
+ // "to be" (infinitive main verb) and ROOT be-forms ("Where WAS I?",
65
+ // "I have BEEN") carry a beat — NOT reduced. Must check BEFORE the AUX
66
+ // gate because UD maps cop→AUX, so "to be" has canonicalRel='AUX'.
67
+ if (BE_FORMS.has(bare(w)) && /^(VB|VBD|VBG|VBN|VBP|VBZ)$/.test(w.lexicalClass)) {
68
+ if (words) {
69
+ const prev = words
70
+ .filter(x => x.syllables.length > 0 && x.absoluteIndex < w.absoluteIndex)
71
+ .sort((a, b) => b.absoluteIndex - a.absoluteIndex)[0];
72
+ if (prev && (prev.canonicalRel === 'COMPMARK' || prev.lexicalClass === 'TO'))
73
+ return false;
74
+ }
75
+ if (rel === 'ROOT')
76
+ return false;
77
+ }
78
+ if (rel === 'AUX' || rel === 'AUXPASS')
79
+ return true; // true auxiliary reduces
80
+ // copular "be" after a function word with a predicate reduces; "have"/"do" as
81
+ // MAIN verbs do not
82
+ return BE_FORMS.has(bare(w)) && /^(VB|VBD|VBG|VBN|VBP|VBZ)$/.test(w.lexicalClass);
83
+ }
84
+ /** Intrinsic prominence of a FUNCTION word — the category gradient. `words` (the
85
+ * whole line) lets the transitivity test (Wagner §6.5.5) see a preposition's
86
+ * complement: only a COMPLEMENT-TAKING (transitive) preposition has the reduced
87
+ * 'x' allomorph; a STRANDED or adverbial-particle preposition keeps a beat. */
88
+ function functionLevel(w, words) {
89
+ const lemma = bare(w);
90
+ const pos = w.lexicalClass;
91
+ const rel = w.canonicalRel ?? '';
92
+ // A quantifier / demonstrative in an ATTRIBUTIVE determiner slot ("each street",
93
+ // "this cat", "that day") is a reduced determiner (x), NOT a standalone n-tier
94
+ // quantifier. Only the determiner USE reduces — a predeterminer ("ALL the",
95
+ // PDT/det:predet) and a standalone quantifier/demonstrative ("EACH of them",
96
+ // "THIS is…", rel ≠ det) keep their 'n'. This is what lets a ϕ-initial
97
+ // preposition's beat ("through", "in") outrank an interior "each".
98
+ if (rel === 'DET' && pos !== 'PDT' && !NEGATORS.has(lemma))
99
+ return 'x';
100
+ // n — quantifiers, demonstratives, wh-words, negators (carry real stress)
101
+ if (pos === 'PDT' || QUANTIFIERS.has(lemma) || DEMONSTRATIVES.has(lemma) ||
102
+ NEGATORS.has(lemma) || /^(WDT|WP|WP\$|WRB)$/.test(pos))
103
+ return 'n';
104
+ // "out of" is a compound preposition with fixed stress on "out" (OUT of, not
105
+ // out OF). "out" carries the directional content; "of" is the grammatical
106
+ // linker that floors to 'x'. Raise "out" to 'n' so it outranks a preceding
107
+ // unstressed syllable (the "-ter" of "laughter") and differentiates the contour
108
+ // ("laughter OUT of" → s w n x, not flat s w w x). Only fires when "of"
109
+ // immediately follows — a stranded/particle "out" ("coming OUT") is handled by
110
+ // the transitivity check below and keeps its own beat.
111
+ if (lemma === 'out') {
112
+ const next = words.find(x => x.absoluteIndex === w.absoluteIndex + 1);
113
+ if (next && bare(next) === 'of')
114
+ return 'n';
115
+ }
116
+ // A STRANDED / intransitive preposition ("what are you waiting FOR", "she walked
117
+ // IN") is NOT the reducible proclitic of "in the house" — Wagner §6.5.5: only
118
+ // transitive functors have the weak allomorph. It keeps an overt beat ('w'), so
119
+ // the metrical fitter can promote it; a TRANSITIVE preposition floors at 'x'.
120
+ if (pos === 'IN' || pos === 'TO' || rel === 'CASE') {
121
+ return isTransitiveFunctionWord(w, words) ? 'x' : 'w';
122
+ }
123
+ // x — pure clitics: articles, coordinators, complementisers
124
+ if (ARTICLES.has(lemma) || pos === 'CC' || rel === 'CC' || rel === 'COMPMARK')
125
+ return 'x';
126
+ // w — possessives, pronouns, auxiliaries, modals, leftover determiners
127
+ return 'w';
128
+ }
129
+ /** The peak (highest lexical-stress) syllable of a word. */
130
+ function peakSyllable(w) {
131
+ let best = null;
132
+ let bestV = -1;
133
+ for (const s of w.syllables) {
134
+ const v = s.lexicalStress ?? s.stress;
135
+ if (v > bestV) {
136
+ bestV = v;
137
+ best = s;
138
+ }
139
+ }
140
+ return best;
141
+ }
142
+ /** Paint every syllable of a word given the level assigned to its peak.
143
+ *
144
+ * The within-word contour preserves the LEXICAL gradient primary > secondary >
145
+ * unstressed (Nounsing's CMU stress → lexicalStress 2 = primary, 1 = secondary,
146
+ * 0 = unstressed). Crucially a SECONDARY stress is a REAL stress and must land
147
+ * STRICTLY ABOVE this word's own unstressed floor — never collapsed flat onto it.
148
+ * Hayes' stress-maximum theory: a polysyllable's internal contour (which syllables
149
+ * bear primary, secondary, or no stress) is a fixed lexical fact, not a position to
150
+ * be overwritten — "ac·CEL·er·AT·ed" must keep its EY2 secondary distinct from the
151
+ * truly toneless syllables around it, in a non-nuclear word (peak = m) exactly as
152
+ * much as in a nuclear one.
153
+ * peak → level; a co-primary (another lexicalStress-2 syllable) sits just below the
154
+ * peak; a secondary (lexicalStress 1) sits ONE TIER ABOVE this word's floor — 'n' if
155
+ * the floor is 'w' (a content word: ti in for·TI·tude is 'w', so tude's secondary
156
+ * clears it at 'n'), or 'w' if the floor is 'x' (a reduced function word: der in
157
+ * UNDER·neath is 'x', so un's secondary clears it at 'w', not flattened to 'x') —
158
+ * capped below the peak and at 'n' (the canonical non-peak ceiling); unstressed
159
+ * syllables sit AT the floor. */
160
+ function paintWord(w, level) {
161
+ const peak = peakSyllable(w);
162
+ const peakRank = RANK[level];
163
+ const floorRank = w.isContent ? RANK.w : RANK.x; // unstressed floor for this word
164
+ for (const s of w.syllables) {
165
+ if (s === peak) {
166
+ s.relativeStress = level;
167
+ continue;
168
+ }
169
+ const lv = s.lexicalStress ?? s.stress;
170
+ let r;
171
+ if (lv >= 2) {
172
+ r = peakRank - 1; // co-primary: just below the peak
173
+ }
174
+ else if (lv === 1) {
175
+ // SECONDARY stress → one tier ABOVE this word's floor (never flattened onto
176
+ // it), never reaching the peak, never exceeding 'n'.
177
+ r = Math.min(floorRank + 1, peakRank - 1, RANK.n);
178
+ }
179
+ else {
180
+ r = Math.min(floorRank, peakRank); // unstressed (≤ peak for reduced words)
181
+ }
182
+ s.relativeStress = LEVELS[Math.max(0, Math.min(r, peakRank))];
183
+ }
184
+ }
185
+ /** Relativise one phonological phrase by READING the genuine phrase stress.
186
+ *
187
+ * The cyclic Compound + Nuclear Stress Rules (bracketing.ts) already ranked every
188
+ * word with an integer (1 = strongest). Within a φ:
189
+ * • the lowest-ranked content word is the NUCLEAR (s) — and because the Compound
190
+ * Stress Rule fore-stressed a compound's LEFT element (lower integer), the
191
+ * compound peak falls out here for free (SLATE roof, ICE cream);
192
+ * • every other content word is a SECONDARY BEAT (m) — kept flat, NOT ramped to a
193
+ * trough, so the alternating beats that metrical verse needs stay visible (a
194
+ * ramp here flattened Hiawatha's "shores"/"Gitche" into non-beats → mis-meter);
195
+ * • the FINE gradient the maintainer asked for (ICE > cream, not a flat tie) is
196
+ * produced where content ABUTS, by clash resolution that breaks the tie with the
197
+ * phrase-stress integer — the less-prominent (higher-integer) member of two
198
+ * adjacent beats is the one demoted (so ICE ps2 keeps the beat, cream ps3 drops).
199
+ *
200
+ * Two anchored beats per φ, per Kiparsky's "beginnings free, endings strict"
201
+ * (McAleese pp.34–35, Hayes & Kaun 1996 — the procedure reads the stresses at the
202
+ * END of each unit, but the LEFT edge carries a lighter beat too):
203
+ * • ENDING (strict): the rightmost lowest-ranked content word → the nuclear s.
204
+ * • BEGINNING (free, light): a φ-initial PREPOSITION rises x→w — the subtle
205
+ * left-edge beat the maintainer hears on "…working | IN the house" and that lets
206
+ * "…through | each…" read through(w) > each(x). Articles, pronouns, auxiliaries
207
+ * and coordinators do not rise. */
208
+ function relativisePP(toks, words) {
209
+ if (toks.length === 0)
210
+ return;
211
+ // A DISCOURSE-GIVEN content word (repeated from an earlier line of the stanza) is
212
+ // subordinated relative to its sister (Wagner Ch.7) — but only as an ANCHOR
213
+ // demotion, and never when it would empty the φ of content. A word given a higher
214
+ // (worse) effective phrase-stress is less likely to win the φ nuclear.
215
+ const ps = (w) => {
216
+ const base = w.phraseStress || Infinity;
217
+ // A given content word — discourse-given (repeated from an earlier line), an
218
+ // inherently-given indefinite pronoun ("something", Wagner §7.2.3), or
219
+ // anaphorically given via a shared-head coordinate structure ("young blood
220
+ // and high blood" → the second "blood") — is less likely to win the φ
221
+ // nuclear, so a heavier sister in the SAME φ takes the beat.
222
+ const given = isContent(w) && (w.discourseGiven || isInherentlyGiven(w) || w.coordinateGiven);
223
+ return given ? base + 2 : base;
224
+ };
225
+ // Pronoun promotion (Nuclear Stress Rule + trochaic inversion): a simple personal
226
+ // pronoun (PRP with no apostrophe) at the start of the φ gets promoted to an
227
+ // anchor if it is syntactically prominent (phraseStress === 1) and its following
228
+ // verb in the φ is a function/light verb (have, has, had, do, etc.) with weaker
229
+ // phraseStress (phraseStress > 1). This captures the trochaic inversion in
230
+ // "I have no right" (I=1, have=3 → I wins the beat, have reduces) while
231
+ // sparing contractions ("I'll", "I'm") and pronouns preceding lexical content
232
+ // verbs ("I would give" → give is lexical, so I does NOT promote).
233
+ const promotePronoun = (w) => {
234
+ if (w.lexicalClass !== 'PRP' || w.word.includes("'") || w.word.includes("’"))
235
+ return false;
236
+ if (w.phraseStress !== 1)
237
+ return false;
238
+ const idx = toks.indexOf(w);
239
+ if (idx !== 0)
240
+ return false;
241
+ const nextVerb = toks.slice(1).find(x => /^VB/.test(x.lexicalClass));
242
+ if (!nextVerb)
243
+ return false;
244
+ const FUNCTION_VERBS_SHORT = new Set(['have', 'has', 'had', 'do', 'does', 'did', 'get', 'got']);
245
+ if (!FUNCTION_VERBS_SHORT.has(nextVerb.word.toLowerCase()))
246
+ return false;
247
+ // Only the MAIN-verb use of have/do ("I HAVE no right" — possession) licenses
248
+ // the inversion. As an AUXILIARY ("i have SENE them") the beat belongs to the
249
+ // lexical verb further right; promoting the pronoun here handed it the φ
250
+ // nuclear over the participle ("I"(s) have sene(m) — the Wyatt L3 bug).
251
+ if ((nextVerb.canonicalRel ?? '') === 'AUX' || (nextVerb.canonicalRel ?? '') === 'AUXPASS')
252
+ return false;
253
+ if ((nextVerb.phraseStress || 0) > 1)
254
+ return true;
255
+ return false;
256
+ };
257
+ // The set the gradient is measured against: content words (non-reduced) first;
258
+ // for a content-less φ fall back to its prominent function words ("for THAT",
259
+ // "to YOU"), then to any content, then to anything — so every φ has an anchor.
260
+ let anchors = toks.filter(w => promotePronoun(w) || (isContent(w) && !isReducedVerb(w, words)));
261
+ if (anchors.length === 0)
262
+ anchors = toks.filter(w => !isReducedVerb(w, words) && functionLevel(w, words) !== 'x');
263
+ if (anchors.length === 0)
264
+ anchors = toks.filter(isContent);
265
+ if (anchors.length === 0)
266
+ anchors = toks.slice();
267
+ const anchorSet = new Set(anchors);
268
+ // Nuclear strength = the lowest phrase-stress integer among the anchors; the
269
+ // nuclear WORD is the RIGHTMOST anchor at that value (English resolves rightward
270
+ // toward the nuclear). Because the Compound Stress Rule already fore-stressed a
271
+ // compound's LEFT element (lower integer), the compound peak falls out here for
272
+ // free — SLATE roof, ICE cream — with no special case.
273
+ const localMin = Math.min(...anchors.map(ps));
274
+ let nuclear = null;
275
+ for (const w of anchors)
276
+ if (ps(w) === localMin)
277
+ nuclear = w; // last = rightmost
278
+ // Base level per token.
279
+ const levels = toks.map((w) => {
280
+ if (w === nuclear)
281
+ return 's'; // φ nuclear (right edge)
282
+ if (anchorSet.has(w))
283
+ return 'm'; // secondary beat (flat — the
284
+ // gradient among abutting beats is set by clash resolution)
285
+ // BE-FORM flooring — only fires for true AUX copulas now (isReducedVerb
286
+ // already excludes "to be" and ROOT be-forms, which are anchors with beats).
287
+ // 'x' reserved for the clearest copula+predicate case: be-form after a
288
+ // function word directly followed by an adjective/noun predicate.
289
+ // Everything else → 'w' (promotable).
290
+ if (isReducedVerb(w, words)) {
291
+ if (BE_FORMS.has(bare(w))) {
292
+ const i = toks.indexOf(w);
293
+ const prev = i > 0 ? toks[i - 1] : null;
294
+ const followingContent = toks.slice(i + 1).find(x => isContent(x));
295
+ if (prev && !isContent(prev) && followingContent &&
296
+ /^(JJ|NN)/.test(followingContent.lexicalClass))
297
+ return 'x';
298
+ }
299
+ return 'w';
300
+ }
301
+ // Function word → its category gradient; a POLYSYLLABIC one floors its peak at
302
+ // 'w' so its stressed syllable outranks the reduced one (be·CAUSE, u·PON).
303
+ let tier = functionLevel(w, words);
304
+ if (w.syllables.length > 1 && tier === 'x')
305
+ tier = 'w';
306
+ // A polysyllabic function word whose lexicon entry records a genuine PRIMARY
307
+ // stress ("O·ver", "AF·ter", "UN·der", "u·PON") is not a reduced clitic — its
308
+ // stressed syllable is a real (if light) beat that metrical verse freely uses
309
+ // ("O·ver many a quaint and curious volume…" opens The Raven's trochee chain).
310
+ // Raise the peak one tier to 'n' — still below every content beat, still
311
+ // promotable/demotable by the fitter — leaving true reduced polysyllables
312
+ // ("be·cause" 00, "where·fore" secondary-only) at 'w'.
313
+ if (w.syllables.length > 1 && tier === 'w' &&
314
+ w.syllables.some(s => (s.lexicalStress ?? s.stress) === 2)) {
315
+ tier = 'n';
316
+ }
317
+ return tier;
318
+ });
319
+ // Phrase-initial beat ("beginnings free" — a light left-edge beat): a φ-initial
320
+ // PREPOSITION rises x→w (the subtle beat on "IN the house"; lets "through" outrank
321
+ // an interior "each"), unless it is itself the nuclear. Also fires for the first
322
+ // IN/TO AFTER a coordinator at the φ's start ("And of the best" — "And" is toks[0]
323
+ // at 'x', "of" is toks[1]; "of" gets the raise so it reads 'w', not flat 'x').
324
+ const raiseFirstPrep = (idx) => {
325
+ if (idx < 0 || idx >= toks.length)
326
+ return;
327
+ const w = toks[idx];
328
+ if (w === nuclear)
329
+ return;
330
+ if ((w.lexicalClass === 'IN' || w.lexicalClass === 'TO') && levels[idx] === 'x') {
331
+ levels[idx] = 'w';
332
+ }
333
+ };
334
+ raiseFirstPrep(0);
335
+ // If toks[0] is a coordinator (CC), the first IN/TO after it also gets the raise
336
+ // — it is κ-initial (the start of the prepositional phrase after "and"), even if
337
+ // not ϕ-initial. "And of the best" → "of" rises to 'w'.
338
+ if (toks.length > 1 && (toks[0].lexicalClass === 'CC' || (toks[0].canonicalRel ?? '') === 'CC')) {
339
+ for (let i = 1; i < toks.length; i++) {
340
+ if (toks[i].lexicalClass === 'IN' || toks[i].lexicalClass === 'TO') {
341
+ raiseFirstPrep(i);
342
+ break;
343
+ }
344
+ // Skip over articles/determiners to find the first IN/TO
345
+ if (toks[i].isContent)
346
+ break; // hit content before any prep
347
+ }
348
+ }
349
+ // Givenness escape (Wagner §6.1.3, maintainer's no-flat-run-on directive): when a
350
+ // φ's nuclear is an inherently-given PRONOUN immediately preceded by a transitive
351
+ // PREPOSITION ("of HIM", "to THEE"), do NOT leave them on a par or stress the
352
+ // pronoun — subordinate the given pronoun and give the preposition the beat
353
+ // ("OF him"), the differentiated reading. The metrical fitter may re-promote the
354
+ // pronoun if the line demands it.
355
+ if (nuclear && isPronoun(nuclear)) {
356
+ const ni = toks.indexOf(nuclear);
357
+ const prep = ni > 0 ? toks[ni - 1] : null;
358
+ if (prep && (prep.lexicalClass === 'IN' || prep.lexicalClass === 'TO' ||
359
+ (prep.canonicalRel ?? '') === 'CASE')) {
360
+ const pi = toks.indexOf(prep);
361
+ levels[ni] = 'w'; // given pronoun subordinated
362
+ levels[pi] = 'n'; // preposition takes the differentiating beat
363
+ }
364
+ }
365
+ toks.forEach((w, i) => paintWord(w, levels[i]));
366
+ // NOTE: the clash invariant (no adjacent equal n/m/s) is enforced once, globally,
367
+ // by resolveStressClashes over TRUE adjacent syllables in computeRelativeStress —
368
+ // not here. A per-word peak comparison would wrongly treat two polysyllables'
369
+ // stressed syllables as adjacent ("TY·ger TY·ger", peaks two syllables apart) and
370
+ // spuriously flatten every falling trochaic/dactylic foot.
371
+ }
372
+ const CLASH_NOUN = /^(NN|NNS|NNP|NNPS)$/;
373
+ /** Two clashing words form a COMPOUND whose LEFT element fore-stresses (Compound
374
+ * Stress Rule): a NOMD modifier + its head, or two adjacent nouns (the N+N compound
375
+ * the tagger routinely mislabels). Only for such a pair does the clash keep the
376
+ * lower-phraseStress (fore-stressed) member; every OTHER clash uses the default
377
+ * rightward resolution. This is the narrow place the Compound Stress Rule needs to
378
+ * reach the contour when neither element is the φ nuclear (e.g. "ice cream" buried
379
+ * under a wrong root) — without disturbing ordinary verse, where forcing the
380
+ * lower-phraseStress member to win moved beats onto odd positions and mis-metered. */
381
+ function isCompoundPair(a, b) {
382
+ if (a === b)
383
+ return false;
384
+ if ((a.canonicalRel ?? '') === 'NOMD' && a.dependency?.governor === b)
385
+ return true;
386
+ if ((b.canonicalRel ?? '') === 'NOMD' && b.dependency?.governor === a)
387
+ return true;
388
+ return CLASH_NOUN.test(a.lexicalClass) && CLASH_NOUN.test(b.lexicalClass) &&
389
+ Math.abs(a.absoluteIndex - b.absoluteIndex) === 1;
390
+ }
391
+ /**
392
+ * Stress-clash resolution over the TRUE surface syllable sequence (McAleese A2
393
+ * step 3d.ii: "stress clashes (ss, ms) > s-s"). A BEAT is one per foot (at most
394
+ * one per phonological phrase) — so two ADJACENT strong syllables (both ≥ m, in
395
+ * any order: ss, ms, sm, mm) are a clash, NOT two beats. One member drops to 'n'
396
+ * (below the beat threshold), leaving a single beat with a demoted neighbour;
397
+ * adjacent equal 'n' likewise demotes one member to 'w'.
398
+ *
399
+ * WHICH member drops:
400
+ * • a COMPOUND pair (isCompoundPair) keeps the fore-stressed (lower-phraseStress)
401
+ * element and demotes the other — the Compound Stress Rule, "ICE cream";
402
+ * • an n-n tie is broken by LEXICAL stress (Hayes' stress-maximum principle):
403
+ * the syllable with the higher lexicalStress value (2=primary > 1=secondary
404
+ * > 0=unstressed) is protected; the lower yields to 'w'. This preserves a
405
+ * polysyllable's internal contour — a PRIMARY (lexicalStress 2) that was
406
+ * demoted to 'n' by a prior m/s clash still outranks a SECONDARY
407
+ * (lexicalStress 1), which in turn outranks an artifact 'n' (lexicalStress
408
+ * 0, reached via the function-word gradient or a prior demotion). Without
409
+ * this, a cascade can crush real lexical data — e.g. "before" (be=1, fore=2):
410
+ * "fore" loses an m/s clash (demoted to 'n'), then ties "be"(n) and was
411
+ * wrongly sacrificed (the old code only protected ===1 secondaries, treating
412
+ * the primary 2 as "not grounded"), corrupting "wn" into "nw". Equal-
413
+ * lexical-stress ties (both primary, both secondary, both unstressed) default
414
+ * left (Rhythm Rule);
415
+ * • every other clash demotes the lower symbolic level, ties to the LEFT (the
416
+ * Rhythm Rule / Iambic Reversal — an earlier stress backs off ahead of one that
417
+ * follows closely, not a general "endings are strict" license).
418
+ *
419
+ * Because it compares adjacent SYLLABLES (not word peaks), a falling foot whose
420
+ * stresses are separated by a weak syllable ("TY·ger TY·ger", "HARB·our") is left
421
+ * intact — only genuinely abutting beats collapse.
422
+ */
423
+ function resolveStressClashes(words, sylWord) {
424
+ const syls = [];
425
+ for (const w of [...words].sort((a, b) => a.absoluteIndex - b.absoluteIndex)) {
426
+ for (const s of w.syllables)
427
+ syls.push(s);
428
+ }
429
+ // Lexical stress value of a syllable (2=primary, 1=secondary, 0=unstressed).
430
+ // Used to break n-n ties: a syllable with HIGHER lexical stress has greater
431
+ // phonological integrity (Hayes' stress-maximum principle) and is protected
432
+ // against demotion by a syllable with lower lexical stress. The prior code
433
+ // checked only ===1 (secondary), which inverted the hierarchy — a primary
434
+ // (lexicalStress===2) was treated as "not grounded" and sacrificed to a
435
+ // secondary, corrupting words like "before" (be=1/secondary, fore=2/primary)
436
+ // into "nw" instead of "wn".
437
+ const lexVal = (s) => s.lexicalStress ?? s.stress ?? 0;
438
+ let changed = true;
439
+ for (let guard = 0; changed && guard < 8; guard++) {
440
+ changed = false;
441
+ for (let i = 0; i + 1 < syls.length; i++) {
442
+ const a = syls[i], b = syls[i + 1];
443
+ const ra = RANK[a.relativeStress ?? 'w'];
444
+ const rb = RANK[b.relativeStress ?? 'w'];
445
+ if (ra >= RANK.m && rb >= RANK.m) {
446
+ const wa = sylWord.get(a), wb = sylWord.get(b);
447
+ let demoteA;
448
+ if (wa && wb && wa !== wb && isCompoundPair(wa, wb)) {
449
+ // compound fore-stress: demote the LESS prominent (higher phrase stress)
450
+ demoteA = (wa.phraseStress || Infinity) >= (wb.phraseStress || Infinity);
451
+ }
452
+ else {
453
+ demoteA = ra <= rb; // default: lower level, tie → left
454
+ }
455
+ if (demoteA)
456
+ a.relativeStress = 'n';
457
+ else
458
+ b.relativeStress = 'n';
459
+ changed = true;
460
+ }
461
+ else if (ra === RANK.n && rb === RANK.n) {
462
+ // Break the tie by LEXICAL stress: the syllable with higher lexical
463
+ // stress (primary > secondary > unstressed) is protected; the lower
464
+ // yields. This preserves a polysyllable's internal contour (the
465
+ // primary survives even if a prior clash demoted it to n) while still
466
+ // letting genuine secondaries (lexicalStress 1) beat artifact n's
467
+ // (lexicalStress 0, reached via function-word gradient or prior
468
+ // demotion). Equal-lexical-stress ties default left (Rhythm Rule).
469
+ const la = lexVal(a), lb = lexVal(b);
470
+ if (la > lb)
471
+ b.relativeStress = 'w';
472
+ else if (lb > la)
473
+ a.relativeStress = 'w';
474
+ else
475
+ a.relativeStress = 'w'; // tie → default left
476
+ changed = true;
477
+ }
478
+ }
479
+ }
480
+ }
481
+ /** Inherent-givenness nuclear demotion (Wagner §7.2.3). After each φ has been
482
+ * relativised, an inherently-given indefinite pronoun that won its OWN φ's nuclear
483
+ * (e.g. a lone "Something" set off as its own ϕ) should still YIELD the utterance
484
+ * peak to a heavier element later in the same intonational unit — so when a LATER
485
+ * word in the IU also reaches 's', the given pronoun's peak is demoted s→m. It
486
+ * keeps a beat (its lexical contour is untouched below the peak) but no longer
487
+ * claims the main prominence: "Something(m) for the modern STAGE(s)".
488
+ *
489
+ * Guards keep it from crushing a FOCAL pronoun: it never fires when the given word
490
+ * is the IU's only nuclear (a lone "Something."), and skips a lemma repeated in the
491
+ * IU (focal anaphora). */
492
+ function demoteGivenNuclei(iuWords) {
493
+ const ordered = iuWords.filter(w => w.syllables.length > 0)
494
+ .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
495
+ const peak = (w) => Math.max(0, ...w.syllables.map(s => RANK[s.relativeStress ?? 'w']));
496
+ for (let i = 0; i < ordered.length; i++) {
497
+ const w = ordered[i];
498
+ if (!isInherentlyGiven(w) || peak(w) !== RANK.s)
499
+ continue;
500
+ const lemma = bare(w);
501
+ if (ordered.filter(x => bare(x) === lemma).length > 1)
502
+ continue; // focal repetition
503
+ if (!ordered.slice(i + 1).some(x => peak(x) === RANK.s))
504
+ continue; // no heavier later nuclear
505
+ for (const s of w.syllables)
506
+ if ((s.relativeStress ?? 'w') === 's')
507
+ s.relativeStress = 'm';
508
+ }
509
+ }
510
+ /** Alternation promotion (Attridge 1982), gated by Hayes' stress-maximum
511
+ * monosyllable/polysyllable asymmetry (McAleese pp.49-57; Halle-Keyser, Kiparsky
512
+ * 1975): English does not sustain a trough of three-or-more weak syllables — its
513
+ * medial syllable is promoted to a light beat, McAleese's 'n' ("some stress"). This
514
+ * is the SAME promotion the meter-fitter applies internally (`scansion.ts`: a 'w'
515
+ * flanked by weakness realises a beat); writing it into the displayed contour
516
+ * breaks the long w-troughs the maintainer flagged.
517
+ *
518
+ * NOTE on grounding: "beginnings free, endings strict" (Kiparsky 1968) is a loose
519
+ * statistical tendency about WHERE stress clashes resolve in the line, not a
520
+ * license to promote by position alone — it says nothing about whether a given
521
+ * syllable is phonologically eligible to carry stress at all. The actual gate
522
+ * here is Hayes' stress-maximum asymmetry: a STRESSED MONOSYLLABLE is positionally
523
+ * free (it has no internal contour to violate, so context can promote it — "error
524
+ * AND upon", "not TO advance"), but an UNSTRESSED syllable inside a POLYSYLLABLE
525
+ * is not free — the lexicon's verdict that it carries no stress (lexicalStress
526
+ * === 0) is a fixed phonological fact, and promoting it fabricates a secondary
527
+ * stress Nounsing-Pro never recorded (e.g. promoting "mariner"'s toneless "-ner"
528
+ * just because it sits between two other weak syllables, one of which belongs to
529
+ * the unrelated following word). So:
530
+ * • only an overt-weak 'w' promotes; a reduced clitic 'x' (the/a/of) stays the
531
+ * genuine trough — a clitic is never a beat;
532
+ * • a candidate that is the toneless (lexicalStress === 0) syllable of a word with
533
+ * MORE THAN ONE syllable is excluded — its own word's lexical contour already
534
+ * settled the question, and position cannot overrule it; a true monosyllable, or
535
+ * a syllable that itself carries some lexical stress (primary or secondary) but
536
+ * is sitting low because its WHOLE WORD only earned a low φ-level, remains
537
+ * eligible;
538
+ * • it must sit between two weak (w/x) syllables — the interior of a real trough;
539
+ * • the LINE EDGES do not count as weak, so a line-initial/final weak is never
540
+ * promoted (the off-beat before the first beat, and the final cadence, are left);
541
+ * • promotions never abut (a promoted 'n' breaks the run), so the result alternates
542
+ * w·n·w and the clash invariant (no adjacent equal n) is preserved. */
543
+ function promoteWeakTroughs(words) {
544
+ const syls = [];
545
+ const sylWord = new Map();
546
+ for (const w of [...words].sort((a, b) => a.absoluteIndex - b.absoluteIndex)) {
547
+ for (const s of w.syllables) {
548
+ syls.push(s);
549
+ sylWord.set(s, w);
550
+ }
551
+ }
552
+ const isW = (i) => i >= 0 && i < syls.length && syls[i].relativeStress === 'w';
553
+ const isLexicallyToneless = (i) => {
554
+ const s = syls[i];
555
+ if ((s.lexicalStress ?? s.stress) !== 0)
556
+ return false; // carries some real stress
557
+ const w = sylWord.get(s);
558
+ return !!w && w.syllables.length > 1; // interior of a polysyllable
559
+ };
560
+ let lastProm = -2;
561
+ for (let i = 1; i < syls.length - 1; i++) {
562
+ if (!isW(i) || !isW(i - 1) || !isW(i + 1))
563
+ continue; // medial of a ≥3 literal-w run
564
+ if (isLexicallyToneless(i))
565
+ continue; // no fabricated secondaries
566
+ if (i - 1 === lastProm)
567
+ continue; // keep promotions non-adjacent
568
+ syls[i].relativeStress = 'n';
569
+ lastProm = i;
570
+ }
571
+ }
572
+ /** Lower a polysyllabic word's final 'w' syllable to 'x' when another 'w'
573
+ * immediately follows it in surface order. The unstressed ending of a longer
574
+ * word (even a stress-bearing word) carries fainter emphasis than even a weakly-
575
+ * stressed syllable or word immediately following it — so a w-w seam at a word
576
+ * boundary is differentiated by dropping the word-internal one to the clitic tier.
577
+ *
578
+ * Narrowly scoped: only fires on the FINAL syllable of a polysyllabic word (2+
579
+ * syllables) that is 'w' (not 'n', 'm', 's', or 'x'), AND only when the very next
580
+ * syllable in surface order is also 'w'. Does NOT affect any other syllable of
581
+ * the word, does NOT fire when the next syllable is 'x', 'n', 'm', or 's'. */
582
+ function demotePolysyllabicFinalTrough(words) {
583
+ const syls = [];
584
+ const sylWord = new Map();
585
+ for (const w of [...words].sort((a, b) => a.absoluteIndex - b.absoluteIndex)) {
586
+ for (const s of w.syllables) {
587
+ syls.push(s);
588
+ sylWord.set(s, w);
589
+ }
590
+ }
591
+ for (let i = 0; i < syls.length - 1; i++) {
592
+ const s = syls[i];
593
+ const next = syls[i + 1];
594
+ if (s.relativeStress !== 'w')
595
+ continue;
596
+ if (next.relativeStress !== 'w')
597
+ continue;
598
+ const w = sylWord.get(s);
599
+ if (!w || w.syllables.length < 2)
600
+ continue; // polysyllabic only
601
+ if (s !== w.syllables[w.syllables.length - 1])
602
+ continue; // final syllable only
603
+ s.relativeStress = 'x';
604
+ }
605
+ }
606
+ /** Anaphoric givenness in coordinate structures (Krifka §4, Wagner §7.2).
607
+ * In a coordinate structure "X blood and Y blood" the HEAD lemma ("blood") is
608
+ * repeated across conjuncts. Both occurrences are the shared contrastive
609
+ * background — the CATEGORY "blood" is what stays constant, and the modifiers
610
+ * ("Young", "high") carry the contrastive focus. So BOTH heads are demoted
611
+ * (`coordinateGiven`), letting the modifier win the φ nuclear in EACH conjunct
612
+ * ("YOUNG blood and HIGH blood"). This is distinct from a refrain (identical
613
+ * phrase repeated for emphasis = focal) and from cross-line discourse
614
+ * givenness (repetition across lines of a stanza).
615
+ *
616
+ * Detection: a content word whose `canonicalRel` is CONJ and whose governor
617
+ * shares the same lemma. BOTH the conjunct (second occurrence) AND its
618
+ * governor (first occurrence) are marked `coordinateGiven`. The relativiser's
619
+ * `ps()` then demotes both, so the modifier in each φ wins the beat.
620
+ *
621
+ * Guard: only fires when the conjuncts' modifiers DIFFER — if the entire phrase
622
+ * is identical ("nevermore and nevermore") it's a refrain, not a shared-head
623
+ * coordinate, and both occurrences stay focal. */
624
+ function markCoordinateGivenness(words) {
625
+ for (const w of words) {
626
+ if (!isContent(w) || (w.canonicalRel ?? '') !== 'CONJ')
627
+ continue;
628
+ const gov = w.dependency?.governor;
629
+ if (!gov || gov === w)
630
+ continue;
631
+ // Same lemma (head repeated across conjuncts)?
632
+ if (bare(w) !== bare(gov))
633
+ continue;
634
+ // Guard against a refrain: check that the conjuncts have DIFFERENT
635
+ // pre-head modifiers. Compare the AMOD/ADVMOD lemmas immediately before
636
+ // each head. If both heads have the same modifier (or neither has one),
637
+ // leave both focal — it's either a refrain or a plain coordination.
638
+ const modBefore = (head) => {
639
+ const idx = words.indexOf(head);
640
+ if (idx <= 0)
641
+ return '';
642
+ const prev = words[idx - 1];
643
+ if ((prev.canonicalRel ?? '') === 'AMOD' || (prev.canonicalRel ?? '') === 'ADVMOD')
644
+ return bare(prev);
645
+ return '';
646
+ };
647
+ if (modBefore(w) === modBefore(gov))
648
+ continue;
649
+ // Mark BOTH heads as coordinate-given: the shared head is the contrastive
650
+ // background in both conjuncts, so contrastive focus falls on BOTH modifiers.
651
+ w.coordinateGiven = true;
652
+ gov.coordinateGiven = true;
653
+ }
654
+ }
655
+ /** Assign relative stress for the whole sentence, one PP at a time. */
656
+ export function computeRelativeStress(words, ius) {
657
+ // Mark anaphorically-given heads in coordinate structures before relativisation,
658
+ // so the relativiser's `ps()` can demote them and let the modifier win the beat.
659
+ markCoordinateGivenness(words);
660
+ for (const iu of ius) {
661
+ for (const pp of iu.phonologicalPhrases) {
662
+ const toks = collectPPTokens(pp)
663
+ .filter(w => w.syllables.length > 0)
664
+ .sort((a, b) => a.absoluteIndex - b.absoluteIndex);
665
+ relativisePP(toks, words);
666
+ }
667
+ }
668
+ // Enforce the clash rule over the whole line's surface syllables (catches the
669
+ // cross-PP abutments the per-PP painting cannot see). Carry each syllable's word
670
+ // so the clash can recognise a compound pair and apply the Compound Stress Rule.
671
+ const sylWord = new Map();
672
+ for (const w of words)
673
+ for (const s of w.syllables)
674
+ sylWord.set(s, w);
675
+ resolveStressClashes(words, sylWord);
676
+ // Per-IU: an inherently-given indefinite pronoun yields its φ-nuclear to a heavier
677
+ // later element in the same IU (after clash resolution, so the demotion is final).
678
+ for (const iu of ius) {
679
+ const iuWords = iu.phonologicalPhrases.flatMap(pp => collectPPTokens(pp));
680
+ demoteGivenNuclei(iuWords);
681
+ }
682
+ // Final readability pass: promote the medial weak of a ≥3-weak trough to 'n'
683
+ // (Attridge alternation), breaking long w-runs and filling promotable beats.
684
+ promoteWeakTroughs(words);
685
+ // Differentiate w-w seams at polysyllabic word boundaries: lower a
686
+ // polysyllabic word's final 'w' to 'x' when another 'w' immediately follows.
687
+ demotePolysyllabicFinalTrough(words);
688
+ }