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