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