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
package/dist/parser.js CHANGED
@@ -1,234 +1,34 @@
1
- // parser.ts — Syntactic dependency parser powered by FinNLP, producing
2
- // ClsDocument with full dependency graph and phrase‑structure node tree
3
- // that matches the Universal Dependencies format as used in McAleeses Calliope.
4
- import * as Lexed from 'lexed';
5
- import * as EnPos from 'en-pos';
6
- import * as EnParse from 'en-parse';
7
- import * as EnNorm from 'en-norm';
8
- import { correctTags } from './tagfix.js';
9
- import { applyDepFixes } from './depfix.js';
10
- const CONTRACTION_MAP = {
11
- "we'll": { host: 'we', clitic: 'will' },
12
- "we've": { host: 'we', clitic: 'have' },
13
- "we're": { host: 'we', clitic: 'are' },
14
- "we'd": { host: 'we', clitic: 'would' },
15
- "i'll": { host: 'i', clitic: 'will' },
16
- "i've": { host: 'i', clitic: 'have' },
17
- "i'm": { host: 'i', clitic: 'am' },
18
- "i'd": { host: 'i', clitic: 'would' },
19
- "you'll": { host: 'you', clitic: 'will' },
20
- "you've": { host: 'you', clitic: 'have' },
21
- "you're": { host: 'you', clitic: 'are' },
22
- "you'd": { host: 'you', clitic: 'would' },
23
- "he'll": { host: 'he', clitic: 'will' },
24
- "he's": { host: 'he', clitic: 'is' },
25
- "he'd": { host: 'he', clitic: 'would' },
26
- "she'll": { host: 'she', clitic: 'will' },
27
- "she's": { host: 'she', clitic: 'is' },
28
- "she'd": { host: 'she', clitic: 'would' },
29
- "it'll": { host: 'it', clitic: 'will' },
30
- "it'd": { host: 'it', clitic: 'would' },
31
- "they'll": { host: 'they', clitic: 'will' },
32
- "they've": { host: 'they', clitic: 'have' },
33
- "they're": { host: 'they', clitic: 'are' },
34
- "they'd": { host: 'they', clitic: 'would' },
35
- "that's": { host: 'that', clitic: 'is' },
36
- "that'll": { host: 'that', clitic: 'will' },
37
- "this'll": { host: 'this', clitic: 'will' },
38
- "it's": { host: 'it', clitic: 'is' },
39
- "who'll": { host: 'who', clitic: 'will' },
40
- "who's": { host: 'who', clitic: 'is' },
41
- "who'd": { host: 'who', clitic: 'would' },
42
- "who've": { host: 'who', clitic: 'have' },
43
- "what's": { host: 'what', clitic: 'is' },
44
- "there's": { host: 'there', clitic: 'is' },
45
- "here's": { host: 'here', clitic: 'is' },
46
- "where's": { host: 'where', clitic: 'is' },
47
- "when's": { host: 'when', clitic: 'is' },
48
- "how's": { host: 'how', clitic: 'is' },
49
- "why's": { host: 'why', clitic: 'is' },
50
- "one's": { host: 'one', clitic: 'is' },
51
- "let's": { host: 'let', clitic: 'us' },
52
- "y'all": { host: 'you', clitic: 'all' },
53
- "don't": { host: 'do', clitic: 'not' },
54
- "can't": { host: 'can', clitic: 'not' },
55
- "won't": { host: 'will', clitic: 'not' },
56
- "shouldn't": { host: 'should', clitic: 'not' },
57
- "couldn't": { host: 'could', clitic: 'not' },
58
- "wouldn't": { host: 'would', clitic: 'not' },
59
- "isn't": { host: 'is', clitic: 'not' },
60
- "aren't": { host: 'are', clitic: 'not' },
61
- "wasn't": { host: 'was', clitic: 'not' },
62
- "weren't": { host: 'were', clitic: 'not' },
63
- "haven't": { host: 'have', clitic: 'not' },
64
- "hasn't": { host: 'has', clitic: 'not' },
65
- "hadn't": { host: 'had', clitic: 'not' },
66
- "didn't": { host: 'did', clitic: 'not' },
67
- "doesn't": { host: 'does', clitic: 'not' },
68
- "ain't": { host: 'am', clitic: 'not' },
69
- "might've": { host: 'might', clitic: 'have' },
70
- "would've": { host: 'would', clitic: 'have' },
71
- "should've": { host: 'should', clitic: 'have' },
72
- "could've": { host: 'could', clitic: 'have' },
73
- "must've": { host: 'must', clitic: 'have' },
74
- };
75
- /** Archaic poetic "-'d" preterite: any -'d form that is not a genuine pronoun/wh
76
- * contraction (those live in CONTRACTION_MAP and are checked first). */
77
- const ARCHAIC_D_RE = /^[a-z]{2,}'d$/;
78
- /**
79
- * Tokenise the raw (un‑normalised) text into word‑like segments,
80
- * marking which are contracted forms.
81
- */
82
- function tokenizeRawText(text) {
83
- const re = /\b[a-zA-Z]+(?:'[a-zA-Z]+)?\b/g;
84
- const segments = [];
85
- let match;
86
- while ((match = re.exec(text)) !== null) {
87
- const lower = match[0].toLowerCase();
88
- const isContraction = lower in CONTRACTION_MAP;
89
- segments.push({
90
- text: lower,
91
- isContraction,
92
- isArchaicD: !isContraction && ARCHAIC_D_RE.test(lower),
93
- });
94
- }
95
- return segments;
96
- }
97
- /**
98
- * Re‑merge contraction pairs in a sentence's ClsWord array.
99
- *
100
- * Segments from the raw text are walked in parallel with the sentence's
101
- * tokens. Non‑contraction segments consume 1 token; contraction segments
102
- * consume 2 tokens (host + clitic), which are merged into a single ClsWord
103
- * that preserves the host's properties and the original contracted form.
104
- *
105
- * Returns the updated word array.
106
- */
107
- function mergeContractionsInSentence(words, segments, startSegmentIdx) {
108
- const merged = [];
109
- let tokenIdx = 0;
110
- let segIdx = startSegmentIdx;
111
- while (tokenIdx < words.length && segIdx < segments.length) {
112
- // Punctuation tokens have NO raw-text segment (tokenizeRawText matches only
113
- // letter sequences), so they must not consume a segment. Otherwise a
114
- // sentence ending in "!"/"." over-advances segIdx and misaligns every later
115
- // sentence — dropping a pronoun that precedes a contraction ("No more! He
116
- // won't…" lost "He" and mis-tagged the contraction PRP).
117
- if (isPunctuation(words[tokenIdx].lexicalClass)) {
118
- merged.push(words[tokenIdx]);
119
- tokenIdx++;
120
- continue;
121
- }
122
- const seg = segments[segIdx];
123
- // Archaic poetic preterite ("fix'd", "lov'd"): en-norm expands the -'d into a
124
- // separate "would"/"had" token, splitting one syllable into two words AND
125
- // shifting every later token off its raw segment. Re-merge host + modal back
126
- // into the apostrophized form — but ONLY when the spurious modal is actually
127
- // there (conditional, so a hand-typed "fix'd" that survived intact is safe).
128
- if (seg.isArchaicD) {
129
- const next = tokenIdx + 1 < words.length ? words[tokenIdx + 1].word.toLowerCase() : '';
130
- if (next === 'would' || next === 'had') {
131
- merged.push({ ...words[tokenIdx], word: seg.text });
132
- tokenIdx += 2;
133
- segIdx++;
134
- continue;
135
- }
136
- merged.push(words[tokenIdx]);
137
- tokenIdx++;
138
- segIdx++;
139
- continue;
140
- }
141
- if (seg.isContraction) {
142
- if (tokenIdx + 1 >= words.length) {
143
- merged.push(words[tokenIdx]);
144
- tokenIdx++;
145
- segIdx++;
146
- continue;
147
- }
148
- const hostWord = words[tokenIdx];
149
- const cliticWord = words[tokenIdx + 1];
150
- // Keep the host as the merged word, update its text to the contracted form.
151
- const mergedWord = {
152
- ...hostWord,
153
- word: seg.text,
154
- };
155
- merged.push(mergedWord);
156
- tokenIdx += 2;
157
- segIdx++;
158
- }
159
- else {
160
- merged.push(words[tokenIdx]);
161
- tokenIdx++;
162
- segIdx++;
163
- }
164
- }
165
- // Append any remaining words that exceeded segment count.
166
- while (tokenIdx < words.length) {
167
- merged.push(words[tokenIdx]);
168
- tokenIdx++;
169
- }
170
- return { words: merged, consumedSegments: segIdx - startSegmentIdx };
171
- }
172
- function mergeHyphenatedWords(words) {
173
- const merged = [];
174
- let i = 0;
175
- while (i < words.length) {
176
- if (i + 2 < words.length &&
177
- words[i + 1].word === '-' &&
178
- !isPunctuation(words[i].lexicalClass) &&
179
- !isPunctuation(words[i + 2].lexicalClass)) {
180
- const combined = words[i].word + '-' + words[i + 2].word;
181
- const mergedWord = {
182
- ...words[i],
183
- word: combined,
184
- lexicalClass: words[i + 2].lexicalClass.startsWith('N') ? words[i + 2].lexicalClass : words[i].lexicalClass,
185
- isContent: words[i].isContent || words[i + 2].isContent,
186
- };
187
- merged.push(mergedWord);
188
- i += 3;
189
- }
190
- else {
191
- merged.push(words[i]);
192
- i++;
193
- }
194
- }
195
- return merged;
196
- }
197
- // ─── Mappings: FinNLP → Antelope‑compatible labels ──────────────────
198
- // FinNLP (aka Stanford Dependency Types) versus Antelope NLP dependency (aka Universal Dependencies) type equivalencies.
199
- const FIN_TO_ANTELOPE_LABEL = {
200
- AUX: 'aux',
201
- AUXPASS: 'auxpass',
202
- NSUBJ: 'nsubj',
203
- NSUBJPASS: 'nsubjpass',
204
- DOBJ: 'dobj',
205
- IOBJ: 'iobj',
206
- OBL: 'pobj', // OBL (oblique) ≈ pobj in Antelope
207
- DET: 'det',
208
- CASE: 'prep', // CASE marks the preposition; matched to UD prep
209
- CC: 'cc',
210
- COMPMARK: 'mark',
211
- ADVMARK: 'mark',
212
- NOMD: 'poss', // nominal modifier ≈ possessive relation
213
- AMOD: 'amod',
214
- ADVMOD: 'advmod',
215
- ADVCL: 'advcl',
216
- XCOMP: 'xcomp',
217
- CCOMP: 'ccomp',
218
- ACL: 'acl',
219
- VPRT: 'prt',
220
- NUMDMOD: 'nummod',
221
- EXPL: 'expl',
222
- DISCOURSE: 'discourse',
223
- PUNCT: 'punct',
224
- INTERJ: 'intj',
225
- EXT: 'dep', // extension – best mapped to generic 'dep'
226
- DEP: 'dep',
227
- ROOT: 'root',
228
- };
229
- function toAntelopeLabel(finLabel) {
230
- return FIN_TO_ANTELOPE_LABEL[finLabel] ?? finLabel.toLowerCase();
1
+ // parser.ts — Syntactic dependency parser powered by UDPipe (English GUM model),
2
+ // producing a ClsDocument with a full dependency graph and a phrase‑structure node
3
+ // tree in the Universal Dependencies format used in McAleese's Calliope.
4
+ //
5
+ // HISTORY: this module previously ran a staged FinNLP pipeline
6
+ // (en-norm lexed en-pos → en-parse) with hand-written tag/dep correction
7
+ // layers (tagfix.ts / depfix.ts) to patch en-parse's systematic errors. It now
8
+ // delegates tokenisation, POS tagging, and dependency parsing to UDPipe via the
9
+ // `udpipe-node` package (a pure-WASM build — no native binary, no subprocess),
10
+ // which is a far more accurate parser. UDPipe's output maps cleanly onto the
11
+ // existing data model:
12
+ // • XPOS column is Penn Treebank → ClsWord.lexicalClass (unchanged downstream)
13
+ // • DEPREL column is Universal Dependencies → ClsDependency.dependentType
14
+ // The correction layers are therefore no longer applied on this path.
15
+ import { createUDPipe } from 'udpipe-node/wasm';
16
+ import { correctUDPipePos } from './calliope/postag.js';
17
+ import { pennTagOf } from './calliope/udpos.js';
18
+ // ── UDPipe instance (lazy singleton) ────────────────────────────────
19
+ // The "./wasm" entry point pre-initialises the WASM runtime via top-level await,
20
+ // so by the time this module is imported the engine is ready and construction /
21
+ // parsing are fully synchronous `parseDocument` keeps its synchronous contract.
22
+ let _nlp = null;
23
+ function nlp() {
24
+ // CALLIOPE_UDPIPE_MODEL lets us swap the UDPipe model (EWT / GUM / LinES /
25
+ // ParTUT) for auditing different treebanks tag XPOS quite differently, so the
26
+ // model choice materially affects the parse the phonological pipeline consumes.
27
+ // Unset the bundled GUM model.
28
+ const modelPath = process.env.CALLIOPE_UDPIPE_MODEL || undefined;
29
+ return (_nlp ??= createUDPipe({ defaultInputMode: 'presegmented', modelPath }));
231
30
  }
31
+ // ── POS / punctuation classification (unchanged) ─────────────────────
232
32
  const CONTENT_POS = new Set([
233
33
  'NN', 'NNS', 'NNP', 'NNPS',
234
34
  'JJ', 'JJR', 'JJS',
@@ -241,266 +41,253 @@ const PUNCT_TAGS = new Set([
241
41
  ',', '.', ':', ';', '!', '?',
242
42
  '-LRB-', '-RRB-', '``', "''",
243
43
  '--', '...', '"', "'",
244
- '(', ')', '[', ']', '{', '}', // FinNLP emits literal bracket tags, not -LRB-/-RRB-
44
+ '(', ')', '[', ']', '{', '}',
245
45
  ]);
246
46
  export function isPunctuation(tag) {
247
47
  return PUNCT_TAGS.has(tag);
248
48
  }
249
49
  /**
250
- * Quotation-mark tags. Quotes are tokens (never syllabified) but NOT prosodic
251
- * breaks: a quoted word inside a clause ('call them "wisdom" teeth') is read in
252
- * one breath — no intonational boundary, no caesura. Treating quotes as IU
253
- * boundaries fragmented such lines into 3-4 IUs and flipped their meter.
50
+ * Quotation-mark tags. Quotes are tokens (never syllabified) but NOT prosodic
51
+ * breaks: a quoted word inside a clause is read in one breath no intonational
52
+ * boundary, no caesura.
254
53
  */
255
54
  const QUOTE_TAGS = new Set(['``', "''", '"', "'"]);
256
55
  export function isQuoteTag(tag) {
257
56
  return QUOTE_TAGS.has(tag);
258
57
  }
58
+ function isContentWord(tag) {
59
+ return CONTENT_POS.has(tag);
60
+ }
259
61
  /**
260
- * Typographic dashes that FinNLP mis-tags as content words. A standalone en-dash
261
- * "–", em-dash "—", horizontal bar "―", minus sign "−" or a run of 2+ hyphens is a
262
- * prosodic break (a dash caesura), NOT a stress-bearing token — but FinNLP's POS
263
- * model labels the bare glyph `NNP` (proper noun), so it flowed through the
264
- * pipeline, received a syllable, and even attracted a strong metrical beat
265
- * ("crunch – a guilt" scanned the dash as 's'). We re-tag any such glyph to the
266
- * Penn dash/colon class ':' (already an IU/caesura boundary) at parse time, so the
267
- * dash drops out of syllabification & scansion and instead marks a pause.
268
- * A *single* hyphen-minus is deliberately excluded — it joins hyphenated compounds
269
- * ("torch-flames") handled by mergeHyphenatedWords.
62
+ * Lowercase the first alphabetic character of every line. Kept available but
63
+ * NOT called by default empirically net-negative with UDPipe (see parseDocument).
64
+ */
65
+ export function lowerLineInitials(text) {
66
+ return text
67
+ .split('\n')
68
+ .map((line) => line.replace(/[A-Za-z]/, (c) => c.toLowerCase()))
69
+ .join('\n');
70
+ }
71
+ // Archaic / Early-Modern English forms the UD model (trained on modern text)
72
+ // systematically mis-tags. This is a closed lexicon of forms that are virtually
73
+ // never modern words, so an unconditional retag is safe domain adaptation for a
74
+ // verse tool (it replaces the role the old en-pos correction layer played for
75
+ // these tokens). Surface forms are matched lowercased, sans apostrophes.
76
+ const ARCHAIC_POS = {
77
+ thy: 'PRP$', thine: 'PRP$',
78
+ thee: 'PRP', thou: 'PRP', ye: 'PRP',
79
+ hath: 'VBZ', doth: 'VBZ', saith: 'VBZ',
80
+ hast: 'VBP', dost: 'VBP', wilt: 'MD',
81
+ shalt: 'MD', canst: 'MD', wouldst: 'MD', shouldst: 'MD', couldst: 'MD',
82
+ hadst: 'VBD', didst: 'VBD', wast: 'VBD', wert: 'VBD',
83
+ };
84
+ // ── Dash handling (unchanged) ────────────────────────────────────────
85
+ /**
86
+ * Standalone en/em/figure/bar/minus dashes (or a run of 2+ hyphens) are prosodic
87
+ * breaks (a dash caesura), not stress-bearing tokens. Re-tagged to the Penn dash
88
+ * class ':' so they drop out of syllabification & scansion and mark a pause.
270
89
  */
271
90
  const DASH_GLYPH_RE = /^(?:[‒–—―−]+|-{2,})$/;
272
91
  function isDashGlyph(word) {
273
92
  return DASH_GLYPH_RE.test(word);
274
93
  }
275
- const DASH_CLASS = '‒–—―−'; // figure / en / em / bar / minus
94
+ const DASH_CLASS = '‒–—―−';
276
95
  const DASH_GLYPHS_RE = new RegExp(`[${DASH_CLASS}]`, 'g');
277
96
  const DASH_PAREN_RE = new RegExp(`([${DASH_CLASS}])([^${DASH_CLASS}]*?[.!?][^${DASH_CLASS}]*?)([${DASH_CLASS}])`, 'g');
278
97
  /**
279
- * Normalize dash *usages* to comma clause-breaks BEFORE parsing. In verse a dash
280
- * is a comma-like prosodic break not a word, not a sentence end — but FinNLP
281
- * mis-handles it two ways: it glues a SPACE-flanked hyphen-minus into the
282
- * neighbouring word ("I still carry - Oh" token "carry-Oh", then OOV), and it
283
- * tags a bare en/em-dash as a proper noun (NNP) that pollutes the dependency tree.
284
- * Worse, a parenthetical aside set off by dashes often contains sentence-final
285
- * punctuation ("– Oh, Petersburg! –") that splits the line into separate
286
- * sentences and severs the main clause's dependencies (here, carry↔address).
287
- *
288
- * So we (1) fold every dash usage — em/en/figure/bar/minus, a 2+ hyphen run, or a
289
- * space-flanked single hyphen — into a canonical dash glyph (leaving unspaced
290
- * hyphen compounds like "torch-flames" intact for `mergeHyphenatedWords`);
291
- * (2) neutralize sentence-final punctuation INSIDE a dash-delimited parenthetical
292
- * so the line stays one sentence; (3) rewrite the dashes to commas, which FinNLP
293
- * parses cleanly and which are the same prosodic break (a comma is an IU boundary
294
- * → caesura). The verbatim original (dashes and all) is preserved by the caller
295
- * for the reading-view projection; only the parser's working copy is normalized.
98
+ * Normalize dash *usages* to colon-class clause-breaks BEFORE parsing. A dash is
99
+ * an ι (intonational-unit) boundary — a stronger pause than a comma. We fold
100
+ * every dash usage into a canonical glyph, neutralise sentence-final punctuation
101
+ * inside a dash-delimited parenthetical (so the line stays one sentence), then
102
+ * rewrite the dashes to a colon-class break (which prosodic.ts reads as an ι
103
+ * boundary). Unspaced hyphen compounds ("torch-flames") are left intact.
296
104
  */
297
105
  function normalizeDashesToClauseBreaks(text) {
298
- // (1) space-flanked single/multi hyphen-minus, and any 2+ hyphen run → en-dash
299
106
  text = text.replace(/(^|\s)-+(?=\s|$)/g, '$1–');
300
107
  text = text.replace(/-{2,}/g, '–');
301
- // (2) neutralize sentence-final punctuation between paired dashes (keeps it one sentence)
302
108
  text = text.replace(DASH_PAREN_RE, (_m, a, inner, b) => a + inner.replace(/[.!?]+/g, ',') + b);
303
- // (3) dash glyphs → comma clause-break
304
- text = text.replace(DASH_GLYPHS_RE, ',');
305
- // tidy: collapse comma runs, no space before a comma, one space after, no leading comma
306
- text = text.replace(/(?:\s*,\s*){2,}/g, ', ')
307
- .replace(/\s+,/g, ',')
308
- .replace(/,(\S)/g, ', $1')
309
- .replace(/^\s*,\s*/, '');
109
+ text = text.replace(DASH_GLYPHS_RE, ' : ');
110
+ text = text.replace(/(?:\s*:\s*){2,}/g, ' : ')
111
+ .replace(/\s+:/g, ' :')
112
+ .replace(/:(\S)/g, ': $1')
113
+ .replace(/^\s*:\s*/, '')
114
+ .replace(/\s{2,}/g, ' ')
115
+ .trim();
310
116
  return text;
311
117
  }
312
- function isContentWord(tag) {
313
- return CONTENT_POS.has(tag);
118
+ // ── Clitic / contraction re‑merge (UDPipe-specific) ──────────────────
119
+ // UDPipe tokenises contractions and elisions on the apostrophe boundary, e.g.
120
+ // it's → it + 's don't → do + n't we'll → we + 'll
121
+ // th'expense → th' + expense 'Tis → ' + Tis fix'd → fix + 'd
122
+ // For scansion a contraction must be ONE orthographic word (one syllable count,
123
+ // one stress domain). We re-merge using UDPipe's SpaceAfter flag (which marks
124
+ // tokens that were contiguous in the source) plus the apostrophe shape:
125
+ // • a LEFT clitic (apostrophe-initial, or n't) merges into the previous word,
126
+ // EXCEPT the possessive 's (XPOS=POS), which stays split (as it always has);
127
+ // • a RIGHT proclitic (a short apostrophe-final piece like "th'", or a bare
128
+ // leading apostrophe before an aphaeresis like 'tis/'twas) merges into the
129
+ // next word.
130
+ const APOS = /['’]/;
131
+ const LEFT_CLITIC_RE = /^['’]([a-z]+)?$|^n['’]?t$/i; // 's 've 'll 'd 're 'm n't
132
+ const RIGHT_PROCLITIC_RE = /^[a-z]{1,3}['’]$/i; // th' o' d' ne'
133
+ const APHAERESIS = new Set(['tis', 'twas', 'twere', 'twill', 'twould', 'gainst', 'neath', 'tween', 'twixt', 'til', 'cause', 'em', 'round', 'bout']);
134
+ /** Group UDPipe words into orthographic clusters, re-merging clitics. */
135
+ function clusterWords(uds) {
136
+ const idToCluster = new Map();
137
+ const clusters = [];
138
+ for (let i = 0; i < uds.length; i++) {
139
+ const w = uds[i];
140
+ const prev = uds[i - 1];
141
+ const contiguous = prev ? prev.spaceAfter === false : false;
142
+ const isLeftClitic = contiguous && w.xpos !== 'POS' && LEFT_CLITIC_RE.test(w.form);
143
+ if (isLeftClitic && clusters.length > 0) {
144
+ clusters[clusters.length - 1].tokens.push(w);
145
+ idToCluster.set(w.id, clusters.length - 1);
146
+ continue;
147
+ }
148
+ clusters.push({ tokens: [w], repr: w });
149
+ idToCluster.set(w.id, clusters.length - 1);
150
+ }
151
+ // Right-merge pass: a cluster that is a lone proclitic (th') or a bare leading
152
+ // apostrophe before an aphaeresis ('tis) folds into the following cluster.
153
+ const merged = [];
154
+ for (let c = 0; c < clusters.length; c++) {
155
+ const cl = clusters[c];
156
+ const next = clusters[c + 1];
157
+ const onlyTok = cl.tokens.length === 1 ? cl.tokens[0] : null;
158
+ const contiguous = onlyTok ? onlyTok.spaceAfter === false : false;
159
+ const nextWord = next?.repr;
160
+ const isProclitic = !!onlyTok && contiguous && !!nextWord &&
161
+ (RIGHT_PROCLITIC_RE.test(onlyTok.form) ||
162
+ (/^['’]$/.test(onlyTok.form) && APHAERESIS.has(nextWord.form.toLowerCase())));
163
+ if (isProclitic && next) {
164
+ next.tokens.unshift(onlyTok); // prepend proclitic
165
+ for (const t of cl.tokens)
166
+ idToCluster.set(t.id, merged.length); // re-point to next cluster's eventual index
167
+ // The next cluster will be pushed next iteration; fix its index mapping then.
168
+ // Mark by leaving cl out (skip pushing it).
169
+ // Re-point all of next's tokens to current merged length too:
170
+ continue;
171
+ }
172
+ merged.push(cl);
173
+ }
174
+ // Rebuild idToCluster cleanly against the merged list (indices shifted by right-merges).
175
+ idToCluster.clear();
176
+ for (let c = 0; c < merged.length; c++) {
177
+ for (const t of merged[c].tokens)
178
+ idToCluster.set(t.id, c);
179
+ // representative = first token that is neither a left-clitic nor a proclitic
180
+ merged[c].repr =
181
+ merged[c].tokens.find((t) => !(t.xpos !== 'POS' && LEFT_CLITIC_RE.test(t.form)) && !RIGHT_PROCLITIC_RE.test(t.form) && !/^['’]$/.test(t.form)) ?? merged[c].tokens[0];
182
+ }
183
+ return { clusters: merged, idToCluster };
314
184
  }
315
- // ─── Public API ───────────────────────────────────────────────────
316
- /**
317
- * Parse a multi‑sentence text string and return a ClsDocument whose
318
- * internal structure mirrors the Antelope NLP (aka Universal Dependency type)
319
- * output from McAleese’s original Calliope implementation.
320
- */
185
+ // Dependency labels are passed through to `ClsDependency.dependentType` as RAW
186
+ // Universal Dependencies relations (obl, nsubj:pass, compound, nmod:poss, …) —
187
+ // they are deliberately NOT folded into the old Stanford names. The canonical
188
+ // normaliser `calliope/deps.ts` maps every UD relation onto the engine's Scenario
189
+ // label space (canonicalRel), so new UD tags are accommodated there, not hidden
190
+ // here.
191
+ // ── Public API ───────────────────────────────────────────────────────
321
192
  export function parseDocument(text) {
322
- // Collapse runs of sentence-final punctuation (ellipsis "...", "!!", "??")
323
- // to a single mark BEFORE tokenisation. FinNLP otherwise glues the surplus
324
- // marks onto the preceding word ("springtime..." → token "springtime.." → OOV,
325
- // mis-tagged JJ, mis-syllabified, and re-phrased), which made two lines that
326
- // differ only in trailing punctuation scan differently. This is metrically
327
- // harmless (punctuation bears no syllable); the verbatim original is preserved
328
- // by the caller and used for the reading projection.
193
+ // Normalise curly/typographic apostrophes to straight ' so contractions and
194
+ // elisions tokenise identically regardless of glyph.
195
+ text = text.replace(/[‘’ʼ′]/g, "'");
196
+ // Collapse runs of sentence-final punctuation (ellipsis, "!!") to a single mark.
329
197
  text = text.replace(/([.!?])\1+/g, '$1');
330
- // Dashes → comma clause-breaks (fixes "carry-Oh" gluing AND the parenthetical
331
- // sentence-split that severs main-clause dependencies). See the helper above.
198
+ // Dashes → colon-class clause-breaks (see helper above).
332
199
  text = normalizeDashesToClauseBreaks(text);
333
- // Pre‑scan the raw text for contraction positions before FinNLP
334
- // normalises them away.
335
- const rawSegments = tokenizeRawText(text);
336
- // Run the FinNLP pipeline STAGED rather than via `Fin.Run`, so the tag-
337
- // correction layer (tagfix.ts) sits between en-pos and en-parse: corrected
338
- // tags repair the tagging AND the dependency tree built from it. The
339
- // stages below mirror finnlp's own Run() exactly (en-norm lexed →
340
- // en-pos → en-parse); lemmas are skipped (unused downstream).
341
- const intercepted = EnNorm.resolveContractions(EnNorm.replaceConfusables(text));
342
- const lexer = new Lexed.Lexed(intercepted).lexer();
343
- const runner = { raw: text, intercepted, sentences: [] };
344
- for (let li = 0; li < lexer.sentences.length; li++) {
345
- const tokens = EnNorm.normalizeCaps(lexer.tokens[li]);
346
- const tagging = new EnPos.Tag(tokens).initial().smooth();
347
- const tags = correctTags(tokens, tagging.tags);
348
- const depsTree = EnParse.tree(tags, tokens)[0];
349
- runner.sentences.push({
350
- sentence: lexer.sentences[li],
351
- tokens, tags, lemmas: [],
352
- depsTree,
353
- // Post-parse dependency repair (depfix.ts): systematic en-parse
354
- // attachment errors (noun-compound double-objects, dangling DT).
355
- deps: applyDepFixes(tokens, tags, EnParse.toArray(depsTree)),
356
- confidence: 0,
357
- });
358
- }
200
+ // NOTE on line-initial caps: lowering the first letter of each line before
201
+ // tagging (the role the old `normalizeCaps` played) was tested and is NET
202
+ // NEGATIVE with UDPipe — it recovers cases like "Nap"/"Gap" (UH→NN) but a
203
+ // line-initial capital often HELPS UDPipe's parse (e.g. "Through Eden took…"
204
+ // parses "Eden" as nsubj when capitalised, obl when lowercased), so it changes
205
+ // more scansions than it fixes. Left disabled; see lowerLineInitials() below.
206
+ const udSentences = nlp().parse(text, { inputMode: 'presegmented' });
359
207
  const sentences = [];
360
208
  let absoluteOffset = 0;
361
- let segmentIdx = 0;
362
- for (let si = 0; si < runner.sentences.length; si++) {
363
- const s = runner.sentences[si];
364
- const rawTokens = s.tokens;
365
- const rawTags = s.tags;
366
- const rawDeps = s.deps;
209
+ udSentences.forEach((ud, si) => {
210
+ const { clusters, idToCluster } = clusterWords(ud.words);
367
211
  // ---- 1. Build ClsWord array ----
368
- const wordsPre = rawTokens.map((word, i) => {
369
- // Re-tag a mis-tagged dash glyph (FinNLP labels "–"/"—" as NNP) to the Penn
370
- // dash class ':' so it acts as a caesura/IU boundary, not a stressable word.
371
- const tag = isDashGlyph(word) ? ':' : rawTags[i];
372
- return ({
373
- index: i + 1, // 1‑based, matching Antelope
212
+ const words = clusters.map((cl, i) => {
213
+ // Preserve the ORIGINAL case of the surface form (UDPipe keeps it); only
214
+ // lowercase a private lookup key for the archaic-lexicon / dash checks.
215
+ // Lowercasing `word` itself lost every proper-noun capital ("pakistan",
216
+ // "marcel proust") in the display and projection; downstream stress/name
217
+ // lookups all lowercase internally, so case in `word` is display-only.
218
+ const surfaceRaw = cl.tokens.map((t) => t.form).join('');
219
+ const surface = surfaceRaw.toLowerCase();
220
+ // Penn tag: use the raw XPOS when it already is Penn (EWT/GUM), else derive
221
+ // it from UPOS+FEATS (LinES/ParTUT emit non-Penn XPOS the pipeline can't read).
222
+ const rawTag = pennTagOf(cl.repr);
223
+ const archaic = ARCHAIC_POS[surface.replace(/['’]/g, '')];
224
+ const tag = isDashGlyph(surfaceRaw) ? ':' : (archaic ?? rawTag);
225
+ return {
226
+ index: i + 1,
374
227
  lexicalClass: tag,
375
- lexicalDetails: '',
228
+ lexicalDetails: cl.repr.feats,
376
229
  lexicalPlural: tag === 'NNS' || tag === 'NNPS',
377
230
  position: '',
378
- word,
231
+ word: surfaceRaw,
379
232
  absoluteIndex: absoluteOffset + i,
380
233
  isContent: isContentWord(tag),
381
- syllables: [], // filled later by stress module
234
+ syllables: [],
382
235
  phraseStress: 0,
383
- dependency: undefined, // patched below
384
- node: undefined, // patched below
385
- });
386
- });
387
- // ---- 1a. Re‑merge contraction pairs ----
388
- const { words: contractedWords, consumedSegments } = mergeContractionsInSentence(wordsPre, rawSegments, segmentIdx);
389
- segmentIdx += consumedSegments;
390
- // ---- 1b. Re‑merge hyphenated words ----
391
- const words = mergeHyphenatedWords(contractedWords);
392
- // Re‑index words after merging (1‑based).
393
- words.forEach((w, i) => {
394
- w.index = i + 1;
236
+ dependency: undefined,
237
+ node: undefined,
238
+ };
395
239
  });
396
- // ---- 2. Build ClsDependency array ----
397
- const dependencies = [];
398
- // Build contraction merge map: wordsPre idxcontractedWords idx.
399
- // MUST replay mergeContractionsInSentence exactly: punctuation tokens have no
400
- // raw segment (consume none), and an archaic -'d merges only when the
401
- // spurious would/had token follows.
402
- const contractionMap = new Map();
403
- let pi = 0;
404
- let qi = 0;
405
- let segOff = segmentIdx - consumedSegments;
406
- while (pi < wordsPre.length && segOff < segmentIdx) {
407
- if (isPunctuation(wordsPre[pi].lexicalClass)) {
408
- contractionMap.set(pi, qi);
409
- pi++;
410
- qi++;
240
+ // Sentence-initial de-capitalisation (mirrors en-norm.normalizeCaps in the
241
+ // pre-UD path): lower the first letter of the sentence's first word UNLESS it
242
+ // is a proper noun, so "The"→"the" and "I""i" (an orthographic capital forced
243
+ // by line position carries no lexical signal) while mid-line proper nouns
244
+ // ("Marcel Proust", "Pakistan") keep their caps for display/projection.
245
+ for (const w of words) {
246
+ if (isPunctuation(w.lexicalClass))
411
247
  continue;
248
+ if (!/^(NNP|NNPS)$/.test(w.lexicalClass) && /^[A-Z]/.test(w.word)) {
249
+ w.displayWord = w.word; // keep the original surface for reports/phonopoetics
250
+ w.word = w.word[0].toLowerCase() + w.word.slice(1);
412
251
  }
413
- const seg = rawSegments[segOff];
414
- const archaicMerge = seg.isArchaicD
415
- && pi + 1 < wordsPre.length
416
- && ['would', 'had'].includes(wordsPre[pi + 1].word.toLowerCase());
417
- if ((seg.isContraction && pi + 1 < wordsPre.length) || archaicMerge) {
418
- contractionMap.set(pi, qi);
419
- contractionMap.set(pi + 1, qi);
420
- pi += 2;
421
- qi += 1;
422
- segOff++;
423
- }
424
- else {
425
- contractionMap.set(pi, qi);
426
- pi++;
427
- qi++;
428
- segOff++;
429
- }
430
- }
431
- while (pi < wordsPre.length) {
432
- contractionMap.set(pi, qi);
433
- pi++;
434
- qi++;
252
+ break;
435
253
  }
436
- // Build hyphen merge map: contractedWords idx → words idx
437
- const hyphenMap = new Map();
438
- let ci = 0;
439
- let wi = 0;
440
- while (ci < contractedWords.length) {
441
- if (ci + 2 < contractedWords.length &&
442
- contractedWords[ci + 1].word === '-' &&
443
- !isPunctuation(contractedWords[ci].lexicalClass) &&
444
- !isPunctuation(contractedWords[ci + 2].lexicalClass)) {
445
- hyphenMap.set(ci, wi);
446
- hyphenMap.set(ci + 1, wi);
447
- hyphenMap.set(ci + 2, wi);
448
- ci += 3;
449
- wi++;
254
+ // ---- 2. Build ClsDependency array ----
255
+ const dependencies = [];
256
+ clusters.forEach((cl, depIdx) => {
257
+ const r = cl.repr;
258
+ let govIdx;
259
+ if (r.head === 0) {
260
+ govIdx = undefined; // attaches to root
450
261
  }
451
262
  else {
452
- hyphenMap.set(ci, wi);
453
- ci++;
454
- wi++;
263
+ const g = idToCluster.get(r.head);
264
+ // If the representative's head fell inside its own cluster (e.g. a copula
265
+ // clitic), follow that clitic's head out of the cluster.
266
+ if (g === depIdx) {
267
+ const external = cl.tokens
268
+ .map((t) => idToCluster.get(t.head))
269
+ .find((gi) => gi !== undefined && gi !== depIdx);
270
+ govIdx = external;
271
+ }
272
+ else {
273
+ govIdx = g;
274
+ }
455
275
  }
456
- }
457
- // Compose: wordsPre idx words idx
458
- const mergeMap2 = new Map();
459
- for (const [preIdx, cIdx] of contractionMap) {
460
- const wIdx = hyphenMap.get(cIdx);
461
- if (wIdx !== undefined)
462
- mergeMap2.set(preIdx, wIdx);
463
- }
464
- // Build dependencies from rawDeps, remapping governor and dependent indices.
465
- for (let i = 0; i < rawDeps.length; i++) {
466
- const dep = rawDeps[i];
467
- const govPreIdx = dep.parent; // 0‑based, -1 for root
468
- const depPreIdx = i;
469
- const govPostIdx = govPreIdx >= 0 ? mergeMap2.get(govPreIdx) : undefined;
470
- const depPostIdx = mergeMap2.get(depPreIdx);
471
- if (depPostIdx === undefined)
472
- continue;
473
- if (govPreIdx >= 0 && govPostIdx === depPostIdx)
474
- continue; // self-loop from merged pair
475
- // If this dependent is the clitic half of a contraction, skip
476
- // (its dependency info is already captured through the host mapping).
477
- // Check if this is the second token of a contraction:
478
- const isCliticHalf = i > 0 &&
479
- mergeMap2.get(i) === mergeMap2.get(i - 1);
480
- if (isCliticHalf)
481
- continue;
482
- const govWord = govPostIdx !== undefined && govPostIdx >= 0 ? words[govPostIdx] : undefined;
483
- const depWord = words[depPostIdx];
484
- // If governor was a clitic half that got merged into host, re-point to host
485
- const actualGovWord = govWord || (govPreIdx >= 0 && govPreIdx < wordsPre.length
486
- ? words[mergeMap2.get(govPreIdx)]
487
- : undefined);
276
+ const depWord = words[depIdx];
277
+ const govWord = govIdx !== undefined ? words[govIdx] : null;
488
278
  dependencies.push({
489
- index: depPostIdx + 1,
490
- governorIndex: govPostIdx !== undefined ? govPostIdx + 1 : 0,
491
- dependentIndex: depPostIdx + 1,
492
- dependentType: toAntelopeLabel(dep.label),
493
- governorName: (govPostIdx !== undefined && govPostIdx >= 0 && words[govPostIdx])
494
- ? words[govPostIdx].word : 'ROOT',
279
+ index: depIdx + 1,
280
+ governorIndex: govIdx !== undefined ? govIdx + 1 : 0,
281
+ dependentIndex: depIdx + 1,
282
+ dependentType: govIdx === undefined ? 'root' : r.deprel,
283
+ governorName: govWord ? govWord.word : 'ROOT',
495
284
  dependentName: depWord.word,
496
- governor: (govPostIdx !== undefined && govPostIdx >= 0 && words[govPostIdx])
497
- ? words[govPostIdx] : null,
285
+ governor: govWord,
498
286
  dependent: depWord,
499
287
  });
500
- }
501
- // Ensure ROOT dependency exists.
502
- const hasRoot = dependencies.some(d => d.governorIndex === 0);
503
- if (!hasRoot && words.length > 0) {
288
+ });
289
+ // Ensure a ROOT dependency exists.
290
+ if (!dependencies.some((d) => d.governorIndex === 0) && words.length > 0) {
504
291
  dependencies.push({
505
292
  index: 0,
506
293
  governorIndex: 0,
@@ -512,16 +299,21 @@ export function parseDocument(text) {
512
299
  dependent: words[0],
513
300
  });
514
301
  }
515
- // Back‑reference: each word stores the dependency edge where it is the dependent.
516
- words.forEach(w => {
517
- w.dependency = dependencies.find(d => d.dependent === w);
302
+ // Back‑reference: each word stores the dependency edge where it is dependent.
303
+ words.forEach((w) => {
304
+ w.dependency = dependencies.find((d) => d.dependent === w);
518
305
  });
519
- // ---- 3. Build phrase‑structure node tree from FinNLP's depsTree ----
520
- const rootNode = buildNodeTree(s.depsTree, words);
521
- // Attach each word’s corresponding leaf node (if any).
306
+ // UDPipe XPOS correction (the role en-pos + tagfix.ts played pre-UD): fix the
307
+ // systematic mis-tags UDPipe makes on terse, decontextualised verse via
308
+ // en-lexicon cross-check. Runs HERE (in the parser, after deps are attached)
309
+ // so direct `parseDocument` consumers — and every engine — see corrected tags;
310
+ // rule (3) needs the dependency back-references just set above.
311
+ correctUDPipePos({ index: si + 1, nodes: null, dependencies, words, xml: '' });
312
+ // ---- 3. Build phrase‑structure node tree from the dependency graph ----
313
+ const rootNode = buildDepNodeTree(words, dependencies);
522
314
  const wordNodeMap = new Map();
523
315
  collectWordNodes(rootNode, wordNodeMap);
524
- words.forEach(w => {
316
+ words.forEach((w) => {
525
317
  w.node = wordNodeMap.get(w.index);
526
318
  });
527
319
  sentences.push({
@@ -532,145 +324,111 @@ export function parseDocument(text) {
532
324
  xml: '',
533
325
  });
534
326
  absoluteOffset += words.length;
535
- }
327
+ });
536
328
  return { sentences, xml: '' };
537
329
  }
538
- // ─── Node‑tree construction ──────────────────────────────────────
539
- /** Sentinel used for empty / unparsable trees. */
540
- const EMPTY_NODE = {
541
- index: '0',
542
- nodeName: 'EMPTY',
543
- parent: null,
544
- contains: [],
545
- };
546
- /**
547
- * Recursively convert a FinNLP NodeInterface tree into a ClsNode tree
548
- * that mirrors Antelope’s phrase‑structure output.
549
- *
550
- * The root of the FinNLP tree is always wrapped in an SQ node.
551
- */
552
- function buildNodeTree(finRoot, words) {
553
- // Guard: missing or empty tree
554
- if (!finRoot || !finRoot.tokens || finRoot.tokens.length === 0) {
555
- // Create a minimal SQ node containing all words as direct leaves.
556
- const sq = {
557
- index: '1',
558
- nodeName: 'SQ',
559
- parent: null,
560
- contains: words.map(w => createWordLeaf(w)),
561
- };
330
+ // ── Dependency constituency projection ─────────────────────────────
331
+ // phonological.ts groups clitic groups into phonological phrases by finding the
332
+ // smallest phrase node containing them, so it needs a properly nested, position-
333
+ // ordered constituency tree. We synthesise one by projection: each head plus its
334
+ // dependent subtrees forms a phrase, labelled by the head's POS family.
335
+ function phraseType(tag) {
336
+ if (/^(NN|NNS|NNP|NNPS|PRP|PRP\$|DT|CD|WP|WDT|EX)$/.test(tag))
337
+ return 'NP';
338
+ if (/^(VB|VBD|VBG|VBN|VBP|VBZ|MD)$/.test(tag))
339
+ return 'VP';
340
+ if (/^(IN|TO)$/.test(tag))
341
+ return 'PP';
342
+ if (/^(JJ|JJR|JJS)$/.test(tag))
343
+ return 'ADJP';
344
+ if (/^(RB|RBR|RBS|WRB)$/.test(tag))
345
+ return 'ADVP';
346
+ return 'XP';
347
+ }
348
+ function buildDepNodeTree(words, deps) {
349
+ const sq = { index: '1', nodeName: 'SQ', parent: null, contains: [] };
350
+ if (words.length === 0)
562
351
  return sq;
352
+ // children[g] = list of dependent word-indices (1-based) governed by g (1-based);
353
+ // roots are governed by 0.
354
+ const children = new Map();
355
+ for (const d of deps) {
356
+ if (d.dependentIndex < 1 || d.dependentIndex > words.length)
357
+ continue;
358
+ const g = d.governorIndex;
359
+ if (!children.has(g))
360
+ children.set(g, []);
361
+ children.get(g).push(d.dependentIndex);
563
362
  }
564
- // The top‑level SQ node (Antelope style)
565
- const sqNode = {
566
- index: '1',
567
- nodeName: 'SQ',
568
- parent: null,
569
- contains: [],
570
- };
571
- // Convert the root FinNLP node and attach it under SQ.
572
- const convertedRoot = convertFinNode(finRoot, words, sqNode);
573
- if (convertedRoot) {
574
- convertedRoot.parent = sqNode;
575
- sqNode.contains.push(convertedRoot);
576
- }
577
- // Ensure every word is represented somewhere in the tree.
578
- // Words not yet attached (e.g., punctuation at the edges) are added
579
- // directly under SQ.
580
- const attachedIndices = new Set();
581
- collectAttachedWordIndices(sqNode, attachedIndices);
582
- for (const w of words) {
583
- if (!attachedIndices.has(w.index)) {
584
- const leaf = createWordLeaf(w);
585
- leaf.parent = sqNode;
586
- sqNode.contains.push(leaf);
363
+ const build = (wordIdx, parent) => {
364
+ const word = words[wordIdx - 1];
365
+ const kids = (children.get(wordIdx) ?? []).filter((k) => k !== wordIdx);
366
+ if (kids.length === 0) {
367
+ const leaf = createWordLeaf(word);
368
+ leaf.parent = parent;
369
+ return leaf;
587
370
  }
588
- }
589
- return sqNode;
590
- }
591
- /**
592
- * Convert a single FinNodeInterface (sub‑tree) into a ClsNode.
593
- */
594
- function convertFinNode(finNode, words, parentNode) {
595
- // Determine whether this is a leaf (single‑word) node.
596
- const isLeaf = (!finNode.left || finNode.left.length === 0) &&
597
- (!finNode.right || finNode.right.length === 0);
598
- if (isLeaf && finNode.tokens.length === 1) {
599
- // Single‑word leaf reference the ClsWord
600
- const wordIdx = finNode.index[0]; // 0‑based
601
- const word = words[wordIdx];
602
- if (!word) {
603
- // Fallback: create a text leaf
604
- return {
605
- index: `leaf_${wordIdx}`,
606
- nodeName: finNode.tokens[0],
607
- parent: parentNode,
608
- contains: [],
609
- };
371
+ const node = {
372
+ index: `ph_${wordIdx}`,
373
+ nodeName: phraseType(word.lexicalClass),
374
+ parent,
375
+ contains: [],
376
+ };
377
+ // Order head + dependents by surface position for a projective tree.
378
+ const ordered = [...kids, wordIdx].sort((a, b) => a - b);
379
+ for (const idx of ordered) {
380
+ if (idx === wordIdx) {
381
+ const leaf = createWordLeaf(word);
382
+ leaf.parent = node;
383
+ node.contains.push(leaf);
384
+ }
385
+ else {
386
+ node.contains.push(build(idx, node));
387
+ }
610
388
  }
611
- return createWordLeaf(word);
612
- }
613
- // Phrase node – use the FinNLP type as label (NP, VP, PP, etc.)
614
- const phraseType = finNode.type && finNode.type !== 'ROOT'
615
- ? finNode.type
616
- : 'XP';
617
- const phraseNode = {
618
- index: `ph_${finNode.index[0]}_${finNode.index[1]}`,
619
- nodeName: phraseType,
620
- parent: parentNode,
621
- contains: [],
389
+ return node;
622
390
  };
623
- // Process left children (pre‑head dependents)
624
- if (finNode.left && finNode.left.length > 0) {
625
- for (const leftChild of finNode.left) {
626
- const childNode = convertFinNode(leftChild, words, phraseNode);
627
- if (childNode) {
628
- childNode.parent = phraseNode;
629
- phraseNode.contains.push(childNode);
630
- }
391
+ const roots = (children.get(0) ?? []).sort((a, b) => a - b);
392
+ if (roots.length === 0) {
393
+ // No explicit root: attach all words as leaves under SQ.
394
+ for (const w of words) {
395
+ const leaf = createWordLeaf(w);
396
+ leaf.parent = sq;
397
+ sq.contains.push(leaf);
631
398
  }
399
+ return sq;
632
400
  }
633
- // The head token(s) of this node
634
- for (let i = finNode.index[0]; i <= finNode.index[1]; i++) {
635
- const word = words[i];
636
- if (word) {
637
- const leaf = createWordLeaf(word);
638
- leaf.parent = phraseNode;
639
- phraseNode.contains.push(leaf);
640
- }
401
+ for (const r of roots) {
402
+ const child = build(r, sq);
403
+ sq.contains.push(child);
641
404
  }
642
- // Process right children (post‑head dependents)
643
- if (finNode.right && finNode.right.length > 0) {
644
- for (const rightChild of finNode.right) {
645
- const childNode = convertFinNode(rightChild, words, phraseNode);
646
- if (childNode) {
647
- childNode.parent = phraseNode;
648
- phraseNode.contains.push(childNode);
649
- }
405
+ // Attach any orphan words (rare) directly under SQ.
406
+ const attached = new Set();
407
+ collectAttachedWordIndices(sq, attached);
408
+ for (const w of words) {
409
+ if (!attached.has(w.index)) {
410
+ const leaf = createWordLeaf(w);
411
+ leaf.parent = sq;
412
+ sq.contains.push(leaf);
650
413
  }
651
414
  }
652
- return phraseNode;
415
+ return sq;
653
416
  }
654
- // ─── Leaf‑node helpers ────────────────────────────────────────────
417
+ // ── Leaf / traversal helpers (unchanged) ─────────────────────────────
655
418
  function createWordLeaf(word) {
656
419
  return {
657
420
  index: `w${word.index}`,
658
- nodeName: word.index.toString(), // Antelope style: the word’s 1‑based index as string
421
+ nodeName: word.index.toString(),
659
422
  parent: null,
660
423
  contains: [word],
661
424
  };
662
425
  }
663
- // ─── Tree traversal helpers ───────────────────────────────────────
664
426
  function collectWordNodes(node, map) {
665
427
  for (const child of node.contains) {
666
428
  if (child instanceof Object && 'word' in child) {
667
- // child is a ClsWord
668
- const w = child;
669
- // The leaf is the current node (since word leaves contain the word directly)
670
- map.set(w.index, node);
429
+ map.set(child.index, node);
671
430
  }
672
431
  else if (child instanceof Object && 'index' in child) {
673
- // child is a ClsNode
674
432
  collectWordNodes(child, map);
675
433
  }
676
434
  }
@@ -678,8 +436,7 @@ function collectWordNodes(node, map) {
678
436
  function collectAttachedWordIndices(node, set) {
679
437
  for (const child of node.contains) {
680
438
  if (child instanceof Object && 'word' in child) {
681
- const w = child;
682
- set.add(w.index);
439
+ set.add(child.index);
683
440
  }
684
441
  else if (child instanceof Object && 'index' in child) {
685
442
  collectAttachedWordIndices(child, set);