calliope-ts 0.0.4 → 0.1.0

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 (183) hide show
  1. package/README.md +69 -3
  2. package/dist/calliope/bracketing.d.ts.map +1 -1
  3. package/dist/calliope/bracketing.js +59 -5
  4. package/dist/calliope/deps.d.ts +5 -1
  5. package/dist/calliope/deps.d.ts.map +1 -1
  6. package/dist/calliope/deps.js +53 -3
  7. package/dist/calliope/engine.d.ts.map +1 -1
  8. package/dist/calliope/engine.js +6 -1
  9. package/dist/calliope/relstress.d.ts.map +1 -1
  10. package/dist/calliope/relstress.js +411 -15
  11. package/dist/calliope/syntax.d.ts +33 -0
  12. package/dist/calliope/syntax.d.ts.map +1 -1
  13. package/dist/calliope/syntax.js +199 -0
  14. package/dist/data/en_common_words.json +1 -0
  15. package/dist/display.d.ts +15 -1
  16. package/dist/display.d.ts.map +1 -1
  17. package/dist/display.js +112 -10
  18. package/dist/fabbhalle.d.ts +54 -0
  19. package/dist/fabbhalle.d.ts.map +1 -0
  20. package/dist/fabbhalle.js +404 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +86 -33
  23. package/dist/phonological.d.ts.map +1 -1
  24. package/dist/phonological.js +7 -1
  25. package/dist/rewriteEn.d.ts +14 -0
  26. package/dist/rewriteEn.d.ts.map +1 -0
  27. package/dist/rewriteEn.js +392 -0
  28. package/dist/rhyme.d.ts +35 -3
  29. package/dist/rhyme.d.ts.map +1 -1
  30. package/dist/rhyme.js +176 -11
  31. package/dist/russian/accentuator.d.ts +31 -0
  32. package/dist/russian/accentuator.d.ts.map +1 -0
  33. package/dist/russian/accentuator.js +546 -0
  34. package/dist/russian/collocations.d.ts +7 -0
  35. package/dist/russian/collocations.d.ts.map +1 -0
  36. package/dist/russian/collocations.js +38 -0
  37. package/dist/russian/compounds.d.ts +6 -0
  38. package/dist/russian/compounds.d.ts.map +1 -0
  39. package/dist/russian/compounds.js +81 -0
  40. package/dist/russian/download.d.ts +23 -0
  41. package/dist/russian/download.d.ts.map +1 -0
  42. package/dist/russian/download.js +117 -0
  43. package/dist/russian/engine.d.ts +6 -0
  44. package/dist/russian/engine.d.ts.map +1 -0
  45. package/dist/russian/engine.js +1424 -0
  46. package/dist/russian/parser.d.ts +8 -0
  47. package/dist/russian/parser.d.ts.map +1 -0
  48. package/dist/russian/parser.js +92 -0
  49. package/dist/russian/paths.d.ts +55 -0
  50. package/dist/russian/paths.d.ts.map +1 -0
  51. package/dist/russian/paths.js +114 -0
  52. package/dist/russian/rewrite.d.ts +6 -0
  53. package/dist/russian/rewrite.d.ts.map +1 -0
  54. package/dist/russian/rewrite.js +400 -0
  55. package/dist/russian/rhyme.d.ts +18 -0
  56. package/dist/russian/rhyme.d.ts.map +1 -0
  57. package/dist/russian/rhyme.js +230 -0
  58. package/dist/russian/syllabifier.d.ts +14 -0
  59. package/dist/russian/syllabifier.d.ts.map +1 -0
  60. package/dist/russian/syllabifier.js +475 -0
  61. package/dist/russian/types.d.ts +94 -0
  62. package/dist/russian/types.d.ts.map +1 -0
  63. package/dist/russian/types.js +27 -0
  64. package/dist/scandroidNative/constants.d.ts +16 -0
  65. package/dist/scandroidNative/constants.d.ts.map +1 -0
  66. package/dist/scandroidNative/constants.js +60 -0
  67. package/dist/scandroidNative/deduceParameters.d.ts +16 -0
  68. package/dist/scandroidNative/deduceParameters.d.ts.map +1 -0
  69. package/dist/scandroidNative/deduceParameters.js +69 -0
  70. package/dist/scandroidNative/dictionary-data.d.ts +2 -0
  71. package/dist/scandroidNative/dictionary-data.d.ts.map +1 -0
  72. package/dist/scandroidNative/dictionary-data.js +744 -0
  73. package/dist/scandroidNative/dictionary.d.ts +12 -0
  74. package/dist/scandroidNative/dictionary.d.ts.map +1 -0
  75. package/dist/scandroidNative/dictionary.js +50 -0
  76. package/dist/scandroidNative/engine.d.ts +41 -0
  77. package/dist/scandroidNative/engine.d.ts.map +1 -0
  78. package/dist/scandroidNative/engine.js +119 -0
  79. package/dist/scandroidNative/machine.d.ts +93 -0
  80. package/dist/scandroidNative/machine.d.ts.map +1 -0
  81. package/dist/scandroidNative/machine.js +795 -0
  82. package/dist/scandroidNative/positioner.d.ts +32 -0
  83. package/dist/scandroidNative/positioner.d.ts.map +1 -0
  84. package/dist/scandroidNative/positioner.js +208 -0
  85. package/dist/scandroidNative/syllabizer.d.ts +3 -0
  86. package/dist/scandroidNative/syllabizer.d.ts.map +1 -0
  87. package/dist/scandroidNative/syllabizer.js +215 -0
  88. package/dist/scandroidNative/utilities.d.ts +37 -0
  89. package/dist/scandroidNative/utilities.d.ts.map +1 -0
  90. package/dist/scandroidNative/utilities.js +86 -0
  91. package/dist/scansion.d.ts.map +1 -1
  92. package/dist/scansion.js +118 -14
  93. package/dist/stress.d.ts +1 -0
  94. package/dist/stress.d.ts.map +1 -1
  95. package/dist/stress.js +502 -42
  96. package/dist/types.d.ts +3 -0
  97. package/dist/types.d.ts.map +1 -1
  98. package/package.json +27 -6
  99. package/tools/fetch-russian-data.mjs +30 -0
  100. package/webapp/public/app.js +2418 -0
  101. package/webapp/public/assets/GithubLogo.png +0 -0
  102. package/webapp/public/assets/NPM2.png +0 -0
  103. package/webapp/public/assets/fonts/MurenaBold-webfont.woff2 +0 -0
  104. package/webapp/public/assets/fonts/MurenaItalic-webfont.woff2 +0 -0
  105. package/webapp/public/assets/fonts/MurenaRegular-webfont.woff2 +0 -0
  106. package/webapp/public/assets/fonts/Myra_4F_Caps_Bold-webfont.woff2 +0 -0
  107. package/webapp/public/assets/fonts/SinkinSans-700Bold.otf +0 -0
  108. package/webapp/public/assets/fonts/butterflies.ttf +0 -0
  109. package/webapp/public/assets/fonts/midcase_blackline-webfont.woff2 +0 -0
  110. package/webapp/public/assets/fonts/neueral-bold-webfont.woff2 +0 -0
  111. package/webapp/public/assets/fonts/neueral-regular-webfont.woff2 +0 -0
  112. package/webapp/public/assets/fonts/popelka-webfont.woff2 +0 -0
  113. package/webapp/public/assets/fonts/vremenagrotesk-webfont.woff +0 -0
  114. package/webapp/public/assets/fonts/wicky_javick_bold-webfont.woff2 +0 -0
  115. package/webapp/public/assets/logos/Logo_Var_10_Celtic_Knot_SVG.svg +116 -0
  116. package/webapp/public/assets/logos/Logo_Var_11_Graph_SVG.svg +313 -0
  117. package/webapp/public/assets/logos/Logo_Var_12_Limacon_Knot_SVG.svg +3942 -0
  118. package/webapp/public/assets/logos/Logo_Var_13_Crescents_SVG.svg +1030 -0
  119. package/webapp/public/assets/logos/Logo_Var_1_Spiral_SVG.svg +1491 -0
  120. package/webapp/public/assets/logos/Logo_Var_2_Tunnel_SVG.svg +12 -0
  121. package/webapp/public/assets/logos/Logo_Var_3_Triskelion_SVG.svg +10 -0
  122. package/webapp/public/assets/logos/Logo_Var_4_Quinquetra_PNG.png +0 -0
  123. package/webapp/public/assets/logos/Logo_Var_5_Penrose_Pentagram_SVG.svg +37 -0
  124. package/webapp/public/assets/logos/Logo_Var_6_Triskelion2_SVG.svg +10 -0
  125. package/webapp/public/assets/logos/Logo_Var_7_SixfoldSpirals_SVG.svg +20 -0
  126. package/webapp/public/assets/logos/Logo_Var_8_Radial_Labyrinth_SVG.svg +163 -0
  127. package/webapp/public/assets/logos/Logo_Var_9_Radial_Labyrinth_2_SVG.svg +106 -0
  128. package/webapp/public/index.html +316 -0
  129. package/webapp/public/styles.css +1114 -0
  130. package/webapp/server.mjs +680 -0
  131. package/src/caesura.ts +0 -201
  132. package/src/calliope/boundaries.ts +0 -190
  133. package/src/calliope/bracketing.ts +0 -390
  134. package/src/calliope/deps.ts +0 -160
  135. package/src/calliope/engine.ts +0 -77
  136. package/src/calliope/feats.ts +0 -46
  137. package/src/calliope/names.ts +0 -44
  138. package/src/calliope/postag.ts +0 -253
  139. package/src/calliope/prosodic.ts +0 -262
  140. package/src/calliope/relstress.ts +0 -645
  141. package/src/calliope/stressrules.ts +0 -147
  142. package/src/calliope/syntax.ts +0 -218
  143. package/src/calliope/udpos.ts +0 -152
  144. package/src/calliope_src_contents.md +0 -19049
  145. package/src/clio/caesura.ts +0 -145
  146. package/src/clio/depfix.ts +0 -88
  147. package/src/clio/display.ts +0 -1042
  148. package/src/clio/engine.ts +0 -38
  149. package/src/clio/parser.ts +0 -845
  150. package/src/clio/phonological.ts +0 -849
  151. package/src/clio/phrasestress.ts +0 -108
  152. package/src/clio/pipeline.ts +0 -154
  153. package/src/clio/rhyme.ts +0 -740
  154. package/src/clio/scandroid.ts +0 -434
  155. package/src/clio/scansion.ts +0 -1130
  156. package/src/clio/semantics.ts +0 -134
  157. package/src/clio/stress.ts +0 -1731
  158. package/src/clio/tagfix.ts +0 -104
  159. package/src/depfix.ts +0 -88
  160. package/src/display.ts +0 -1138
  161. package/src/engine.ts +0 -22
  162. package/src/index.ts +0 -690
  163. package/src/parser.ts +0 -501
  164. package/src/phonological.ts +0 -917
  165. package/src/phrasestress.ts +0 -108
  166. package/src/rhyme.ts +0 -748
  167. package/src/scandroid.ts +0 -434
  168. package/src/scansion.ts +0 -1174
  169. package/src/semantics.ts +0 -134
  170. package/src/stress.ts +0 -2111
  171. package/src/tagfix.ts +0 -104
  172. package/src/types.ts +0 -265
  173. package/tests/DataForHayesLinesOnly.txt +0 -364
  174. package/tests/DataForHayesStressSymbolsRevised.txt +0 -728
  175. package/tests/basic.test.ts +0 -1102
  176. package/tests/bench-hayes.mjs +0 -72
  177. package/tests/epg64.meter.train.txt +0 -8139
  178. package/tests/litlab-sample-2016.txt +0 -1738
  179. package/tests/prosodic.meter.train.txt +0 -13192
  180. package/tests/wagner-stress.test.ts +0 -188
  181. package/tsconfig.json +0 -29
  182. package/vitest.config.d.ts +0 -2
  183. package/vitest.config.js +0 -14
@@ -0,0 +1,546 @@
1
+ // accentuator.ts — Russian stress placement.
2
+ // Combines a 3.4M-entry pre-stressed dictionary with a neural MLP (arch=1)
3
+ // for OOV words, plus auxiliary dictionaries for:
4
+ // - morphologically-conditioned ambiguous stress (ambiguous_accents)
5
+ // - multi-stress words (ambiguous_accents2)
6
+ // - ёфикация (yo_words, yo_by_gram)
7
+ // - secondary stress (secondary_stress_dict)
8
+ // - compound word stress (derivation_data)
9
+ //
10
+ // The neural MLP forward pass is implemented in pure TypeScript using
11
+ // Float32Array matrix operations. Weights are loaded from a binary file
12
+ // extracted from the original PyTorch model.
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { gunzipSync } from 'node:zlib';
15
+ import { countVowels } from './syllabifier.js';
16
+ import { applyVerbPrefixDerivation } from './compounds.js';
17
+ import { RU_VOWELS } from './types.js';
18
+ import { russianDataFile, requireRussianDataFile } from './paths.js';
19
+ let _data = null;
20
+ function loadJson(filename) {
21
+ const p = requireRussianDataFile(filename);
22
+ return JSON.parse(readFileSync(p, 'utf-8'));
23
+ }
24
+ /** Load a JSON data file (used by other modules). */
25
+ export { loadJson };
26
+ function loadAccentsData() {
27
+ if (_data)
28
+ return _data;
29
+ // Load gzipped TSV dictionary
30
+ const tsvPath = russianDataFile('word_accents.tsv.gz');
31
+ const wordAccents = new Map();
32
+ if (tsvPath && existsSync(tsvPath)) {
33
+ const raw = gunzipSync(readFileSync(tsvPath)).toString('utf-8');
34
+ for (const line of raw.split('\n')) {
35
+ if (!line)
36
+ continue;
37
+ const tab = line.indexOf('\t');
38
+ if (tab > 0) {
39
+ wordAccents.set(line.slice(0, tab), Number(line.slice(tab + 1)));
40
+ }
41
+ }
42
+ }
43
+ // Load JSON dictionaries
44
+ const ambiguousAccents = loadJson('ambiguous_accents.json');
45
+ const ambiguousAccents2 = loadJson('ambiguous_accents2.json');
46
+ const yoWords = loadJson('yo_words.json');
47
+ const yoByGram = loadJson('yo_by_gram.json');
48
+ const secondaryStress = loadJson('secondary_stress.json');
49
+ const rhymedWordsArr = loadJson('rhymed_words.json');
50
+ const rhymingDict = loadJson('rhyming_dict.json');
51
+ const derivationData = loadJson('derivation_data.json');
52
+ const rhymedWords = new Set(rhymedWordsArr.map(p => [p[0], p[1]]));
53
+ _data = {
54
+ wordAccents, ambiguousAccents, ambiguousAccents2,
55
+ yoWords, yoByGram, secondaryStress,
56
+ rhymedWords, rhymingDict, derivationData,
57
+ };
58
+ return _data;
59
+ }
60
+ let _config = null;
61
+ let _weights = null;
62
+ function loadNeuralModel() {
63
+ if (_config && _weights)
64
+ return { config: _config, weights: _weights };
65
+ _config = loadJson('accentuator.json');
66
+ // Load binary weights
67
+ const binPath = requireRussianDataFile('accentuator.bin');
68
+ const buf = readFileSync(binPath);
69
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
70
+ let off = 0;
71
+ // Header: magic(4) + version(4) + num_layers(4)
72
+ off += 4; // skip magic
73
+ dv.getUint32(off, true);
74
+ off += 4; // version
75
+ const numLayers = dv.getUint32(off, true);
76
+ off += 4;
77
+ const layers = {};
78
+ for (let i = 0; i < numLayers; i++) {
79
+ const nameLen = dv.getUint32(off, true);
80
+ off += 4;
81
+ const name = buf.toString('utf-8', off, off + nameLen);
82
+ off += nameLen;
83
+ const numDims = dv.getUint32(off, true);
84
+ off += 4;
85
+ const shape = [];
86
+ for (let d = 0; d < numDims; d++) {
87
+ shape.push(dv.getUint32(off, true));
88
+ off += 4;
89
+ }
90
+ const dataLen = dv.getUint32(off, true);
91
+ off += 4;
92
+ // Copy bytes into a new ArrayBuffer to guarantee 4-byte alignment
93
+ // (the header strings can throw off alignment in the original buffer)
94
+ const dataBuf = new ArrayBuffer(dataLen);
95
+ new Uint8Array(dataBuf).set(buf.subarray(off, off + dataLen));
96
+ const data = new Float32Array(dataBuf);
97
+ off += dataLen;
98
+ layers[name] = { shape, data };
99
+ }
100
+ _weights = {
101
+ embeddingWeight: layers['embedding.weight'].data,
102
+ fc1Weight: layers['fc1.weight'].data,
103
+ fc1Bias: layers['fc1.bias'].data,
104
+ fc2Weight: layers['fc2.weight'].data,
105
+ fc2Bias: layers['fc2.bias'].data,
106
+ fc3Weight: layers['fc3.weight'].data,
107
+ fc3Bias: layers['fc3.bias'].data,
108
+ fc4Weight: layers['fc4.weight'].data,
109
+ fc4Bias: layers['fc4.bias'].data,
110
+ };
111
+ return { config: _config, weights: _weights };
112
+ }
113
+ function relu(x) { return x > 0 ? x : 0; }
114
+ function linear(input, inDim, weight, bias, outDim) {
115
+ const out = new Float32Array(outDim);
116
+ for (let o = 0; o < outDim; o++) {
117
+ let sum = bias[o];
118
+ const wOff = o * inDim;
119
+ for (let i = 0; i < inDim; i++) {
120
+ sum += input[i] * weight[wOff + i];
121
+ }
122
+ out[o] = sum;
123
+ }
124
+ return out;
125
+ }
126
+ function argmax(arr) {
127
+ let max = -Infinity, idx = 0;
128
+ for (let i = 0; i < arr.length; i++) {
129
+ if (arr[i] > max) {
130
+ max = arr[i];
131
+ idx = i;
132
+ }
133
+ }
134
+ return idx;
135
+ }
136
+ /** Neural stress prediction: returns 1-based vowel position, or -1. */
137
+ function neuralPredict(word) {
138
+ const { config, weights } = loadNeuralModel();
139
+ const maxLen = config.max_len;
140
+ const embedDim = config.embed_dim;
141
+ const { char2index } = config;
142
+ // Encode: [ + word + ], padded to maxLen
143
+ const inputIds = new Int32Array(maxLen);
144
+ const chars = ('[' + word.toLowerCase() + ']').split('');
145
+ for (let i = 0; i < Math.min(chars.length, maxLen); i++) {
146
+ inputIds[i] = char2index[chars[i]] ?? 0;
147
+ }
148
+ // Embedding lookup → flatten
149
+ const flat = new Float32Array(maxLen * embedDim);
150
+ for (let i = 0; i < maxLen; i++) {
151
+ const row = inputIds[i] * embedDim;
152
+ const off = i * embedDim;
153
+ for (let j = 0; j < embedDim; j++) {
154
+ flat[off + j] = weights.embeddingWeight[row + j];
155
+ }
156
+ }
157
+ // FC1 + ReLU
158
+ const h1 = linear(flat, maxLen * embedDim, weights.fc1Weight, weights.fc1Bias, 1984);
159
+ for (let i = 0; i < h1.length; i++)
160
+ h1[i] = relu(h1[i]);
161
+ // FC2 + ReLU
162
+ const h2 = linear(h1, 1984, weights.fc2Weight, weights.fc2Bias, 1984);
163
+ for (let i = 0; i < h2.length; i++)
164
+ h2[i] = relu(h2[i]);
165
+ // FC3 + ReLU
166
+ const h3 = linear(h2, 1984, weights.fc3Weight, weights.fc3Bias, 54);
167
+ for (let i = 0; i < h3.length; i++)
168
+ h3[i] = relu(h3[i]);
169
+ // FC4 (no ReLU, softmax next)
170
+ const logits = linear(h3, 54, weights.fc4Weight, weights.fc4Bias, 27);
171
+ // Argmax → vowel index (0-based, convert to 1-based)
172
+ const predictedIdx = argmax(logits);
173
+ // Map vowel index to actual position in the word
174
+ let nvowels = 0;
175
+ for (let i = 0; i < word.length; i++) {
176
+ if (RU_VOWELS.includes(word[i].toLowerCase())) {
177
+ nvowels++;
178
+ if (nvowels === predictedIdx + 1)
179
+ return nvowels;
180
+ }
181
+ }
182
+ return -1;
183
+ }
184
+ /** Apply ёфикация: replace 'е' with 'ё' where appropriate. */
185
+ export function yoficate(word, feats = {}, upos) {
186
+ const data = loadAccentsData();
187
+ const lower = word.toLowerCase();
188
+ // If it's a known ambiguous accent2 word, don't yoficate (matching original logic)
189
+ if (data.ambiguousAccents2[lower]) {
190
+ return word;
191
+ }
192
+ // If there's a strict single yofication, use it
193
+ if (data.yoWords[lower]) {
194
+ return applyYo(word, data.yoWords[lower]);
195
+ }
196
+ // Check gram-specific ёфикация
197
+ if (data.yoByGram[lower]) {
198
+ const gramData = data.yoByGram[lower];
199
+ let bestYoForm = null;
200
+ let bestMatching = -1;
201
+ // Build the tagset the original Python uses: ud_tags + [upos]
202
+ // Here we approximate with feats + upos
203
+ const featSet = new Set();
204
+ if (upos)
205
+ featSet.add(upos);
206
+ for (const [k, v] of Object.entries(feats)) {
207
+ featSet.add(`${k}=${v}`);
208
+ }
209
+ for (const [yoForm, tagsets] of Object.entries(gramData)) {
210
+ for (const tagset of tagsets) {
211
+ let nbMatched = 0;
212
+ for (const tag of tagset.split('|')) {
213
+ if (featSet.has(tag))
214
+ nbMatched++;
215
+ }
216
+ if (nbMatched > bestMatching) {
217
+ bestMatching = nbMatched;
218
+ bestYoForm = yoForm;
219
+ }
220
+ }
221
+ }
222
+ if (bestYoForm) {
223
+ return applyYo(word, bestYoForm);
224
+ }
225
+ }
226
+ return word;
227
+ }
228
+ function applyYo(word, yoForm) {
229
+ // The yoForm is the same word but with 'ё' where 'е' was.
230
+ // Simply find all positions where yoForm has 'ё' and replace in word.
231
+ const result = word.split('');
232
+ const lowerWord = word.toLowerCase();
233
+ const lowerYo = yoForm.toLowerCase();
234
+ // Walk both strings in parallel, matching characters
235
+ let wi = 0, yi = 0;
236
+ while (wi < result.length && yi < yoForm.length) {
237
+ if (lowerWord[wi] === lowerYo[yi]) {
238
+ // Same character — check if yoForm has 'ё' here
239
+ if (yoForm[yi] === 'ё' || yoForm[yi] === 'Ё') {
240
+ // Replace with ё (preserving case)
241
+ if (word[wi] === word[wi].toUpperCase()) {
242
+ result[wi] = 'Ё';
243
+ }
244
+ else {
245
+ result[wi] = 'ё';
246
+ }
247
+ }
248
+ wi++;
249
+ yi++;
250
+ }
251
+ else if (lowerWord[wi] === 'е' && lowerYo[yi] === 'ё') {
252
+ // Direct е→ё replacement
253
+ if (word[wi] === word[wi].toUpperCase()) {
254
+ result[wi] = 'Ё';
255
+ }
256
+ else {
257
+ result[wi] = 'ё';
258
+ }
259
+ wi++;
260
+ yi++;
261
+ }
262
+ else {
263
+ // Mismatch — skip
264
+ yi++;
265
+ }
266
+ }
267
+ return result.join('');
268
+ }
269
+ /** Get all accentuations for a word (multi-variant, like original get_accents). */
270
+ export function getAccents(word, feats = {}, upos) {
271
+ const data = loadAccentsData();
272
+ const lower = word.toLowerCase();
273
+ const vowelCount = countVowels(lower);
274
+ if (vowelCount === 0) {
275
+ return [{ stressPos: -1, secondaryStress: null, ambiguous: false, allStressPositions: [] }];
276
+ }
277
+ if (vowelCount === 1) {
278
+ return [{ stressPos: 1, secondaryStress: getSecondaryStress(lower, data), ambiguous: false, allStressPositions: [1] }];
279
+ }
280
+ const secondaryStress = getSecondaryStress(lower, data);
281
+ // ё-stress
282
+ if (lower.includes('ё')) {
283
+ if (data.wordAccents.has(lower)) {
284
+ return [{ stressPos: data.wordAccents.get(lower), secondaryStress, ambiguous: false, allStressPositions: [data.wordAccents.get(lower)] }];
285
+ }
286
+ let nvowels = 0;
287
+ for (const c of lower) {
288
+ if ('уеыаоэёяию'.includes(c)) {
289
+ nvowels++;
290
+ if (c === 'ё') {
291
+ return [{ stressPos: nvowels, secondaryStress, ambiguous: false, allStressPositions: [nvowels] }];
292
+ }
293
+ }
294
+ }
295
+ }
296
+ // ambiguous_accents2 — multiple stress positions
297
+ if (data.ambiguousAccents2[lower]) {
298
+ return data.ambiguousAccents2[lower].map(sp => ({ stressPos: sp, secondaryStress, ambiguous: true, allStressPositions: data.ambiguousAccents2[lower] }));
299
+ }
300
+ // word_accents_dict — single stress
301
+ if (data.wordAccents.has(lower)) {
302
+ const sp = data.wordAccents.get(lower);
303
+ return [{ stressPos: sp, secondaryStress, ambiguous: false, allStressPositions: [sp] }];
304
+ }
305
+ // ambiguous_accents — homograph resolution
306
+ if (data.ambiguousAccents[lower]) {
307
+ const forms = data.ambiguousAccents[lower];
308
+ const results = [];
309
+ for (const [stressedForm, tagsets] of Object.entries(forms)) {
310
+ if (matchesFeatures(feats, tagsets, upos)) {
311
+ const sp = stressPosFromForm(stressedForm);
312
+ if (sp > 0)
313
+ results.push({ stressPos: sp, secondaryStress, ambiguous: true, allStressPositions: [sp] });
314
+ }
315
+ }
316
+ if (results.length === 0) {
317
+ for (const stressedForm of Object.keys(forms)) {
318
+ const sp = stressPosFromForm(stressedForm);
319
+ if (sp > 0)
320
+ results.push({ stressPos: sp, secondaryStress, ambiguous: true, allStressPositions: [sp] });
321
+ }
322
+ }
323
+ if (results.length > 0)
324
+ return results;
325
+ }
326
+ // Consonant-only abbreviations
327
+ if (/^[бвгджзклмнпрстфхцчшщ]{2,}$/i.test(lower)) {
328
+ return [{ stressPos: lower.length, secondaryStress: null, ambiguous: false, allStressPositions: [lower.length] }];
329
+ }
330
+ // Verb prefix derivation
331
+ if (feats['VerbForm'] || feats['Aspect']) {
332
+ // Basic heuristic to check if it's a verb, or we can just try it
333
+ const verbStress = applyVerbPrefixDerivation(lower, 'VERB');
334
+ if (verbStress && verbStress > 0) {
335
+ return [{ stressPos: verbStress, secondaryStress, ambiguous: false, allStressPositions: [verbStress] }];
336
+ }
337
+ }
338
+ // Neural MLP fallback
339
+ const predicted = neuralPredict(lower);
340
+ return [{ stressPos: predicted, secondaryStress: null, ambiguous: false, allStressPositions: predicted > 0 ? [predicted] : [] }];
341
+ }
342
+ /** Get stress position for a word, using dictionary + neural MLP fallback. */
343
+ export function getAccent(word, feats = {}, upos) {
344
+ const data = loadAccentsData();
345
+ const lower = word.toLowerCase();
346
+ const vowelCount = countVowels(lower);
347
+ // 0a. No vowels → no stress
348
+ if (vowelCount === 0) {
349
+ return { stressPos: -1, secondaryStress: null, ambiguous: false, allStressPositions: [] };
350
+ }
351
+ // 0b. Single-vowel words → stress on that vowel (universal Russian rule)
352
+ if (vowelCount === 1) {
353
+ return { stressPos: 1, secondaryStress: null, ambiguous: false, allStressPositions: [1] };
354
+ }
355
+ // 0c. ё-stress rule: if word contains ё, check dict first, then ё is always stressed
356
+ if (lower.includes('ё')) {
357
+ if (data.wordAccents.has(lower)) {
358
+ return {
359
+ stressPos: data.wordAccents.get(lower),
360
+ secondaryStress: getSecondaryStress(lower, data),
361
+ ambiguous: false,
362
+ allStressPositions: [data.wordAccents.get(lower)],
363
+ };
364
+ }
365
+ // ё is always stressed in Russian (except ёфикация and derivatives)
366
+ let nvowels = 0;
367
+ for (const c of lower) {
368
+ if ('уеыаоэёяию'.includes(c)) {
369
+ nvowels++;
370
+ if (c === 'ё') {
371
+ return { stressPos: nvowels, secondaryStress: null, ambiguous: false, allStressPositions: [nvowels] };
372
+ }
373
+ }
374
+ }
375
+ }
376
+ // 1. Check ambiguous_accents2 first (multiple possible stress positions)
377
+ if (data.ambiguousAccents2[lower]) {
378
+ const positions = data.ambiguousAccents2[lower];
379
+ return {
380
+ stressPos: positions[0],
381
+ secondaryStress: getSecondaryStress(lower, data),
382
+ ambiguous: true,
383
+ allStressPositions: positions,
384
+ };
385
+ }
386
+ // 2. Check ambiguous_accents (morphologically conditioned)
387
+ if (data.ambiguousAccents[lower]) {
388
+ const forms = data.ambiguousAccents[lower];
389
+ for (const [stressedForm, tagsets] of Object.entries(forms)) {
390
+ if (matchesFeatures(feats, tagsets, upos)) {
391
+ const stressPos = stressPosFromForm(stressedForm);
392
+ if (stressPos > 0) {
393
+ return {
394
+ stressPos,
395
+ secondaryStress: getSecondaryStress(lower, data),
396
+ ambiguous: true,
397
+ allStressPositions: [stressPos],
398
+ };
399
+ }
400
+ }
401
+ }
402
+ const firstForm = Object.keys(forms)[0];
403
+ const stressPos = stressPosFromForm(firstForm);
404
+ if (stressPos > 0) {
405
+ return {
406
+ stressPos,
407
+ secondaryStress: getSecondaryStress(lower, data),
408
+ ambiguous: true,
409
+ allStressPositions: [stressPos],
410
+ };
411
+ }
412
+ }
413
+ // 3. Check main dictionary
414
+ if (data.wordAccents.has(lower)) {
415
+ return {
416
+ stressPos: data.wordAccents.get(lower),
417
+ secondaryStress: getSecondaryStress(lower, data),
418
+ ambiguous: false,
419
+ allStressPositions: [data.wordAccents.get(lower)],
420
+ };
421
+ }
422
+ // 4. Consonant-only abbreviations → stress on last "syllable"
423
+ if (/^[бвгджзклмнпрстфхцчшщ]{2,}$/i.test(lower)) {
424
+ return {
425
+ stressPos: lower.length,
426
+ secondaryStress: null,
427
+ ambiguous: false,
428
+ allStressPositions: [lower.length],
429
+ };
430
+ }
431
+ // Verb prefix derivation
432
+ if (feats['VerbForm'] || feats['Aspect']) {
433
+ const verbStress = applyVerbPrefixDerivation(lower, 'VERB');
434
+ if (verbStress && verbStress > 0) {
435
+ return {
436
+ stressPos: verbStress,
437
+ secondaryStress: null,
438
+ ambiguous: false,
439
+ allStressPositions: [verbStress],
440
+ };
441
+ }
442
+ }
443
+ // 5. Neural MLP fallback for OOV words
444
+ const predicted = neuralPredict(lower);
445
+ return {
446
+ stressPos: predicted,
447
+ secondaryStress: null,
448
+ ambiguous: false,
449
+ allStressPositions: predicted > 0 ? [predicted] : [],
450
+ };
451
+ }
452
+ /** Check if morphological features match any of the tagsets. A tagset
453
+ * component without '=' is a bare UPOS requirement (e.g. 'NOUN|Gender=Neut')
454
+ * — enforced when the caller supplies the token's UPOS, ignored otherwise
455
+ * (legacy lenient behaviour). */
456
+ function matchesFeatures(feats, tagsets, upos) {
457
+ if (!tagsets || tagsets.length === 0)
458
+ return false;
459
+ for (const tagset of tagsets) {
460
+ let allMatch = true;
461
+ for (const pair of tagset.split('|')) {
462
+ const [key, val] = pair.split('=');
463
+ if (val === undefined) {
464
+ if (upos && key !== upos) {
465
+ allMatch = false;
466
+ break;
467
+ }
468
+ continue;
469
+ }
470
+ if (feats[key] && feats[key] !== val) {
471
+ allMatch = false;
472
+ break;
473
+ }
474
+ }
475
+ if (allMatch)
476
+ return true;
477
+ }
478
+ return false;
479
+ }
480
+ /** Extract stress position from a stressed form (uppercase vowel = stressed). */
481
+ function stressPosFromForm(form) {
482
+ let nvowels = 0;
483
+ for (const c of form) {
484
+ if ('уеыаоэёяию'.includes(c.toLowerCase()))
485
+ nvowels++;
486
+ if ('АЕЁИОУЫЭЮЯ'.includes(c))
487
+ return nvowels;
488
+ }
489
+ return -1;
490
+ }
491
+ /** Get secondary stress for a word. */
492
+ function getSecondaryStress(word, data) {
493
+ if (data.secondaryStress[word]) {
494
+ return data.secondaryStress[word];
495
+ }
496
+ // Check compound word prefixes
497
+ const deriv = data.derivationData;
498
+ if (deriv?.compound2stress && deriv?.compound_prefixes) {
499
+ for (const prefix of deriv.compound_prefixes) {
500
+ if (word.startsWith(prefix) && word.length > prefix.length) {
501
+ const tail = word.slice(prefix.length);
502
+ if (data.wordAccents.has(tail) || data.ambiguousAccents[tail] || deriv.compound_tails?.[tail]) {
503
+ const stressedHead = deriv.compound2stress[prefix];
504
+ if (stressedHead) {
505
+ const secPos = stressPosFromForm(stressedHead);
506
+ if (secPos > 0) {
507
+ const result = new Array(countVowels(word)).fill(0);
508
+ let vCount = 0;
509
+ for (let i = 0; i < prefix.length && vCount < secPos; i++) {
510
+ if (RU_VOWELS.includes(word[i])) {
511
+ vCount++;
512
+ if (vCount === secPos)
513
+ result[vCount - 1] = 2;
514
+ }
515
+ }
516
+ return result;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+ }
523
+ return null;
524
+ }
525
+ /** Render a word with stress mark (U+0301 for primary, U+0300 for secondary). */
526
+ export function renderStressedWord(word, accent) {
527
+ const out = [];
528
+ let vowelCount = 0;
529
+ for (const c of word) {
530
+ out.push(c);
531
+ if (RU_VOWELS.includes(c.toLowerCase())) {
532
+ vowelCount++;
533
+ if (accent.secondaryStress && accent.secondaryStress[vowelCount - 1] === 2) {
534
+ out.push('\u0300');
535
+ }
536
+ else if (accent.stressPos === vowelCount) {
537
+ out.push('\u0301');
538
+ }
539
+ }
540
+ }
541
+ return out.join('');
542
+ }
543
+ /** Get the data handle (for rhyme module). */
544
+ export function getAccentsData() {
545
+ return loadAccentsData();
546
+ }
@@ -0,0 +1,7 @@
1
+ /** Check if a sequence of words matches a collocation override.
2
+ * Returns the stress override for the matched word, or null. */
3
+ export declare function checkCollocation(words: string[], currentIdx: number): {
4
+ wordIndex: number;
5
+ stressPos: number;
6
+ } | null;
7
+ //# sourceMappingURL=collocations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collocations.d.ts","sourceRoot":"","sources":["../../src/russian/collocations.ts"],"names":[],"mappings":"AAuBA;iEACiE;AACjE,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,GACjB;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAwBjD"}
@@ -0,0 +1,38 @@
1
+ // collocations.ts — Collocation stress overrides.
2
+ // Some multi-word expressions have non-obvious stress patterns that differ
3
+ // from the individual words' dictionary stress. This module applies a small
4
+ // set of overrides (185 entries from the original tool).
5
+ import { requireRussianDataFile } from './paths.js';
6
+ import { readFileSync } from 'node:fs';
7
+ let _collocations = null;
8
+ function loadCollocations() {
9
+ if (_collocations)
10
+ return _collocations;
11
+ const p = requireRussianDataFile('collocations.json');
12
+ _collocations = JSON.parse(readFileSync(p, 'utf-8'));
13
+ return _collocations;
14
+ }
15
+ /** Check if a sequence of words matches a collocation override.
16
+ * Returns the stress override for the matched word, or null. */
17
+ export function checkCollocation(words, currentIdx) {
18
+ const colls = loadCollocations();
19
+ // Check 2-word collocations
20
+ if (currentIdx > 0) {
21
+ const key = words[currentIdx - 1].toLowerCase() + '|' + words[currentIdx].toLowerCase();
22
+ if (colls[key]) {
23
+ for (const entry of colls[key]) {
24
+ return { wordIndex: entry.stressed_word_index, stressPos: entry.stress_pos };
25
+ }
26
+ }
27
+ }
28
+ // Check forward
29
+ if (currentIdx < words.length - 1) {
30
+ const key = words[currentIdx].toLowerCase() + '|' + words[currentIdx + 1].toLowerCase();
31
+ if (colls[key]) {
32
+ for (const entry of colls[key]) {
33
+ return { wordIndex: entry.stressed_word_index, stressPos: entry.stress_pos };
34
+ }
35
+ }
36
+ }
37
+ return null;
38
+ }
@@ -0,0 +1,6 @@
1
+ /** Check if a word is a compound and return secondary stress info. */
2
+ export declare function getCompoundSecondaryStress(word: string): number[] | null;
3
+ /** Apply verb prefix derivation: for prefixed verbs, preserve the stress
4
+ * of the original unprefixed verb. */
5
+ export declare function applyVerbPrefixDerivation(word: string, upos: string): number | null;
6
+ //# sourceMappingURL=compounds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compounds.d.ts","sourceRoot":"","sources":["../../src/russian/compounds.ts"],"names":[],"mappings":"AASA,sEAAsE;AACtE,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAiDxE;AAWD;uCACuC;AACvC,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAenF"}
@@ -0,0 +1,81 @@
1
+ // compounds.ts — Compound word and secondary stress handling.
2
+ // Russian frequently uses compound words in poetry. The algorithm detects
3
+ // compound prefixes, places secondary stress on the first root, and primary
4
+ // stress on the second root.
5
+ import { getAccentsData } from './accentuator.js';
6
+ import { countVowels } from './syllabifier.js';
7
+ import { RU_VOWELS } from './types.js';
8
+ /** Check if a word is a compound and return secondary stress info. */
9
+ export function getCompoundSecondaryStress(word) {
10
+ const data = getAccentsData();
11
+ const lower = word.toLowerCase();
12
+ // 1. Check explicit secondary stress dictionary
13
+ if (data.secondaryStress[lower]) {
14
+ return data.secondaryStress[lower];
15
+ }
16
+ // 2. Skip if word already has known stress (not compound)
17
+ if (data.wordAccents.has(lower) || data.ambiguousAccents[lower] || data.ambiguousAccents2[lower]) {
18
+ return null;
19
+ }
20
+ // 3. Check compound prefixes
21
+ const deriv = data.derivationData;
22
+ if (!deriv?.compound2stress || !deriv?.compound_prefixes)
23
+ return null;
24
+ for (const prefix of deriv.compound_prefixes) {
25
+ if (lower.startsWith(prefix) && lower.length > prefix.length) {
26
+ const tail = lower.slice(prefix.length);
27
+ const isValidTail = data.wordAccents.has(tail) ||
28
+ data.ambiguousAccents[tail] ||
29
+ data.ambiguousAccents2[tail] ||
30
+ deriv.compound_tails?.[tail];
31
+ if (isValidTail) {
32
+ const stressedHead = deriv.compound2stress[prefix];
33
+ if (stressedHead) {
34
+ const secPos = stressPosFromForm(stressedHead);
35
+ if (secPos > 0) {
36
+ const nVowels = countVowels(lower);
37
+ const result = new Array(nVowels).fill(0);
38
+ let vCount = 0;
39
+ for (let i = 0; i < prefix.length && vCount < secPos; i++) {
40
+ if (RU_VOWELS.includes(lower[i])) {
41
+ vCount++;
42
+ if (vCount === secPos)
43
+ result[vCount - 1] = 2;
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ function stressPosFromForm(form) {
55
+ let nvowels = 0;
56
+ for (const c of form) {
57
+ if ('уеыаоэёяию'.includes(c.toLowerCase()))
58
+ nvowels++;
59
+ if ('АЕЁИОУЫЭЮЯ'.includes(c))
60
+ return nvowels;
61
+ }
62
+ return -1;
63
+ }
64
+ /** Apply verb prefix derivation: for prefixed verbs, preserve the stress
65
+ * of the original unprefixed verb. */
66
+ export function applyVerbPrefixDerivation(word, upos) {
67
+ if (upos !== 'VERB')
68
+ return null;
69
+ const data = getAccentsData();
70
+ const lower = word.toLowerCase();
71
+ const verbPrefixes = data.derivationData?.verb_prefixes || [];
72
+ for (const prefix of verbPrefixes) {
73
+ if (lower.startsWith(prefix) && lower.length > prefix.length) {
74
+ const stem = lower.slice(prefix.length);
75
+ if (data.wordAccents.has(stem)) {
76
+ return data.wordAccents.get(stem);
77
+ }
78
+ }
79
+ }
80
+ return null;
81
+ }