calliope-ts 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +3 -13
  2. package/dist/caesura.d.ts +33 -0
  3. package/dist/caesura.d.ts.map +1 -0
  4. package/dist/caesura.js +202 -0
  5. package/dist/calliope/boundaries.d.ts +19 -0
  6. package/dist/calliope/boundaries.d.ts.map +1 -0
  7. package/dist/calliope/boundaries.js +182 -0
  8. package/dist/calliope/bracketing.d.ts +11 -0
  9. package/dist/calliope/bracketing.d.ts.map +1 -0
  10. package/dist/calliope/bracketing.js +416 -0
  11. package/dist/calliope/deps.d.ts +4 -0
  12. package/dist/calliope/deps.d.ts.map +1 -0
  13. package/dist/calliope/deps.js +181 -0
  14. package/dist/calliope/engine.d.ts +3 -0
  15. package/dist/calliope/engine.d.ts.map +1 -0
  16. package/dist/calliope/engine.js +71 -0
  17. package/dist/calliope/feats.d.ts +9 -0
  18. package/dist/calliope/feats.d.ts.map +1 -0
  19. package/dist/calliope/feats.js +45 -0
  20. package/dist/calliope/names.d.ts +7 -0
  21. package/dist/calliope/names.d.ts.map +1 -0
  22. package/dist/calliope/names.js +42 -0
  23. package/dist/calliope/postag.d.ts +8 -0
  24. package/dist/calliope/postag.d.ts.map +1 -0
  25. package/dist/calliope/postag.js +250 -0
  26. package/dist/calliope/prosodic.d.ts +3 -0
  27. package/dist/calliope/prosodic.d.ts.map +1 -0
  28. package/dist/calliope/prosodic.js +275 -0
  29. package/dist/calliope/relstress.d.ts +4 -0
  30. package/dist/calliope/relstress.d.ts.map +1 -0
  31. package/dist/calliope/relstress.js +688 -0
  32. package/dist/calliope/stressrules.d.ts +28 -0
  33. package/dist/calliope/stressrules.d.ts.map +1 -0
  34. package/dist/calliope/stressrules.js +147 -0
  35. package/dist/calliope/syntax.d.ts +38 -0
  36. package/dist/calliope/syntax.d.ts.map +1 -0
  37. package/dist/calliope/syntax.js +234 -0
  38. package/dist/calliope/udpos.d.ts +13 -0
  39. package/dist/calliope/udpos.d.ts.map +1 -0
  40. package/dist/calliope/udpos.js +156 -0
  41. package/dist/clio/caesura.d.ts +27 -0
  42. package/dist/clio/caesura.d.ts.map +1 -0
  43. package/dist/clio/caesura.js +148 -0
  44. package/dist/clio/depfix.d.ts +13 -0
  45. package/dist/clio/depfix.d.ts.map +1 -0
  46. package/dist/clio/depfix.js +84 -0
  47. package/dist/clio/display.d.ts +32 -0
  48. package/dist/clio/display.d.ts.map +1 -0
  49. package/dist/clio/display.js +976 -0
  50. package/dist/clio/engine.d.ts +3 -0
  51. package/dist/clio/engine.d.ts.map +1 -0
  52. package/dist/clio/engine.js +28 -0
  53. package/dist/clio/parser.d.ts +10 -0
  54. package/dist/clio/parser.d.ts.map +1 -0
  55. package/dist/clio/parser.js +696 -0
  56. package/dist/clio/phonological.d.ts +41 -0
  57. package/dist/clio/phonological.d.ts.map +1 -0
  58. package/dist/clio/phonological.js +788 -0
  59. package/dist/clio/phrasestress.d.ts +6 -0
  60. package/dist/clio/phrasestress.d.ts.map +1 -0
  61. package/dist/clio/phrasestress.js +106 -0
  62. package/dist/clio/pipeline.d.ts +11 -0
  63. package/dist/clio/pipeline.d.ts.map +1 -0
  64. package/dist/clio/pipeline.js +147 -0
  65. package/dist/clio/rhyme.d.ts +65 -0
  66. package/dist/clio/rhyme.d.ts.map +1 -0
  67. package/dist/clio/rhyme.js +761 -0
  68. package/dist/clio/scandroid.d.ts +17 -0
  69. package/dist/clio/scandroid.d.ts.map +1 -0
  70. package/dist/clio/scandroid.js +435 -0
  71. package/dist/clio/scansion.d.ts +46 -0
  72. package/dist/clio/scansion.d.ts.map +1 -0
  73. package/dist/clio/scansion.js +1086 -0
  74. package/dist/clio/semantics.d.ts +44 -0
  75. package/dist/clio/semantics.d.ts.map +1 -0
  76. package/dist/clio/semantics.js +139 -0
  77. package/dist/clio/stress.d.ts +83 -0
  78. package/dist/clio/stress.d.ts.map +1 -0
  79. package/dist/clio/stress.js +1737 -0
  80. package/dist/clio/tagfix.d.ts +6 -0
  81. package/dist/clio/tagfix.d.ts.map +1 -0
  82. package/dist/clio/tagfix.js +101 -0
  83. package/dist/display.d.ts +0 -6
  84. package/dist/display.d.ts.map +1 -1
  85. package/dist/display.js +322 -141
  86. package/dist/engine.d.ts +9 -0
  87. package/dist/engine.d.ts.map +1 -0
  88. package/dist/engine.js +12 -0
  89. package/dist/index.d.ts +4 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +169 -26
  92. package/dist/parser.d.ts +3 -3
  93. package/dist/parser.d.ts.map +1 -1
  94. package/dist/parser.js +320 -563
  95. package/dist/phonological.d.ts +1 -1
  96. package/dist/phonological.d.ts.map +1 -1
  97. package/dist/phonological.js +73 -4
  98. package/dist/phrasestress.d.ts +6 -0
  99. package/dist/phrasestress.d.ts.map +1 -0
  100. package/dist/phrasestress.js +106 -0
  101. package/dist/rhyme.d.ts +40 -1
  102. package/dist/rhyme.d.ts.map +1 -1
  103. package/dist/rhyme.js +435 -7
  104. package/dist/scansion.d.ts +9 -0
  105. package/dist/scansion.d.ts.map +1 -1
  106. package/dist/scansion.js +145 -18
  107. package/dist/semantics.d.ts +44 -0
  108. package/dist/semantics.d.ts.map +1 -0
  109. package/dist/semantics.js +139 -0
  110. package/dist/stress.d.ts +127 -4
  111. package/dist/stress.d.ts.map +1 -1
  112. package/dist/stress.js +834 -61
  113. package/dist/types.d.ts +15 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/package.json +5 -3
  116. package/src/caesura.ts +201 -0
  117. package/src/calliope/boundaries.ts +190 -0
  118. package/src/calliope/bracketing.ts +390 -0
  119. package/src/calliope/deps.ts +160 -0
  120. package/src/calliope/engine.ts +77 -0
  121. package/src/calliope/feats.ts +46 -0
  122. package/src/calliope/names.ts +44 -0
  123. package/src/calliope/postag.ts +253 -0
  124. package/src/calliope/prosodic.ts +262 -0
  125. package/src/calliope/relstress.ts +645 -0
  126. package/src/calliope/stressrules.ts +147 -0
  127. package/src/calliope/syntax.ts +218 -0
  128. package/src/calliope/udpos.ts +152 -0
  129. package/src/clio/caesura.ts +145 -0
  130. package/src/clio/depfix.ts +88 -0
  131. package/src/clio/display.ts +1042 -0
  132. package/src/clio/engine.ts +38 -0
  133. package/src/clio/parser.ts +845 -0
  134. package/src/clio/phonological.ts +849 -0
  135. package/src/clio/phrasestress.ts +108 -0
  136. package/src/clio/pipeline.ts +154 -0
  137. package/src/clio/rhyme.ts +740 -0
  138. package/src/clio/scandroid.ts +434 -0
  139. package/src/clio/scansion.ts +1130 -0
  140. package/src/clio/semantics.ts +134 -0
  141. package/src/clio/stress.ts +1731 -0
  142. package/src/clio/tagfix.ts +104 -0
  143. package/src/display.ts +321 -137
  144. package/src/engine.ts +22 -0
  145. package/src/index.ts +176 -32
  146. package/src/parser.ts +346 -682
  147. package/src/phonological.ts +72 -4
  148. package/src/phrasestress.ts +108 -0
  149. package/src/rhyme.ts +428 -8
  150. package/src/scansion.ts +136 -15
  151. package/src/semantics.ts +134 -0
  152. package/src/stress.ts +794 -64
  153. package/src/types.ts +39 -4
  154. package/tests/DataForHayesLinesOnly.txt +364 -0
  155. package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
  156. package/tests/basic.test.ts +443 -37
  157. package/tests/bench-hayes.mjs +72 -0
  158. package/tests/wagner-stress.test.ts +188 -0
  159. package/vitest.config.ts +0 -15
@@ -0,0 +1,390 @@
1
+ // calliope/bracketing.ts — cyclic Compound + Nuclear Stress Rules, extended with
2
+ // Wagner (2005) Ch. 6 functor/argument geometry (2026-06-29 Wagner/Krifka rebuild).
3
+ //
4
+ // This is the REAL phrase-stress stage McAleese (after Chomsky & Halle's SPE and
5
+ // Hayes 1984b) prescribes — NOT a left-to-right ramp. It PROJECTS a constituent
6
+ // bracketing from the (UD-normalised) dependency tree, then runs the cyclic stress
7
+ // rules over it, innermost cycle out. The classic SPE convention is:
8
+ //
9
+ // • a COMPOUND combination (N+N / NOMD) → Compound Stress Rule: primary on the LEFT
10
+ // element (SLATE roof, ICE cream);
11
+ // • every other (phrasal) combination → Nuclear Stress Rule: primary on the RIGHT
12
+ // element (the rightmost constituent of a phrase is its nuclear).
13
+ //
14
+ // Wagner refines WHICH sister wins at each cycle (the plan's Gaps 2–8):
15
+ // • Complement Prominence (§6.2.2): the ARGUMENT outranks the FUNCTOR;
16
+ // • the Prosodic Asymmetry (§6.1.3): a functor that FOLLOWS its argument is
17
+ // obligatorily subordinated; one that PRECEDES may be on a par;
18
+ // • the specifier restriction (§6.3.2): a BRANCHING functor (a transitive VP) does
19
+ // not subordinate its subject — the two MATCH (co-nuclei);
20
+ // • the modifier asymmetry (§6.5.1): a modifier that PRECEDES its modifiee gets its
21
+ // OWN accentual domain (both project) — unlike an argument, which integrates;
22
+ // • associative domains (§2.2.2): same-coordinator conjuncts MATCH (flat), not NSR.
23
+ //
24
+ // To express MATCH (two co-equal sisters, neither subordinated) the relational grid is
25
+ // generalised: each constituent carries a SET of designated terminal elements (dtes),
26
+ // and
27
+ //
28
+ // level(w) = 1 + #{ enclosing constituents c : w ∈ c.words ∧ w ∉ c.dtes }
29
+ //
30
+ // A normal NSR/CSR cycle has a singleton dte (the winner's), so the formula still
31
+ // reproduces the canonical derivation
32
+ // [Mary [ate [sweet [ice cream]]]] → Mary 2, ate 3, sweet 4, ice 1, cream 5
33
+ // (Krifka 2001 Table 17); a MATCH cycle co-designates BOTH sisters' dtes, so neither is
34
+ // demoted at that cycle (a branching subject + VP give two co-nuclei).
35
+ //
36
+ // Output is per-word `phraseStress` (1 = strongest, punctuation = 0): the structural
37
+ // prominence the relativiser then reads PER PHONOLOGICAL PHRASE to place beats.
38
+
39
+ import { ClsSentence, ClsWord } from '../types.js';
40
+ import {
41
+ isPunct, isVerb, verbHasArgChild,
42
+ isUnaccusativeOrPassive, isLowLocative, isFrameSetting, isLightNominalHead,
43
+ } from './syntax.js';
44
+
45
+ const NOUN = /^(NN|NNS|NNP|NNPS)$/;
46
+
47
+ /** A head-dependent combination is a COMPOUND (Compound Stress Rule → primary LEFT)
48
+ * when the parse labels it NOMD, or when a common-/proper-noun modifier sits
49
+ * immediately to the left of a noun head — the N+N compound the tagger routinely
50
+ * mislabels AMOD ("ICE cream", "SLATE roof", "BIRD nest"). */
51
+ function isCompound(dep: ClsWord, head: ClsWord): boolean {
52
+ if ((dep.canonicalRel ?? '') === 'NOMD') return true;
53
+ if ((dep.canonicalRel ?? '') === 'EXT') return false;
54
+ return NOUN.test(dep.lexicalClass) && NOUN.test(head.lexicalClass)
55
+ && Math.abs(dep.absoluteIndex - head.absoluteIndex) === 1;
56
+ }
57
+
58
+ interface Cons { words: ClsWord[]; dtes: ClsWord[]; }
59
+
60
+ function bare(w: ClsWord): string { return w.word.toLowerCase().replace(/['’]/g, ''); }
61
+
62
+ /** The coordinator lemma of a conjunct (its `cc` dependent), or null. */
63
+ function coordinatorLemma(conjunct: ClsWord, words: ClsWord[]): string | null {
64
+ for (const w of words) {
65
+ if (w.dependency?.governor === conjunct &&
66
+ (w.canonicalRel === 'CC' || w.lexicalClass === 'CC')) {
67
+ return bare(w);
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** Is the coordination headed by `head` ASSOCIATIVE (all conjuncts share one
74
+ * coordinator: "old and gray and full") rather than ARTICULATED (mixed and/or)?
75
+ * Associative → MATCH (flat, Wagner §2.2.2 / Newman); articulated → NSR. */
76
+ function conjIsAssociative(
77
+ head: ClsWord, children: Map<number, ClsWord[]>, words: ClsWord[],
78
+ ): boolean {
79
+ const conjuncts = (children.get(head.absoluteIndex) ?? [])
80
+ .filter(c => (c.canonicalRel ?? '') === 'CONJ');
81
+ if (conjuncts.length < 1) return true;
82
+ const coords = conjuncts.map(c => coordinatorLemma(c, words)).filter(Boolean);
83
+ if (coords.length === 0) return true; // no overt coordinator → flat
84
+ return coords.every(c => c === coords[0]);
85
+ }
86
+
87
+ /** A child relation that makes a verb BRANCH — carry an internal argument.
88
+ * Used for the specifier-restriction trigger and for distinguishing a bare
89
+ * participle ("little boy lost" — no args, adjective-like) from a participial
90
+ * PHRASE ("gone in the teeth" — has an OBL, a real clause). */
91
+ const ARG_CHILD_LOCAL = new Set(['DOBJ', 'IOBJ', 'OBJ', 'OBL', 'CCOMP', 'XCOMP', 'ADVCL']);
92
+ function hasArgChildren(w: ClsWord, children: Map<number, ClsWord[]>): boolean {
93
+ return (children.get(w.absoluteIndex) ?? []).some(c =>
94
+ ARG_CHILD_LOCAL.has(c.canonicalRel ?? ''));
95
+ }
96
+
97
+ /** Decide which sister of a (dep, head) edge is the constituent's DTE — 'dep',
98
+ * 'head', or 'match' (both co-designated). Encodes Wagner's combination rules. */
99
+ function combineDecision(
100
+ dep: ClsWord, head: ClsWord,
101
+ children: Map<number, ClsWord[]>, words: ClsWord[],
102
+ ): 'dep' | 'head' | 'match' {
103
+ const rel = dep.canonicalRel ?? '';
104
+ const depBefore = dep.absoluteIndex < head.absoluteIndex;
105
+ const rightWins: 'dep' | 'head' = depBefore ? 'head' : 'dep'; // NSR rightmost
106
+ const leftWins: 'dep' | 'head' = depBefore ? 'dep' : 'head'; // CSR leftmost
107
+
108
+ // Compound Stress Rule first (handles the backwards-parsed N+N the tagger mislabels).
109
+ if (isCompound(dep, head)) return leftWins;
110
+ if (rel === 'EXT') return rightWins; // proper-name head (New YORK)
111
+
112
+ switch (rel) {
113
+ case 'NOMD':
114
+ return leftWins; // CSR: complement (N1) prominent
115
+ case 'DOBJ': case 'IOBJ': case 'OBJ':
116
+ case 'CCOMP': case 'XCOMP':
117
+ return 'dep'; // argument is the DTE
118
+ case 'OBL':
119
+ // A post-nominal / post-adjectival oblique ("a friend OF him", "full OF
120
+ // sleep") is a complement of its NON-verbal head and is subordinated to it
121
+ // (the head stays the local nuclear). Only a VERBAL oblique is an argument
122
+ // / locative of the predicate.
123
+ if (!isVerb(head)) return 'head';
124
+ if (isLowLocative(dep, head, words)) return 'match'; // low locative → own accent
125
+ if (isFrameSetting(dep, words)) return 'match'; // frame-setting → own domain
126
+ return 'dep'; // oblique argument → DTE
127
+ case 'NMOD':
128
+ return 'head'; // post-nominal modifier subordinated
129
+ case 'NSUBJ': case 'NSUBJPASS':
130
+ if (verbHasArgChild(head, children)) return 'match'; // branching VP → specifier restriction
131
+ if (isUnaccusativeOrPassive(dep, head)) return 'dep'; // underlying object → subject DTE
132
+ return rightWins; // unergative → verb (NSR) DTE
133
+ case 'AMOD':
134
+ return depBefore ? 'match' : 'head'; // pre-modifier → own domain; post → subordinate
135
+ case 'ADVMOD':
136
+ if (/^JJ/.test(head.lexicalClass)) return 'head'; // degree adv ("very TALL") integrates
137
+ return depBefore ? 'match' : 'head'; // pre-adv → own domain; post → subordinate
138
+ case 'ACL': case 'ADVCL':
139
+ // A BARE participial acl (no argument children — "little boy lost",
140
+ // "mission accomplished") is adjective-like: the NSR gives right-stress
141
+ // (the participle is the nuclear: "little boy LOST"). A participial PHRASE
142
+ // ("gone in the teeth") has arguments, so the HEAD NOUN stays the nuclear
143
+ // and the participial phrase is subordinated to it ("old BITCH gone in the
144
+ // teeth").
145
+ if (!hasArgChildren(dep, children)) return 'dep'; // bare participle → NSR right-stress
146
+ return 'head'; // participial phrase → head noun wins
147
+ case 'CASE':
148
+ return 'head'; // preposition subordinated to its noun
149
+ // (the "OF him" differentiation — prep beat over a given pronoun — is set in
150
+ // relstress.ts, not here, so a function word never becomes the phrase nuclear)
151
+ case 'AUX': case 'AUXPASS': case 'DET': case 'NUMMOD':
152
+ case 'EXPL': case 'COMPMARK': case 'ADVMARK': case 'CC':
153
+ return 'head'; // function-word functor subordinated
154
+ case 'VPRT': case 'DISCOURSE': case 'INTJ':
155
+ return 'match'; // particle / interjection keep accent
156
+ case 'CONJ':
157
+ return conjIsAssociative(head, children, words) ? 'match' : rightWins;
158
+ default:
159
+ return rightWins; // NSR: rightmost wins
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Run the cyclic Compound + Nuclear Stress Rules (Wagner-extended) over the
165
+ * sentence's dependency tree and write each word's `phraseStress` (1 = strongest).
166
+ */
167
+ export function computePhraseStress(sent: ClsSentence): void {
168
+ for (const w of sent.words) if (isPunct(w)) w.phraseStress = 0;
169
+ const words = sent.words.filter(w => !isPunct(w));
170
+ if (words.length === 0) return;
171
+
172
+ const childrenOf = new Map<number, ClsWord[]>();
173
+ const roots: ClsWord[] = [];
174
+ for (const w of words) {
175
+ const gov = w.dependency?.governor;
176
+ if (gov && !isPunct(gov) && gov !== w) {
177
+ const arr = childrenOf.get(gov.absoluteIndex);
178
+ if (arr) arr.push(w); else childrenOf.set(gov.absoluteIndex, [w]);
179
+ } else {
180
+ roots.push(w);
181
+ }
182
+ }
183
+
184
+ const cons: Cons[] = [];
185
+ const visited = new Set<ClsWord>();
186
+
187
+ function combine(left: Cons, right: Cons, dep: ClsWord, head: ClsWord): Cons {
188
+ const depBefore = dep.absoluteIndex < head.absoluteIndex;
189
+ const depCons = depBefore ? left : right;
190
+ const headCons = depBefore ? right : left;
191
+ const decision = combineDecision(dep, head, childrenOf, words);
192
+ let dtes: ClsWord[];
193
+ if (decision === 'match') {
194
+ dtes = [...left.dtes, ...right.dtes];
195
+ // For an ASSOCIATIVE coordination, the conjunct HEAD words are all co-equal
196
+ // accents (old / gray / full), even when a conjunct's own internal nuclear
197
+ // differs ("full of sleep" → sleep) — so co-designate the conjunct heads too,
198
+ // keeping the coordinate accents level instead of letting one sink.
199
+ if ((dep.canonicalRel ?? '') === 'CONJ') {
200
+ if (!dtes.includes(dep)) dtes.push(dep);
201
+ if (!dtes.includes(head)) dtes.push(head);
202
+ }
203
+ } else if (decision === 'dep') {
204
+ dtes = depCons.dtes;
205
+ } else {
206
+ dtes = headCons.dtes;
207
+ }
208
+ const c: Cons = { words: [...left.words, ...right.words], dtes };
209
+ cons.push(c);
210
+ return c;
211
+ }
212
+
213
+ function project(head: ClsWord): Cons {
214
+ visited.add(head);
215
+ let cur: Cons = { words: [head], dtes: [head] };
216
+ const kids = (childrenOf.get(head.absoluteIndex) ?? []).filter(k => !visited.has(k));
217
+ const left = kids.filter(k => k.absoluteIndex < head.absoluteIndex)
218
+ .sort((a, b) => b.absoluteIndex - a.absoluteIndex); // closest-first
219
+ const right = kids.filter(k => k.absoluteIndex > head.absoluteIndex)
220
+ .sort((a, b) => a.absoluteIndex - b.absoluteIndex); // closest-first
221
+ for (const d of right) cur = combine(cur, project(d), d, head); // head LEFT, dep RIGHT
222
+ for (const d of left) cur = combine(project(d), cur, d, head); // dep LEFT, head RIGHT
223
+ return cur;
224
+ }
225
+
226
+ roots.sort((a, b) => a.absoluteIndex - b.absoluteIndex);
227
+ let top: Cons | null = null;
228
+ for (const r of roots) {
229
+ if (visited.has(r)) continue;
230
+ const pc = project(r);
231
+ if (!top) {
232
+ top = pc;
233
+ } else {
234
+ const c: Cons = { words: [...top.words, ...pc.words], dtes: pc.dtes }; // NSR: right root wins
235
+ cons.push(c);
236
+ top = c;
237
+ }
238
+ }
239
+
240
+ // level(w) = 1 + #{ constituents c : w ∈ c.words ∧ w ∉ c.dtes }
241
+ for (const w of words) {
242
+ let demotions = 0;
243
+ for (const c of cons) if (!c.dtes.includes(w) && c.words.includes(w)) demotions++;
244
+ w.phraseStress = 1 + demotions;
245
+ }
246
+
247
+ // Accent-level clash reduction (Wagner §6.4.1): runs BEFORE the syllable-level
248
+ // resolveStressClashes (relstress.ts) and is additional to it. See below.
249
+ reduceAccentClashes(words);
250
+ }
251
+
252
+ /** Accent-level clash reduction (Wagner §6.4.1). Operates on which CONTENT words
253
+ * bear a phrasal accent (a local prominence), NOT on syllable stress. A run of
254
+ * three-or-more SURFACE-ADJACENT content words (no intervening function word) that
255
+ * share the SAME phraseStress is a clash plateau: the last is the nuclear (never
256
+ * dropped), the first keeps its accent, and medial accents are thinned by the
257
+ * alternation rule (keep every other one). Dropped accents are demoted one rung
258
+ * (phraseStress += 1), increasing contour differentiation without flattening.
259
+ *
260
+ * This is deliberately narrow — it fires only on a true equal-prominence plateau —
261
+ * so ordinary verse, where the cyclic rules already differentiate, is untouched.
262
+ * The syllable-level resolveStressClashes still runs afterward for cross-φ
263
+ * abutments and the compound-pair special case. */
264
+ function reduceAccentClashes(words: ClsWord[]): void {
265
+ const ordered = [...words].sort((a, b) => a.absoluteIndex - b.absoluteIndex);
266
+ let i = 0;
267
+ while (i < ordered.length) {
268
+ if (!ordered[i].isContent) { i++; continue; }
269
+ // Maximal run of surface-adjacent content words (adjacent absolute indices).
270
+ let j = i;
271
+ while (j + 1 < ordered.length && ordered[j + 1].isContent &&
272
+ ordered[j + 1].absoluteIndex === ordered[j].absoluteIndex + 1) {
273
+ j++;
274
+ }
275
+ const run = ordered.slice(i, j + 1);
276
+ if (run.length >= 3) {
277
+ const minPs = Math.min(...run.map(w => w.phraseStress));
278
+ const plateau = run.filter(w => w.phraseStress === minPs);
279
+ // Only thin when MOST of the run sits on the same prominence (a real plateau).
280
+ if (plateau.length >= 3) {
281
+ // keep first and last; drop alternate medial members.
282
+ for (let k = 1; k < plateau.length - 1; k++) {
283
+ if (k % 2 === 1) plateau[k].phraseStress = minPs + 1; // drop every other medial accent
284
+ }
285
+ }
286
+ }
287
+ i = j + 1;
288
+ }
289
+ }
290
+
291
+ // ─── ϕ-domain derivation (the phonological-phrase grounding) ──────────────
292
+ //
293
+ // The phonological-phrase (ϕ) layer is derived from the SAME dependency
294
+ // constituent structure the cyclic stress rules use above — not from a parallel
295
+ // POS heuristic. Two adjacent words share a ϕ iff the dependency edge between
296
+ // them is ϕ-INTERNAL; a new ϕ opens at every ϕ-PROJECTING edge.
297
+
298
+ const NOMINAL = /^(NN|NNS|NNP|NNPS)$/;
299
+ const COPULA = new Set(['am', 'is', 'are', 'was', 'were']);
300
+
301
+ function isCopula(w: ClsWord): boolean {
302
+ return COPULA.has(w.word.toLowerCase().replace(/['’]/g, '')) && /^VB/.test(w.lexicalClass);
303
+ }
304
+
305
+ /** Does a nominal `dep` BRANCH — carry its own NOMINAL modifier? An ADVERB child
306
+ * does NOT count. */
307
+ function branchesNominally(dep: ClsWord, children: Map<number, ClsWord[]>): boolean {
308
+ return (children.get(dep.absoluteIndex) ?? []).some(c => {
309
+ if (!c.isContent) return false;
310
+ if (/^RB/.test(c.lexicalClass) || (c.canonicalRel ?? '') === 'ADVMOD') return false;
311
+ return true;
312
+ });
313
+ }
314
+
315
+ /** Does `dep`'s subtree open its OWN ϕ relative to its governor `head`? */
316
+ function phiProjects(
317
+ dep: ClsWord, head: ClsWord, children: Map<number, ClsWord[]>, words: ClsWord[],
318
+ ): boolean {
319
+ switch (dep.canonicalRel ?? '') {
320
+ case 'CCOMP': case 'XCOMP': case 'ADVCL': case 'CONJ':
321
+ return true;
322
+ case 'ACL':
323
+ // A BARE participial acl ("little boy lost", "mission accomplished" — no
324
+ // argument children) is adjective-like: it stays WITH its head noun in one
325
+ // ϕ, and the NSR gives right-stress (LOST is the nuclear). A participial
326
+ // PHRASE ("gone in the teeth") has arguments and is a clause: it projects
327
+ // its own ϕ, separate from the head noun.
328
+ return hasArgChildren(dep, children);
329
+ case 'OBL':
330
+ // A verbal oblique is its own ϕ — EXCEPT an oblique whose governor is an
331
+ // ACL (a participle): it is a complement of the participial phrase, not an
332
+ // independent locative, so it stays INSIDE the ACL's ϕ ("gone in the teeth"
333
+ // is one ϕ, not two). This prevents the oblique from being stranded alone
334
+ // while the participle projects separately.
335
+ if ((head.canonicalRel ?? '') === 'ACL') return false;
336
+ if (!isVerb(head)) return isLightNominalHead(head); // post-nominal PP of a LIGHT
337
+ // head ("Something | for the modern stage") projects; of a FULL noun ("a mould
338
+ // in plaster") it does not — see the NMOD case for the same modifier asymmetry.
339
+ if (isLowLocative(dep, head, words) || isFrameSetting(dep, words)) return true;
340
+ return true; // verbal oblique → own ϕ
341
+ case 'NSUBJ': case 'NSUBJPASS':
342
+ return NOMINAL.test(dep.lexicalClass);
343
+ case 'DOBJ': case 'IOBJ': case 'OBJ':
344
+ if (isCopula(head)) return false;
345
+ return branchesNominally(dep, children);
346
+ case 'NMOD':
347
+ // A post-nominal PP modifier of a LIGHT head (a pronoun / inherently-given
348
+ // indefinite — "Something | for the modern stage") opens its OWN ϕ: the head
349
+ // is too light to host the modifier, so a real phrasing break falls after it
350
+ // (Wagner §6.5.1). A FULL lexical-noun head keeps its post-nominal PP as an
351
+ // internal κ ("a mould in plaster" stays one ϕ), so this does not over-segment.
352
+ return isLightNominalHead(head);
353
+ default:
354
+ return false;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Partition the sentence's words into ϕ-domains over the dependency tree.
360
+ */
361
+ export function computePhiDomains(sent: ClsSentence): Map<ClsWord, number> {
362
+ const words = sent.words.filter(w => !isPunct(w));
363
+
364
+ const children = new Map<number, ClsWord[]>();
365
+ for (const w of words) {
366
+ const g = w.dependency?.governor;
367
+ if (g && !isPunct(g) && g !== w) {
368
+ const a = children.get(g.absoluteIndex);
369
+ if (a) a.push(w); else children.set(g.absoluteIndex, [w]);
370
+ }
371
+ }
372
+
373
+ const parent = new Map<number, number>();
374
+ for (const w of words) parent.set(w.absoluteIndex, w.absoluteIndex);
375
+ const find = (x: number): number => {
376
+ while (parent.get(x)! !== x) { parent.set(x, parent.get(parent.get(x)!)!); x = parent.get(x)!; }
377
+ return x;
378
+ };
379
+ const union = (a: number, b: number) => { parent.set(find(a), find(b)); };
380
+
381
+ for (const w of words) {
382
+ const g = w.dependency?.governor;
383
+ if (!g || isPunct(g) || g === w) continue;
384
+ if (!phiProjects(w, g, children, words)) union(w.absoluteIndex, g.absoluteIndex);
385
+ }
386
+
387
+ const dom = new Map<ClsWord, number>();
388
+ for (const w of words) dom.set(w, find(w.absoluteIndex));
389
+ return dom;
390
+ }
@@ -0,0 +1,160 @@
1
+ // calliope/deps.ts — canonical dependency normalisation for the Calliope engine.
2
+ //
3
+ // en-parse emits a hybrid Stanford/UD label set, partly unreliable. This pass
4
+ // writes a normalised Scenario relation onto `word.canonicalRel`, the label space
5
+ // the Match-Theory prosodic builder (Stage 2) and the Scenario A–O stress rules
6
+ // (Stage 3) read. It is ADDITIVE — it never mutates the raw `dependency`, so the
7
+ // legacy/Clio passes see exactly the same parse as before.
8
+ //
9
+ // Where en-parse is reliable the mapping is a straight relabel; where it is not —
10
+ // the ditransitive DOBJ/IOBJ swap (probed: "gave John a book" → John=dobj,
11
+ // book=iobj, reversed), or N+N compounds it labels generic `dep` — POS and surface
12
+ // adjacency decide. (Head-changing repairs — coordinate re-heading, fronted
13
+ // adverbial re-root, invocations — are handled where the prosodic builder needs
14
+ // them, Stage 2.)
15
+
16
+ import { ClsSentence, ClsWord } from '../types.js';
17
+
18
+ const NOUN = /^(NN|NNS|NNP|NNPS)$/;
19
+ const PROPER = /^(NNP|NNPS)$/;
20
+ const VERB = /^VB/;
21
+ const ADJ = /^JJ/;
22
+
23
+ function rawRel(w: ClsWord): string {
24
+ return (w.dependency?.dependentType ?? '').toLowerCase();
25
+ }
26
+ function gov(w: ClsWord): ClsWord | undefined {
27
+ return w.dependency?.governor;
28
+ }
29
+ /** w immediately precedes its head (a pre-head modifier — the N+N / Adj+N frame). */
30
+ function preHead(w: ClsWord, head: ClsWord): boolean {
31
+ return w.absoluteIndex + 1 === head.absoluteIndex;
32
+ }
33
+
34
+ /** Populate `canonicalRel` for every word, then apply label-only repairs. */
35
+ export function normalizeDeps(sent: ClsSentence): void {
36
+ for (const w of sent.words) w.canonicalRel = canonical(w);
37
+ fixDitransitive(sent.words);
38
+ inferPrenominalModifiers(sent.words);
39
+ }
40
+
41
+ /** Reliable structural relations whose label adjacency must NOT override. */
42
+ const STRUCTURAL = new Set([
43
+ 'ROOT', 'NSUBJ', 'NSUBJPASS', 'DOBJ', 'IOBJ', 'OBL', 'AUX', 'AUXPASS',
44
+ 'CCOMP', 'XCOMP', 'ADVCL', 'ADVMOD', 'AMOD', 'ACL', 'CC', 'CONJ', 'EXPL',
45
+ 'INTJ', 'DISCOURSE', 'VPRT', 'COMPMARK', 'ADVMARK', 'EXT',
46
+ ]);
47
+
48
+ /**
49
+ * Pre-head modifier inference by SURFACE ADJACENCY — independent of en-parse's
50
+ * (often unreliable) head links. An attributive adjective immediately before a
51
+ * noun is AMOD; a noun immediately before a noun is a NOMD noun adjunct (or EXT
52
+ * for a proper+proper name span). This is what lets a POS-corrected adjective
53
+ * (Pale/High/Green, demoted from a spurious NNP by `correctPosWithLexicon`) read
54
+ * as the AMOD it is, rather than collapsing to a bare `dep`. Only fills a word
55
+ * whose current label is non-structural, so deliberate relations are preserved.
56
+ */
57
+ function inferPrenominalModifiers(words: ClsWord[]): void {
58
+ for (let i = 0; i + 1 < words.length; i++) {
59
+ const w = words[i];
60
+ const h = words[i + 1];
61
+ if (w.absoluteIndex + 1 !== h.absoluteIndex) continue; // surface-adjacent
62
+ if (!NOUN.test(h.lexicalClass)) continue; // head is a noun
63
+ if (STRUCTURAL.has(w.canonicalRel ?? '')) continue; // keep real relations
64
+ if (ADJ.test(w.lexicalClass)) {
65
+ w.canonicalRel = 'AMOD';
66
+ } else if (NOUN.test(w.lexicalClass)) {
67
+ w.canonicalRel = PROPER.test(w.lexicalClass) && PROPER.test(h.lexicalClass) ? 'EXT' : 'NOMD';
68
+ }
69
+ }
70
+ }
71
+
72
+ function canonical(w: ClsWord): string {
73
+ const rel = rawRel(w);
74
+ const pos = w.lexicalClass;
75
+ const g = gov(w);
76
+ const gpos = g?.lexicalClass ?? '';
77
+
78
+ switch (rel) {
79
+ case 'root': return 'ROOT';
80
+ case 'nsubj': return 'NSUBJ';
81
+ case 'nsubjpass': case 'nsubj:pass': return 'NSUBJPASS';
82
+ case 'csubj': return 'NSUBJ';
83
+ case 'csubjpass': case 'csubj:pass': return 'NSUBJPASS';
84
+ case 'dobj': case 'obj': return 'DOBJ';
85
+ case 'iobj': return 'IOBJ';
86
+ case 'aux': return 'AUX';
87
+ case 'auxpass': case 'aux:pass': return 'AUXPASS';
88
+ // UD oblique nominal (UDPipe emits `obl`; the old path used Stanford `pobj`).
89
+ case 'obl': case 'obl:npmod': case 'obl:tmod': case 'obl:arg': return 'OBL';
90
+ case 'cop': return 'AUX'; // copula behaves prosodically like an auxiliary
91
+ case 'ccomp': return 'CCOMP';
92
+ case 'xcomp': return 'XCOMP';
93
+ case 'advcl': return 'ADVCL';
94
+ case 'advmod': return 'ADVMOD';
95
+ case 'amod': return 'AMOD';
96
+ case 'acl': case 'relcl': case 'acl:relcl': return 'ACL';
97
+ case 'det': case 'predet': return 'DET';
98
+ case 'nummod': return 'NUMMOD';
99
+ case 'cc': return 'CC';
100
+ case 'conj': return 'CONJ';
101
+ case 'expl': return 'EXPL';
102
+ case 'intj': return 'INTJ';
103
+ case 'discourse': return 'DISCOURSE';
104
+ case 'prt': case 'compound:prt': return 'VPRT';
105
+ case 'case': return 'CASE';
106
+ case 'poss': case 'possessive': case 'nmod:poss': return 'CASE';
107
+ case 'prep': return 'CASE'; // the preposition itself cliticises
108
+ case 'pobj': return 'OBL'; // object of a preposition → oblique
109
+ case 'mark': return markType(w); // complementiser vs adverbial subordinator
110
+ case 'nmod':
111
+ if (NOUN.test(pos) && g && NOUN.test(gpos) && preHead(w, g)) return 'NOMD';
112
+ return 'OBL';
113
+ case 'compound':
114
+ return 'NOMD';
115
+ case 'flat': case 'flat:name': case 'name':
116
+ return 'EXT';
117
+ }
118
+
119
+ // Generic `dep` / unknown: infer from POS + adjacency.
120
+ if (NOUN.test(pos) && g && NOUN.test(gpos) && preHead(w, g)) {
121
+ // A proper-name span (both proper, adjacent) reads as an EXT extension; a
122
+ // common-noun pre-modifier is a NOMD noun adjunct.
123
+ return PROPER.test(pos) && PROPER.test(gpos) ? 'EXT' : 'NOMD';
124
+ }
125
+ if (ADJ.test(pos) && g && NOUN.test(gpos)) return 'AMOD';
126
+ if (pos === 'RP') return 'VPRT';
127
+ if (pos === 'CC') return 'CC';
128
+ if (pos === 'DT' || pos === 'PDT') return 'DET';
129
+ if (pos === 'CD') return 'NUMMOD';
130
+ if (pos === 'IN' || pos === 'TO') return 'CASE';
131
+ return rel ? rel.toUpperCase() : 'DEP';
132
+ }
133
+
134
+ /** A `mark` heads a complement clause (COMPMARK: to/that) or an adverbial clause
135
+ * (ADVMARK: as/when/because). Decide by the governed clause's own relation. */
136
+ function markType(w: ClsWord): string {
137
+ const clauseVerb = gov(w);
138
+ const crel = clauseVerb ? rawRel(clauseVerb) : '';
139
+ return crel === 'advcl' ? 'ADVMARK' : 'COMPMARK';
140
+ }
141
+
142
+ /** Ditransitive correction: a verb governing two bare objects N1 (precedes) N2 is
143
+ * often labelled N1=DOBJ N2=IOBJ — reversed. The first post-verbal object is the
144
+ * recipient (IOBJ), the second the theme (DOBJ). */
145
+ function fixDitransitive(words: ClsWord[]): void {
146
+ const byGov = new Map<ClsWord, ClsWord[]>();
147
+ for (const w of words) {
148
+ if (w.canonicalRel !== 'DOBJ' && w.canonicalRel !== 'IOBJ') continue;
149
+ const g = gov(w);
150
+ if (!g || !VERB.test(g.lexicalClass)) continue;
151
+ const list = byGov.get(g);
152
+ if (list) list.push(w); else byGov.set(g, [w]);
153
+ }
154
+ for (const objs of byGov.values()) {
155
+ if (objs.length !== 2) continue;
156
+ objs.sort((a, b) => a.absoluteIndex - b.absoluteIndex);
157
+ objs[0].canonicalRel = 'IOBJ';
158
+ objs[1].canonicalRel = 'DOBJ';
159
+ }
160
+ }
@@ -0,0 +1,77 @@
1
+ // calliope/engine.ts — the "Calliope" engine: the faithful, default,
2
+ // syntax-driven prosody pipeline. It derives the κ/ϕ/ι prosodic hierarchy and
3
+ // word prominence from a canonical, DepEdit-normalised dependency parse via the
4
+ // Scenario A–O relation-keyed stress rules and the corrected Match-Theory
5
+ // boundary map.
6
+ //
7
+ // Build status: STAGE 0 — the engine seam is in place but the per-sentence
8
+ // sequence is, for now, identical to the legacy ("Clio") one, so the default
9
+ // output is unchanged and the existing tests stay green. Stages 1–4 progressively
10
+ // replace the body with: canonical deps (Stage 1) → Match-Theory hierarchy
11
+ // (Stage 2) → Scenario A–O stress (Stage 3) → phrase-stress ramp + relativise
12
+ // (Stage 4). The legacy path remains untouched in `src/clio/engine.ts`.
13
+
14
+ import { ClsSentence, IntonationalUnit } from '../types.js';
15
+ import { assignLexicalStress, applySurfacePostProcessing, detectDisplayPrefixes, applyStressShift } from '../stress.js';
16
+ import { ProsodyEngine } from '../engine.js';
17
+ import { correctPosWithLexicon } from './postag.js';
18
+ import { tagNames } from './names.js';
19
+ import { parseFeats } from './feats.js';
20
+ import { normalizeDeps } from './deps.js';
21
+ import { buildProsodicHierarchy } from './prosodic.js';
22
+ import { computePhraseStress } from './bracketing.js';
23
+ import { computeRelativeStress } from './relstress.js';
24
+
25
+ function analyzeSentenceCalliope(sent: ClsSentence): IntonationalUnit[] {
26
+ // ── Stage F1: reliable parse over the whole utterance (Calliope-only). ──
27
+ // Correct spurious proper-noun tags via en-lexicon (Pale/High → JJ); type real
28
+ // proper nouns as person/place names; normalise en-parse's hybrid relations into
29
+ // the Scenario label space on word.canonicalRel (with surface-adjacency fallback
30
+ // for pre-head modifiers). Mutates the Calliope-only view of the parse; Clio,
31
+ // invoked via --clio, never runs these and keeps its frozen reading.
32
+ correctPosWithLexicon(sent);
33
+ tagNames(sent);
34
+ // Parse UD morphological FEATS (Number/VerbForm/Voice/PronType/Definite/Degree/…)
35
+ // from lexicalDetails onto word.featsMap so the Wagner/Krifka stress + bracketing
36
+ // refinements can read morphology. Must precede normalizeDeps (which may consult it).
37
+ parseFeats(sent);
38
+ normalizeDeps(sent);
39
+
40
+ // ── Stress path: lexical → genuine phrase stress → relative, per McAleese E4. ──
41
+ // 1. Lexical stress (syllabification + word contour 0-3).
42
+ // 2. κ/ϕ/ι hierarchy fixed over the whole utterance from the dependency relations.
43
+ // 3. PHRASE STRESS: the genuine cyclic Compound + Nuclear Stress Rules over the
44
+ // dependency tree's constituent bracketing (bracketing.ts) — an integer
45
+ // prominence ranking (1 = strongest utterance nuclear), NOT a ramp.
46
+ // 4. RELATIVE STRESS: the x/w/n/m/s contour DERIVED per φ from that phrase stress
47
+ // (the φ's lowest-phraseStress word is its beat), then clash-resolved. The two
48
+ // layers are separate signals — global integer vs local contour — free to
49
+ // diverge. (Replaces the legacy compound→nuclear→phrase→relativise chain, which
50
+ // Clio still runs.)
51
+ assignLexicalStress(sent.words);
52
+ // Display-only prefix detection: set `morphPrefix` on words whose productive
53
+ // prefix + dictionary stem split would guide the display syllabifier to
54
+ // respect the morpheme boundary (dis·il·lu·sions, un·ed·u·ca·ted). Runs for
55
+ // ALL words (in-vocab AND OOV), never affects stress or meter.
56
+ detectDisplayPrefixes(sent.words);
57
+ const ius = buildProsodicHierarchy(sent);
58
+ // Stress Shift: swap primary↔secondary for words where Nounsing-Pro confirms
59
+ // shiftLikely=true AND the context motivates it (imperative at phrase start or
60
+ // Rhythm Rule clash). Runs after hierarchy (needs PP-initial info) but before
61
+ // phrase stress (so the shifted peak flows into the NSR ramp).
62
+ applyStressShift(sent.words, ius);
63
+ computePhraseStress(sent);
64
+ computeRelativeStress(sent.words, ius);
65
+ // Surface-order post-processing passes shared with the Clio engine: compound
66
+ // forestress, lexicalised collocation forestress, hyphen-seam clash resolution,
67
+ // residual linear clash resolution, and exclaimed-interjection raise. These
68
+ // re-assert forestress on surface-adjacent pairs the hierarchy-order passes may
69
+ // miss (mis-grouped parses) and catch any residual equal-stress clashes.
70
+ applySurfacePostProcessing(sent.words);
71
+ return ius;
72
+ }
73
+
74
+ export const calliopeEngine: ProsodyEngine = {
75
+ name: 'calliope',
76
+ analyzeSentence: analyzeSentenceCalliope,
77
+ };