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