hanlint 0.0.17 → 0.0.18
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 +12 -10
- package/data/calqueFrame.json +60 -0
- package/data/cliches.json +10 -0
- package/data/collocationStops.txt +28 -0
- package/data/easyWords.json +0 -234
- package/data/exemplars.json +23 -2
- package/data/koreanProse.txt +64 -0
- package/data/patterns.json +2 -2
- package/data/phraseRepeat.json +11 -0
- package/data/ruleCategories.json +2 -0
- package/data/ruleDocs.json +18 -16
- package/data/ruleMechanisms.json +2 -0
- package/data/screenNarration.json +7 -7
- package/data/screenSentence.json +56 -8
- package/data/screenTone.json +4 -4
- package/data/screenWord.json +48 -88
- package/data/spacing.json +1 -1
- package/data/usageCounts.report.json +1676 -0
- package/data/version.json +1 -1
- package/package.json +1 -1
- package/src/analysis/index.js +1 -1
- package/src/analysis/tokenize.js +30 -15
- package/src/cli/help.js +3 -2
- package/src/cli/main.js +227 -88
- package/src/cli/walk.js +1 -1
- package/src/cli/welcome.js +1 -1
- package/src/config/settings.js +17 -0
- package/src/data/profiles.js +2 -2
- package/src/data/prose.js +27 -0
- package/src/document/sourceText.js +104 -6
- package/src/fingerprint/build.js +13 -4
- package/src/fingerprint/dictionaries.js +31 -4
- package/src/fingerprint/markers.js +22 -0
- package/src/index.js +3 -1
- package/src/report/compactReport.js +7 -3
- package/src/report/jsonReport.js +2 -2
- package/src/report/sheet.js +0 -0
- package/src/report/textReport.js +27 -8
- package/src/report/writingSpec.js +3 -3
- package/src/rules/registry.js +5 -1
- package/src/rules/screen/screenWord.js +2 -1
- package/src/rules/sentence/calqueFrame.js +11 -0
- package/src/rules/sentence/cliche.js +7 -3
- package/src/rules/sentence/hardWord.js +6 -3
- package/src/rules/sentence/japaneseLoan.js +7 -3
- package/src/rules/sentence/nounPile.js +10 -4
- package/src/rules/sentence/phraseRepeat.js +53 -0
- package/src/rules/sentence/redundantPair.js +7 -3
- package/src/rules/sentence/translationese.js +7 -3
- package/src/rules/shared/dictionaryRule.js +37 -6
- package/src/text.js +1 -1
- package/src/usage/counts.js +54 -0
- package/src/usage/index.js +8 -0
- package/src/usage/sentences.js +681 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 문장 역인덱스. 뜻과 파일 꼴은 파이썬 usage/sentences.py 가 소유한다. 같은 글에서 같은 바이트를 만들고 같은 순서로 답한다.
|
|
4
|
+
* 토큰은 조사를 뗀 어절과 한글 어절의 글자 두 개짜리 조각, 순서는 BM25 (k1 1.5, b 0.75) 값, 값은 SCORE_SCALE 배의
|
|
5
|
+
* 정수로 내려 견준다 (두 구현의 log 가 마지막 자리에서 다를 수 있다). 만들기는 문장 표를 바로 쓰고 postings 를 조각으로
|
|
6
|
+
* 내렸다가 병합한다 (파이썬과 같은 조각 크기, 같은 병합 결과). 함께 쓰는 말은 조회 때 postings 앞 문장을 읽어 센다.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
closeSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
readSync,
|
|
17
|
+
rmdirSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
writeSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
25
|
+
|
|
26
|
+
import { splitSentences } from "../analysis/splitSentences.js";
|
|
27
|
+
import { COPULA, EDGE_PUNCTUATION, isBareNoun, josaSet, nonNouns, stripJosa, tailOf, words } from "../analysis/tokenize.js";
|
|
28
|
+
import { loadLines } from "../data/load.js";
|
|
29
|
+
import { splitLines, splitWords, stripChars } from "../text.js";
|
|
30
|
+
|
|
31
|
+
export const FORMAT = 3;
|
|
32
|
+
const K1 = 1.5;
|
|
33
|
+
const B = 0.75;
|
|
34
|
+
export const SCORE_SCALE = 1_000_000;
|
|
35
|
+
const MAX_DF_SHARE = 0.5;
|
|
36
|
+
const MIN_FILTER_SENTENCES = 1000;
|
|
37
|
+
const SHARD_SENTENCES = 250_000;
|
|
38
|
+
const COLLOCATION_SAMPLE = 4000;
|
|
39
|
+
const COLLOCATION_TOP = 8;
|
|
40
|
+
const PREDICATE_STEMS = ["하였", "되었", "시켰", "했", "됐", "하", "되"];
|
|
41
|
+
const PREDICATE_TAILS = ["하지", "되지", "하거나", "되거나", "하기", "되기"];
|
|
42
|
+
const HANGUL_WORD = /^[가-힣]+$/;
|
|
43
|
+
const HANGUL = /[가-힣]/;
|
|
44
|
+
const ASCII_WORD = /[A-Za-z0-9]+/g;
|
|
45
|
+
const DIGITS = /[0-9]+/g;
|
|
46
|
+
const SPACE = /\s+/g;
|
|
47
|
+
const STRAY = /[\u001c-\u001f\u0085]/g;
|
|
48
|
+
const BOM = /\ufeff/g;
|
|
49
|
+
const BULLET = /^(?:(?:[-*·•※]|\([0-9a-z]{1,2}\)|[0-9]{1,2}[.)])\s+|(?:\([가-힣]\)|[가-힣][.)])\s*)/;
|
|
50
|
+
const TERMINAL = /(?<![0-9])[.!?]["'”’)\]]*$/;
|
|
51
|
+
const FENCE = "```";
|
|
52
|
+
const TEXT_SUFFIXES = new Set([".txt", ".md"]);
|
|
53
|
+
const JSONL_SUFFIX = ".jsonl";
|
|
54
|
+
export const FILES = [
|
|
55
|
+
"meta.json",
|
|
56
|
+
"sentences.tsv",
|
|
57
|
+
"offsets.bin",
|
|
58
|
+
"lengths.bin",
|
|
59
|
+
"documents.bin",
|
|
60
|
+
"terms.tsv",
|
|
61
|
+
"termOffsets.bin",
|
|
62
|
+
"postings.bin",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function defaultRoot() {
|
|
66
|
+
return join(homedir(), ".cache", "hanlint", "usage");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 파이썬 sorted() 와 같은 코드 포인트 순. @param {string} a @param {string} b */
|
|
70
|
+
export function compareCodePoints(a, b) {
|
|
71
|
+
const left = [...a];
|
|
72
|
+
const right = [...b];
|
|
73
|
+
const count = Math.min(left.length, right.length);
|
|
74
|
+
for (let i = 0; i < count; i++) {
|
|
75
|
+
const delta = /** @type {number} */ (left[i].codePointAt(0)) - /** @type {number} */ (right[i].codePointAt(0));
|
|
76
|
+
if (delta) return delta;
|
|
77
|
+
}
|
|
78
|
+
return left.length - right.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 두 구현이 같은 글자를 보게 한다. 뜻은 파이썬 normalized 가 소유한다. @param {string} text */
|
|
82
|
+
export function normalized(text) {
|
|
83
|
+
return text.replace(BOM, "").replace(STRAY, " ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** BM25 토큰. 조사를 뗀 어절과 한글 어절의 글자 두 개짜리 조각. 영문과 숫자는 부호에서 갈라 소문자로. @param {string} text @returns {string[]} */
|
|
87
|
+
export function indexTokens(text) {
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const found = [];
|
|
90
|
+
for (const raw of splitWords(normalized(text))) {
|
|
91
|
+
let core = stripChars(raw, EDGE_PUNCTUATION);
|
|
92
|
+
if (!core || josaSet().has(core) || COPULA.test(core)) continue;
|
|
93
|
+
core = stripJosa(core);
|
|
94
|
+
if (HANGUL.test(core)) {
|
|
95
|
+
found.push(core);
|
|
96
|
+
const chars = [...core];
|
|
97
|
+
if (chars.length >= 3) {
|
|
98
|
+
for (let i = 0; i < chars.length - 1; i++) found.push(chars[i] + chars[i + 1]);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
for (const piece of core.match(ASCII_WORD) ?? []) found.push(piece.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 함께 쓰는 말을 물을 낱말. 뜻은 파이썬 queryCores 가 소유한다. @param {string} text @returns {string[]} */
|
|
108
|
+
export function queryCores(text) {
|
|
109
|
+
/** @type {string[]} */
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const raw of splitWords(normalized(text))) {
|
|
112
|
+
const core = stripJosa(stripChars(raw, EDGE_PUNCTUATION));
|
|
113
|
+
if ([...core].length >= 2 && HANGUL.test(core) && !found.includes(core)) found.push(core);
|
|
114
|
+
}
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 같은 문장으로 접는 꼴. @param {string} text */
|
|
119
|
+
export function sentenceKey(text) {
|
|
120
|
+
return text.replace(SPACE, " ").trim().replace(DIGITS, "0");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 접는 꼴의 64비트 해시 (SHA-256 앞 8바이트, little endian). 파이썬 keyHash 와 같은 값이다. @param {string} key */
|
|
124
|
+
export function keyHash(key) {
|
|
125
|
+
return createHash("sha256").update(key, "utf-8").digest().readBigUInt64LE(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 글의 산문 줄. 코드 펜스 안, 제목, 표 줄은 넘기고 목록 표시는 뗀다. @param {string} text @returns {string[]} */
|
|
129
|
+
export function paragraphsOf(text) {
|
|
130
|
+
/** @type {string[]} */
|
|
131
|
+
const found = [];
|
|
132
|
+
let inFence = false;
|
|
133
|
+
for (const line of splitLines(normalized(text))) {
|
|
134
|
+
const stripped = line.trim();
|
|
135
|
+
if (stripped.startsWith(FENCE)) {
|
|
136
|
+
inFence = !inFence;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (inFence || !stripped || stripped.startsWith("#") || stripped.startsWith("|")) continue;
|
|
140
|
+
found.push(stripped.replace(BULLET, ""));
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {string} text @returns {string[]} */
|
|
146
|
+
export function sentencesOf(text) {
|
|
147
|
+
/** @type {string[]} */
|
|
148
|
+
const found = [];
|
|
149
|
+
for (const paragraph of paragraphsOf(text)) {
|
|
150
|
+
for (const sentence of splitSentences(paragraph)) {
|
|
151
|
+
if (HANGUL.test(sentence.text) && TERMINAL.test(sentence.text)) found.push(sentence.text.replace(SPACE, " ").trim());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @param {number} value @param {number[]} out */
|
|
158
|
+
function pushVarint(value, out) {
|
|
159
|
+
while (true) {
|
|
160
|
+
const byte = value & 0x7f;
|
|
161
|
+
value = Math.floor(value / 128);
|
|
162
|
+
if (value) out.push(byte | 0x80);
|
|
163
|
+
else {
|
|
164
|
+
out.push(byte);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {Uint8Array} data @returns {number[]} */
|
|
171
|
+
export function decodeVarints(data) {
|
|
172
|
+
/** @type {number[]} */
|
|
173
|
+
const values = [];
|
|
174
|
+
let value = 0;
|
|
175
|
+
let scale = 1;
|
|
176
|
+
for (const byte of data) {
|
|
177
|
+
value += (byte & 0x7f) * scale;
|
|
178
|
+
if (byte & 0x80) scale *= 128;
|
|
179
|
+
else {
|
|
180
|
+
values.push(value);
|
|
181
|
+
value = 0;
|
|
182
|
+
scale = 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return values;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 폴더 (하위 포함) 의 txt 와 md 는 파일 하나가 문서 하나, jsonl 은 줄 하나가 문서 하나. 폴더 기준 posix 경로의 코드 포인트
|
|
190
|
+
* 순이라 파이썬 readDocuments 와 같은 차례다. 큰 jsonl 을 통째로 들지 않게 generator 다.
|
|
191
|
+
* @param {string} folder @returns {Generator<[string, string]>}
|
|
192
|
+
*/
|
|
193
|
+
export function* readDocuments(folder) {
|
|
194
|
+
/** @type {string[]} */
|
|
195
|
+
const paths = [];
|
|
196
|
+
const walk = (/** @type {string} */ dir) => {
|
|
197
|
+
for (const name of readdirSync(dir)) {
|
|
198
|
+
const path = join(dir, name);
|
|
199
|
+
const suffix = extname(name).toLowerCase();
|
|
200
|
+
if (statSync(path).isDirectory()) walk(path);
|
|
201
|
+
else if (TEXT_SUFFIXES.has(suffix) || suffix === JSONL_SUFFIX) paths.push(path);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
walk(folder);
|
|
205
|
+
const posix = (/** @type {string} */ path) => relative(folder, path).split(sep).join("/");
|
|
206
|
+
paths.sort((a, b) => compareCodePoints(posix(a), posix(b)));
|
|
207
|
+
for (const path of paths) {
|
|
208
|
+
if (extname(path).toLowerCase() === JSONL_SUFFIX) {
|
|
209
|
+
yield* readJsonl(path);
|
|
210
|
+
} else {
|
|
211
|
+
yield [basename(path, extname(path)), readFileSync(path, "utf-8")];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** jsonl 을 줄 단위로 읽는다. 위키백과처럼 1 GB 가 넘는 파일을 한 번에 들지 않는다. @param {string} path @returns {Generator<[string, string]>} */
|
|
217
|
+
function* readJsonl(path) {
|
|
218
|
+
const fd = openSync(path, "r");
|
|
219
|
+
const chunk = Buffer.alloc(1 << 20);
|
|
220
|
+
let rest = Buffer.alloc(0);
|
|
221
|
+
try {
|
|
222
|
+
while (true) {
|
|
223
|
+
const read = readSync(fd, chunk, 0, chunk.length, null);
|
|
224
|
+
if (!read) break;
|
|
225
|
+
let data = Buffer.concat([rest, chunk.subarray(0, read)]);
|
|
226
|
+
let start = 0;
|
|
227
|
+
while (true) {
|
|
228
|
+
const end = data.indexOf(0x0a, start);
|
|
229
|
+
if (end < 0) break;
|
|
230
|
+
const line = data.subarray(start, end).toString("utf-8");
|
|
231
|
+
start = end + 1;
|
|
232
|
+
if (line.trim()) {
|
|
233
|
+
const record = JSON.parse(line);
|
|
234
|
+
yield [String(record.source), String(record.text)];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
rest = Buffer.from(data.subarray(start));
|
|
238
|
+
}
|
|
239
|
+
const line = rest.toString("utf-8");
|
|
240
|
+
if (line.trim()) {
|
|
241
|
+
const record = JSON.parse(line);
|
|
242
|
+
yield [String(record.source), String(record.text)];
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
closeSync(fd);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {number[]} values */
|
|
250
|
+
function littleEndian(values) {
|
|
251
|
+
const bytes = Buffer.alloc(values.length * 4);
|
|
252
|
+
values.forEach((value, index) => bytes.writeUInt32LE(value, index * 4));
|
|
253
|
+
return bytes;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 조각 하나. 토큰 순 terms 줄과 (절대 문장 번호, 빈도) varint 덩어리. 뜻은 파이썬 writeShard 가 소유한다.
|
|
258
|
+
* @param {string} work @param {number} index @param {Map<string, number[]>} postingIds @param {Map<string, number[]>} postingTfs
|
|
259
|
+
*/
|
|
260
|
+
function writeShard(work, index, postingIds, postingTfs) {
|
|
261
|
+
const terms = [...postingIds.keys()].sort(compareCodePoints);
|
|
262
|
+
/** @type {string[]} */
|
|
263
|
+
const lines = [];
|
|
264
|
+
/** @type {Buffer[]} */
|
|
265
|
+
const chunks = [];
|
|
266
|
+
let offset = 0;
|
|
267
|
+
for (const term of terms) {
|
|
268
|
+
const ids = /** @type {number[]} */ (postingIds.get(term));
|
|
269
|
+
const tfs = /** @type {number[]} */ (postingTfs.get(term));
|
|
270
|
+
/** @type {number[]} */
|
|
271
|
+
const bytes = [];
|
|
272
|
+
ids.forEach((sentenceId, at) => {
|
|
273
|
+
pushVarint(sentenceId, bytes);
|
|
274
|
+
pushVarint(tfs[at], bytes);
|
|
275
|
+
});
|
|
276
|
+
chunks.push(Buffer.from(bytes));
|
|
277
|
+
lines.push(`${term}\t${ids.length}\t${offset}\t${bytes.length}\n`);
|
|
278
|
+
offset += bytes.length;
|
|
279
|
+
}
|
|
280
|
+
writeFileSync(join(work, `shard${index}.bin`), Buffer.concat(chunks));
|
|
281
|
+
const path = join(work, `shard${index}.tsv`);
|
|
282
|
+
writeFileSync(path, lines.join(""), "utf-8");
|
|
283
|
+
return path;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 조각의 terms 줄을 차례로 주는 읽개. @param {string} path */
|
|
287
|
+
function shardLines(path) {
|
|
288
|
+
const lines = splitLines(readFileSync(path, "utf-8"));
|
|
289
|
+
let at = 0;
|
|
290
|
+
return () => {
|
|
291
|
+
if (at >= lines.length) return null;
|
|
292
|
+
const [term, df, offset, size] = lines[at++].split("\t");
|
|
293
|
+
return { term, df: Number(df), offset: Number(offset), size: Number(size) };
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 조각들을 토큰 순으로 병합해 postings.bin, terms.tsv, termOffsets.bin 을 쓴다. 뜻은 파이썬 mergeShards 가 소유한다.
|
|
299
|
+
* @param {string} work @param {string[]} shards @param {string} target @returns {number}
|
|
300
|
+
*/
|
|
301
|
+
function mergeShards(work, shards, target) {
|
|
302
|
+
const readers = shards.map((path) => shardLines(path));
|
|
303
|
+
const dataFds = shards.map((path) => openSync(join(work, basename(path).replace(".tsv", ".bin")), "r"));
|
|
304
|
+
/** @type {({ term: string, df: number, offset: number, size: number, shard: number } | null)[]} */
|
|
305
|
+
const heads = readers.map((next, shard) => {
|
|
306
|
+
const line = next();
|
|
307
|
+
return line ? { ...line, shard } : null;
|
|
308
|
+
});
|
|
309
|
+
const postingsFd = openSync(join(target, "postings.bin"), "w");
|
|
310
|
+
const termsFd = openSync(join(target, "terms.tsv"), "w");
|
|
311
|
+
/** @type {Buffer[]} */
|
|
312
|
+
const termOffsets = [];
|
|
313
|
+
let termPosition = 0;
|
|
314
|
+
let postingPosition = 0;
|
|
315
|
+
let termCount = 0;
|
|
316
|
+
try {
|
|
317
|
+
while (true) {
|
|
318
|
+
let term = null;
|
|
319
|
+
for (const head of heads) {
|
|
320
|
+
if (head && (term === null || compareCodePoints(head.term, term) < 0)) term = head.term;
|
|
321
|
+
}
|
|
322
|
+
if (term === null) break;
|
|
323
|
+
/** @type {number[]} */
|
|
324
|
+
const bytes = [];
|
|
325
|
+
let previous = 0;
|
|
326
|
+
let total = 0;
|
|
327
|
+
for (let shard = 0; shard < heads.length; shard++) {
|
|
328
|
+
const head = heads[shard];
|
|
329
|
+
if (!head || head.term !== term) continue;
|
|
330
|
+
const buffer = Buffer.alloc(head.size);
|
|
331
|
+
readSync(dataFds[shard], buffer, 0, head.size, head.offset);
|
|
332
|
+
const values = decodeVarints(buffer);
|
|
333
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
334
|
+
pushVarint(values[index] - previous, bytes);
|
|
335
|
+
pushVarint(values[index + 1], bytes);
|
|
336
|
+
previous = values[index];
|
|
337
|
+
}
|
|
338
|
+
total += head.df;
|
|
339
|
+
const line = readers[shard]();
|
|
340
|
+
heads[shard] = line ? { ...line, shard } : null;
|
|
341
|
+
}
|
|
342
|
+
const chunk = Buffer.from(bytes);
|
|
343
|
+
writeSync(postingsFd, chunk);
|
|
344
|
+
const line = Buffer.from(`${term}\t${total}\t${postingPosition}\t${chunk.length}\n`, "utf-8");
|
|
345
|
+
postingPosition += chunk.length;
|
|
346
|
+
const offsetBytes = Buffer.alloc(8);
|
|
347
|
+
offsetBytes.writeBigUInt64LE(BigInt(termPosition), 0);
|
|
348
|
+
termOffsets.push(offsetBytes);
|
|
349
|
+
writeSync(termsFd, line);
|
|
350
|
+
termPosition += line.length;
|
|
351
|
+
termCount += 1;
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
closeSync(postingsFd);
|
|
355
|
+
closeSync(termsFd);
|
|
356
|
+
for (const fd of dataFds) closeSync(fd);
|
|
357
|
+
}
|
|
358
|
+
writeFileSync(join(target, "termOffsets.bin"), Buffer.concat(termOffsets));
|
|
359
|
+
return termCount;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* [출처, 글] 들로 색인을 만들어 root/<kind>/ 에 쓴다. 문서는 한 번만 읽는다.
|
|
364
|
+
* @param {string} kind @param {Iterable<[string, string]>} documents @param {string} root
|
|
365
|
+
* @param {number} [shardSentences] 조각 크기. 어떤 값이든 결과 바이트는 같다
|
|
366
|
+
* @returns {{ documents: number, sentences: number, terms: number }}
|
|
367
|
+
*/
|
|
368
|
+
export function buildIndex(kind, documents, root, shardSentences = SHARD_SENTENCES) {
|
|
369
|
+
const target = join(root, kind);
|
|
370
|
+
mkdirSync(target, { recursive: true });
|
|
371
|
+
const work = join(target, "build");
|
|
372
|
+
mkdirSync(work, { recursive: true });
|
|
373
|
+
/** @type {Map<bigint, number>} */
|
|
374
|
+
const seen = new Map();
|
|
375
|
+
/** @type {number[]} */
|
|
376
|
+
const lengths = [];
|
|
377
|
+
/** @type {number[]} */
|
|
378
|
+
const documentCounts = [];
|
|
379
|
+
/** @type {number[]} */
|
|
380
|
+
const lastDocument = [];
|
|
381
|
+
/** @type {Map<string, number[]>} */
|
|
382
|
+
let postingIds = new Map();
|
|
383
|
+
/** @type {Map<string, number[]>} */
|
|
384
|
+
let postingTfs = new Map();
|
|
385
|
+
/** @type {string[]} */
|
|
386
|
+
const shards = [];
|
|
387
|
+
let documentCount = 0;
|
|
388
|
+
let tokenTotal = 0;
|
|
389
|
+
let position = 0;
|
|
390
|
+
const sentencesFd = openSync(join(target, "sentences.tsv"), "w");
|
|
391
|
+
const offsetsFd = openSync(join(target, "offsets.bin"), "w");
|
|
392
|
+
try {
|
|
393
|
+
for (const [source, text] of documents) {
|
|
394
|
+
documentCount += 1;
|
|
395
|
+
for (const sentence of sentencesOf(text)) {
|
|
396
|
+
const digest = keyHash(sentenceKey(sentence));
|
|
397
|
+
const known = seen.get(digest);
|
|
398
|
+
if (known !== undefined) {
|
|
399
|
+
if (lastDocument[known] !== documentCount) {
|
|
400
|
+
documentCounts[known] += 1;
|
|
401
|
+
lastDocument[known] = documentCount;
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const tokens = indexTokens(sentence);
|
|
406
|
+
if (!tokens.length) continue;
|
|
407
|
+
const sentenceId = lengths.length;
|
|
408
|
+
seen.set(digest, sentenceId);
|
|
409
|
+
const line = Buffer.from(`${source}\t${sentence}\n`, "utf-8");
|
|
410
|
+
const offsetBytes = Buffer.alloc(8);
|
|
411
|
+
offsetBytes.writeBigUInt64LE(BigInt(position), 0);
|
|
412
|
+
writeSync(offsetsFd, offsetBytes);
|
|
413
|
+
writeSync(sentencesFd, line);
|
|
414
|
+
position += line.length;
|
|
415
|
+
lengths.push(tokens.length);
|
|
416
|
+
documentCounts.push(1);
|
|
417
|
+
lastDocument.push(documentCount);
|
|
418
|
+
tokenTotal += tokens.length;
|
|
419
|
+
/** @type {Map<string, number>} */
|
|
420
|
+
const counts = new Map();
|
|
421
|
+
for (const token of tokens) counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
422
|
+
for (const [token, count] of counts) {
|
|
423
|
+
let ids = postingIds.get(token);
|
|
424
|
+
if (!ids) {
|
|
425
|
+
ids = [];
|
|
426
|
+
postingIds.set(token, ids);
|
|
427
|
+
postingTfs.set(token, []);
|
|
428
|
+
}
|
|
429
|
+
ids.push(sentenceId);
|
|
430
|
+
/** @type {number[]} */ (postingTfs.get(token)).push(Math.min(count, 65535));
|
|
431
|
+
}
|
|
432
|
+
if (lengths.length % shardSentences === 0) {
|
|
433
|
+
shards.push(writeShard(work, shards.length, postingIds, postingTfs));
|
|
434
|
+
postingIds = new Map();
|
|
435
|
+
postingTfs = new Map();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} finally {
|
|
440
|
+
closeSync(sentencesFd);
|
|
441
|
+
closeSync(offsetsFd);
|
|
442
|
+
}
|
|
443
|
+
if (postingIds.size || !shards.length) shards.push(writeShard(work, shards.length, postingIds, postingTfs));
|
|
444
|
+
const termCount = mergeShards(work, shards, target);
|
|
445
|
+
for (const name of readdirSync(work)) unlinkSync(join(work, name));
|
|
446
|
+
rmdirSync(work);
|
|
447
|
+
writeFileSync(join(target, "lengths.bin"), littleEndian(lengths));
|
|
448
|
+
writeFileSync(join(target, "documents.bin"), littleEndian(documentCounts));
|
|
449
|
+
const meta = { format: FORMAT, kind, documents: documentCount, sentences: lengths.length, tokens: tokenTotal, terms: termCount };
|
|
450
|
+
writeFileSync(join(target, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf-8");
|
|
451
|
+
return { documents: documentCount, sentences: lengths.length, terms: termCount };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** @typedef {{ text: string, documents: number, source: string, score: number }} Hit */
|
|
455
|
+
/** @typedef {{ term: string, sampled: number, predicates: [string, number][], following: [string, number][], preceding: [string, number][] }} Collocation */
|
|
456
|
+
|
|
457
|
+
/** @type {Set<string> | null} */
|
|
458
|
+
let stopCache = null;
|
|
459
|
+
function collocationStops() {
|
|
460
|
+
if (!stopCache) stopCache = new Set(loadLines("collocationStops.txt"));
|
|
461
|
+
return stopCache;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 용언 어절의 어간. 뜻은 파이썬 predicateStem 이 소유한다. 용언이 아니면 null, 어간이 한 글자면 "".
|
|
466
|
+
* @param {string} core @returns {string | null}
|
|
467
|
+
*/
|
|
468
|
+
export function predicateStem(core) {
|
|
469
|
+
let tail = tailOf(core, "verbTails.txt");
|
|
470
|
+
if (tail === null) tail = PREDICATE_TAILS.find((piece) => core.endsWith(piece) && core.length > piece.length) ?? null;
|
|
471
|
+
if (tail === null) return null;
|
|
472
|
+
let stem = core.slice(0, -tail.length);
|
|
473
|
+
let stripped = false;
|
|
474
|
+
for (const piece of PREDICATE_STEMS) {
|
|
475
|
+
if (stem.endsWith(piece) && stem.length > piece.length) {
|
|
476
|
+
stem = stem.slice(0, -piece.length);
|
|
477
|
+
stripped = true;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// 한 글자 꼬리는 명사의 끝 글자와 갈리지 않는다 (기업회계기준서). 하/되 가 앞에 있을 때만 용언이다.
|
|
482
|
+
if ([...tail].length === 1 && !stripped) return null;
|
|
483
|
+
return [...stem].length >= 2 && HANGUL_WORD.test(stem) ? stem : "";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 이웃 어절을 [부류, 낱말] 로. 뜻은 파이썬 neighbourOf 가 소유한다. @param {string} core @returns {["predicate" | "noun", string] | null} */
|
|
487
|
+
export function neighbourOf(core) {
|
|
488
|
+
if (collocationStops().has(core) || nonNouns().has(core)) return null;
|
|
489
|
+
const stem = predicateStem(core);
|
|
490
|
+
if (stem !== null) return stem ? ["predicate", stem] : null;
|
|
491
|
+
if ([...core].length >= 2 && HANGUL_WORD.test(core) && isBareNoun(core)) return ["noun", core];
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** @param {Map<string, Set<string>>} found @returns {[string, number][]} */
|
|
496
|
+
function topCounts(found) {
|
|
497
|
+
const ranked = [...found].map(([term, sources]) => /** @type {[string, number]} */ ([term, sources.size]));
|
|
498
|
+
ranked.sort((a, b) => b[1] - a[1] || compareCodePoints(a[0], b[0]));
|
|
499
|
+
return ranked.slice(0, COLLOCATION_TOP);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export class UsageIndex {
|
|
503
|
+
/** @param {string} folder */
|
|
504
|
+
constructor(folder) {
|
|
505
|
+
this.folder = folder;
|
|
506
|
+
this.meta = JSON.parse(readFileSync(join(folder, "meta.json"), "utf-8"));
|
|
507
|
+
if (this.meta.format !== FORMAT) throw new Error(`${folder} 의 색인 format 이 ${this.meta.format} 이다. ${FORMAT} 으로 다시 만든다`);
|
|
508
|
+
const lengthBytes = readFileSync(join(folder, "lengths.bin"));
|
|
509
|
+
this.lengths = new Uint32Array(lengthBytes.buffer, lengthBytes.byteOffset, lengthBytes.length / 4);
|
|
510
|
+
const documentBytes = readFileSync(join(folder, "documents.bin"));
|
|
511
|
+
this.documentCounts = new Uint32Array(documentBytes.buffer, documentBytes.byteOffset, documentBytes.length / 4);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
get kind() {
|
|
515
|
+
return /** @type {string} */ (this.meta.kind);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
get documents() {
|
|
519
|
+
return /** @type {number} */ (this.meta.documents);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
get sentences() {
|
|
523
|
+
return /** @type {number} */ (this.meta.sentences);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** @param {string} name @param {number} offset @param {number} size */
|
|
527
|
+
readAt(name, offset, size) {
|
|
528
|
+
const fd = openSync(join(this.folder, name), "r");
|
|
529
|
+
try {
|
|
530
|
+
const buffer = Buffer.alloc(size);
|
|
531
|
+
const read = readSync(fd, buffer, 0, size, offset);
|
|
532
|
+
return buffer.subarray(0, read);
|
|
533
|
+
} finally {
|
|
534
|
+
closeSync(fd);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** offsetsName 의 index 번째 위치에서 name 의 한 줄. @param {string} name @param {string} offsetsName @param {number} index */
|
|
539
|
+
lineAt(name, offsetsName, index) {
|
|
540
|
+
const offset = Number(this.readAt(offsetsName, index * 8, 8).readBigUInt64LE(0));
|
|
541
|
+
const size = statSync(join(this.folder, name)).size;
|
|
542
|
+
let span = 4096;
|
|
543
|
+
let chunk = this.readAt(name, offset, Math.min(size - offset, span));
|
|
544
|
+
while (chunk.indexOf(0x0a) < 0 && offset + span < size) {
|
|
545
|
+
span *= 4;
|
|
546
|
+
chunk = this.readAt(name, offset, Math.min(size - offset, span));
|
|
547
|
+
}
|
|
548
|
+
const end = chunk.indexOf(0x0a);
|
|
549
|
+
return chunk.subarray(0, end < 0 ? chunk.length : end).toString("utf-8");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* [문서 빈도, postings 위치, 바이트 수]. 토큰 표를 코드 포인트 순으로 이분 탐색한다. 뜻은 파이썬 lookup 이 소유한다.
|
|
554
|
+
* @param {string} term @returns {[number, number, number] | null}
|
|
555
|
+
*/
|
|
556
|
+
lookup(term) {
|
|
557
|
+
let low = 0;
|
|
558
|
+
let high = Number(this.meta.terms);
|
|
559
|
+
while (low < high) {
|
|
560
|
+
const middle = Math.floor((low + high) / 2);
|
|
561
|
+
const [found, df, offset, size] = this.lineAt("terms.tsv", "termOffsets.bin", middle).split("\t");
|
|
562
|
+
const order = compareCodePoints(found, term);
|
|
563
|
+
if (order < 0) low = middle + 1;
|
|
564
|
+
else if (order > 0) high = middle;
|
|
565
|
+
else return [Number(df), Number(offset), Number(size)];
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** @param {string} term @returns {[number, number][]} */
|
|
571
|
+
postings(term) {
|
|
572
|
+
const found = this.lookup(term);
|
|
573
|
+
return found ? this.postingsAt(found[1], found[2]) : [];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** @param {number} offset @param {number} size @returns {[number, number][]} */
|
|
577
|
+
postingsAt(offset, size) {
|
|
578
|
+
const values = decodeVarints(this.readAt("postings.bin", offset, size));
|
|
579
|
+
/** @type {[number, number][]} */
|
|
580
|
+
const result = [];
|
|
581
|
+
let sentenceId = 0;
|
|
582
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
583
|
+
sentenceId += values[index];
|
|
584
|
+
result.push([sentenceId, values[index + 1]]);
|
|
585
|
+
}
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** (문서 수, 출처, 글). @param {number} sentenceId @returns {[number, string, string]} */
|
|
590
|
+
sentence(sentenceId) {
|
|
591
|
+
const line = this.lineAt("sentences.tsv", "offsets.bin", sentenceId);
|
|
592
|
+
const first = line.indexOf("\t");
|
|
593
|
+
return [this.documentCounts[sentenceId], line.slice(0, first), line.slice(first + 1)];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** @param {string} query @param {number} limit @returns {Hit[]} */
|
|
597
|
+
search(query, limit) {
|
|
598
|
+
const tokens = [...new Set(indexTokens(query))].sort(compareCodePoints);
|
|
599
|
+
if (!tokens.length || !this.sentences) return [];
|
|
600
|
+
const averageLength = this.meta.tokens / this.sentences;
|
|
601
|
+
/** @type {Map<number, number>} */
|
|
602
|
+
const scores = new Map();
|
|
603
|
+
for (const token of tokens) {
|
|
604
|
+
const found = this.lookup(token);
|
|
605
|
+
if (!found || (this.sentences >= MIN_FILTER_SENTENCES && found[0] > this.sentences * MAX_DF_SHARE)) continue;
|
|
606
|
+
const [df, offset, size] = found;
|
|
607
|
+
const idf = Math.log(1 + (this.sentences - df + 0.5) / (df + 0.5));
|
|
608
|
+
for (const [sentenceId, tf] of this.postingsAt(offset, size)) {
|
|
609
|
+
const norm = tf + K1 * (1 - B + (B * this.lengths[sentenceId]) / averageLength);
|
|
610
|
+
scores.set(sentenceId, (scores.get(sentenceId) ?? 0) + (idf * (tf * (K1 + 1))) / norm);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const ranked = [...scores].map(([sentenceId, score]) => [Math.floor(score * SCORE_SCALE), sentenceId]);
|
|
614
|
+
ranked.sort((a, b) => b[0] - a[0] || a[1] - b[1]);
|
|
615
|
+
return ranked.slice(0, limit).map(([score, sentenceId]) => {
|
|
616
|
+
const [documents, source, text] = this.sentence(sentenceId);
|
|
617
|
+
return { text, documents, source, score };
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 그 낱말 바로 앞뒤에 오는 말. 뜻은 파이썬 collocations 가 소유한다. @param {string} term @returns {Collocation | null} */
|
|
622
|
+
collocations(term) {
|
|
623
|
+
const found = this.lookup(term);
|
|
624
|
+
if (!found) return null;
|
|
625
|
+
const postings = this.postingsAt(found[1], found[2]);
|
|
626
|
+
const stride = Math.max(1, Math.floor(postings.length / COLLOCATION_SAMPLE));
|
|
627
|
+
const sample = postings.filter((_, index) => index % stride === 0).slice(0, COLLOCATION_SAMPLE);
|
|
628
|
+
/** @type {Map<string, Set<string>>} */
|
|
629
|
+
const predicates = new Map();
|
|
630
|
+
/** @type {Map<string, Set<string>>} */
|
|
631
|
+
const following = new Map();
|
|
632
|
+
/** @type {Map<string, Set<string>>} */
|
|
633
|
+
const preceding = new Map();
|
|
634
|
+
const add = (/** @type {Map<string, Set<string>>} */ bucket, /** @type {string} */ key, /** @type {string} */ source) => {
|
|
635
|
+
let sources = bucket.get(key);
|
|
636
|
+
if (!sources) {
|
|
637
|
+
sources = new Set();
|
|
638
|
+
bucket.set(key, sources);
|
|
639
|
+
}
|
|
640
|
+
sources.add(source);
|
|
641
|
+
};
|
|
642
|
+
for (const [sentenceId] of sample) {
|
|
643
|
+
const [, source, text] = this.sentence(sentenceId);
|
|
644
|
+
const tokens = words(text);
|
|
645
|
+
tokens.forEach((word, index) => {
|
|
646
|
+
if (word.particle || stripJosa(word.core) !== term) return;
|
|
647
|
+
if (index + 1 < tokens.length && !word.endsClause) {
|
|
648
|
+
const after = tokens[index + 1];
|
|
649
|
+
const found = after.particle ? null : neighbourOf(after.core);
|
|
650
|
+
if (found !== null) add(found[0] === "predicate" ? predicates : following, found[1], source);
|
|
651
|
+
}
|
|
652
|
+
if (index > 0) {
|
|
653
|
+
const before = tokens[index - 1];
|
|
654
|
+
const found = before.endsClause || before.particle ? null : neighbourOf(before.core);
|
|
655
|
+
if (found !== null && found[0] === "noun") add(preceding, found[1], source);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return { term, sampled: sample.length, predicates: topCounts(predicates), following: topCounts(following), preceding: topCounts(preceding) };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** @param {string} kind @param {string} root @returns {UsageIndex | null} */
|
|
664
|
+
export function loadIndex(kind, root) {
|
|
665
|
+
const folder = join(root, kind);
|
|
666
|
+
if (!FILES.every((name) => existsSync(join(folder, name)))) return null;
|
|
667
|
+
return new UsageIndex(folder);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** root 아래 색인 폴더의 meta. 종류 이름 순. @param {string} root @returns {Record<string, unknown>[]} */
|
|
671
|
+
export function listIndexes(root) {
|
|
672
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) return [];
|
|
673
|
+
const found = [];
|
|
674
|
+
for (const name of readdirSync(root).sort(compareCodePoints)) {
|
|
675
|
+
const folder = join(root, name);
|
|
676
|
+
if (statSync(folder).isDirectory() && FILES.every((file) => existsSync(join(folder, file)))) {
|
|
677
|
+
found.push(JSON.parse(readFileSync(join(folder, "meta.json"), "utf-8")));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return found;
|
|
681
|
+
}
|