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.
- package/README.md +3 -13
- 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/clio/caesura.ts +145 -0
- package/src/clio/depfix.ts +88 -0
- package/src/clio/display.ts +1042 -0
- package/src/clio/engine.ts +38 -0
- package/src/clio/parser.ts +845 -0
- package/src/clio/phonological.ts +849 -0
- package/src/clio/phrasestress.ts +108 -0
- package/src/clio/pipeline.ts +154 -0
- package/src/clio/rhyme.ts +740 -0
- package/src/clio/scandroid.ts +434 -0
- package/src/clio/scansion.ts +1130 -0
- package/src/clio/semantics.ts +134 -0
- package/src/clio/stress.ts +1731 -0
- package/src/clio/tagfix.ts +104 -0
- package/src/display.ts +321 -137
- package/src/engine.ts +22 -0
- package/src/index.ts +176 -32
- package/src/parser.ts +346 -682
- package/src/phonological.ts +72 -4
- package/src/phrasestress.ts +108 -0
- package/src/rhyme.ts +428 -8
- package/src/scansion.ts +136 -15
- package/src/semantics.ts +134 -0
- package/src/stress.ts +794 -64
- package/src/types.ts +39 -4
- package/tests/DataForHayesLinesOnly.txt +364 -0
- package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
- package/tests/basic.test.ts +443 -37
- package/tests/bench-hayes.mjs +72 -0
- package/tests/wagner-stress.test.ts +188 -0
- package/vitest.config.ts +0 -15
package/dist/parser.js
CHANGED
|
@@ -1,234 +1,34 @@
|
|
|
1
|
-
// parser.ts — Syntactic dependency parser powered by
|
|
2
|
-
// ClsDocument with full dependency graph and phrase‑structure node
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
'(', ')', '[', ']', '{', '}',
|
|
44
|
+
'(', ')', '[', ']', '{', '}',
|
|
245
45
|
]);
|
|
246
46
|
export function isPunctuation(tag) {
|
|
247
47
|
return PUNCT_TAGS.has(tag);
|
|
248
48
|
}
|
|
249
49
|
/**
|
|
250
|
-
* Quotation-mark tags.
|
|
251
|
-
* breaks: a quoted word inside a clause
|
|
252
|
-
*
|
|
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
|
-
*
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 = '‒–—―−';
|
|
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
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
|
|
304
|
-
text = text.replace(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
.replace(
|
|
308
|
-
.replace(
|
|
309
|
-
.
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
//
|
|
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 →
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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: [],
|
|
234
|
+
syllables: [],
|
|
382
235
|
phraseStress: 0,
|
|
383
|
-
dependency: undefined,
|
|
384
|
-
node: undefined,
|
|
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
|
-
//
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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:
|
|
490
|
-
governorIndex:
|
|
491
|
-
dependentIndex:
|
|
492
|
-
dependentType:
|
|
493
|
-
governorName:
|
|
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:
|
|
497
|
-
? words[govPostIdx] : null,
|
|
285
|
+
governor: govWord,
|
|
498
286
|
dependent: depWord,
|
|
499
287
|
});
|
|
500
|
-
}
|
|
501
|
-
// Ensure ROOT dependency exists.
|
|
502
|
-
|
|
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
|
|
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
|
-
//
|
|
520
|
-
|
|
521
|
-
//
|
|
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
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
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
|
-
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
415
|
+
return sq;
|
|
653
416
|
}
|
|
654
|
-
//
|
|
417
|
+
// ── Leaf / traversal helpers (unchanged) ─────────────────────────────
|
|
655
418
|
function createWordLeaf(word) {
|
|
656
419
|
return {
|
|
657
420
|
index: `w${word.index}`,
|
|
658
|
-
nodeName: word.index.toString(),
|
|
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
|
-
|
|
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
|
-
|
|
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);
|