ocr-foundry 0.2.0__py3-none-any.whl
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.
- khocr_gen/__init__.py +11 -0
- khocr_gen/_pil_compat.py +25 -0
- khocr_gen/_rust_accel.py +204 -0
- khocr_gen/augmentation.py +1193 -0
- khocr_gen/benchmark.py +888 -0
- khocr_gen/benchmark_cmd.py +103 -0
- khocr_gen/cli.py +246 -0
- khocr_gen/combine.py +214 -0
- khocr_gen/combine_cmd.py +125 -0
- khocr_gen/config.py +1474 -0
- khocr_gen/config_loader.py +157 -0
- khocr_gen/corpus.py +621 -0
- khocr_gen/data_generator.py +1160 -0
- khocr_gen/errors.py +33 -0
- khocr_gen/evaluate_cmd.py +50 -0
- khocr_gen/evaluation.py +259 -0
- khocr_gen/fonts.py +316 -0
- khocr_gen/generate.py +227 -0
- khocr_gen/layout.py +1028 -0
- khocr_gen/layout_cmd.py +371 -0
- khocr_gen/layout_degradation.py +128 -0
- khocr_gen/layout_mesh.py +200 -0
- khocr_gen/layout_shaping.py +264 -0
- khocr_gen/layout_transform.py +238 -0
- khocr_gen/layout_warp.py +473 -0
- khocr_gen/line_height.py +127 -0
- khocr_gen/lmdb_pack.py +132 -0
- khocr_gen/logging.py +25 -0
- khocr_gen/normalizer.py +221 -0
- khocr_gen/parallel.py +366 -0
- khocr_gen/provenance.py +328 -0
- khocr_gen/rendering.py +1607 -0
- khocr_gen/text_support.py +429 -0
- khocr_gen/verify.py +471 -0
- khocr_gen/viewer.py +172 -0
- ocr_foundry-0.2.0.dist-info/METADATA +174 -0
- ocr_foundry-0.2.0.dist-info/RECORD +40 -0
- ocr_foundry-0.2.0.dist-info/WHEEL +4 -0
- ocr_foundry-0.2.0.dist-info/entry_points.txt +3 -0
- ocr_foundry-0.2.0.dist-info/licenses/LICENSE +21 -0
khocr_gen/corpus.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Corpus loading, filtering, and counting.
|
|
2
|
+
|
|
3
|
+
Handles UTF-8 plain-text corpora and JSONL corpus manifests: streams lines,
|
|
4
|
+
applies length filters after normalization, and supports parallel count-only
|
|
5
|
+
scans.
|
|
6
|
+
|
|
7
|
+
JSONL manifests (one JSON object per line) are auto-detected by file extension
|
|
8
|
+
(``.jsonl``). Each object must have a ``"text"`` key. Optional keys:
|
|
9
|
+
``"source_id"``, ``"source_group"`` (alias ``"doc_id"``), ``"language"``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json as _json
|
|
15
|
+
import logging
|
|
16
|
+
import multiprocessing as mp
|
|
17
|
+
import os
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from .errors import InputValidationError
|
|
23
|
+
from .normalizer import NormalizerConfig, normalize
|
|
24
|
+
from .text_support import line_separator_error
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Generator
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger("khocr_gen.corpus")
|
|
30
|
+
|
|
31
|
+
_AUTO_PARALLEL_MIN_BYTES = 16 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
_SKIP = 0
|
|
34
|
+
_TOO_SHORT = 1
|
|
35
|
+
_TOO_LONG = 2
|
|
36
|
+
_PASS = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# CorpusItem: str subclass carrying optional provenance metadata
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CorpusItem(str):
|
|
45
|
+
"""A text string with optional provenance metadata from a JSONL manifest.
|
|
46
|
+
|
|
47
|
+
Behaves exactly like ``str`` in every context that expects a string value.
|
|
48
|
+
The extra attributes survive pickling (needed for multiprocessing workers)
|
|
49
|
+
because ``str`` subclasses keep a ``__dict__``.
|
|
50
|
+
"""
|
|
51
|
+
source_id: str | None
|
|
52
|
+
source_group: str | None
|
|
53
|
+
language: str | None
|
|
54
|
+
script: str | None
|
|
55
|
+
domain: str | None
|
|
56
|
+
source_uri: str | None
|
|
57
|
+
license: str | None
|
|
58
|
+
retrieved_at: str | None
|
|
59
|
+
source_sha256: str | None
|
|
60
|
+
normalization_policy: str | None
|
|
61
|
+
consent_or_restrictions: str | None
|
|
62
|
+
raw_text: str | None
|
|
63
|
+
|
|
64
|
+
def __new__(
|
|
65
|
+
cls,
|
|
66
|
+
text: str,
|
|
67
|
+
source_id: str | None = None,
|
|
68
|
+
source_group: str | None = None,
|
|
69
|
+
language: str | None = None,
|
|
70
|
+
script: str | None = None,
|
|
71
|
+
domain: str | None = None,
|
|
72
|
+
source_uri: str | None = None,
|
|
73
|
+
license: str | None = None,
|
|
74
|
+
retrieved_at: str | None = None,
|
|
75
|
+
source_sha256: str | None = None,
|
|
76
|
+
normalization_policy: str | None = None,
|
|
77
|
+
consent_or_restrictions: str | None = None,
|
|
78
|
+
raw_text: str | None = None,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> CorpusItem:
|
|
81
|
+
obj = super().__new__(cls, text)
|
|
82
|
+
obj.source_id = source_id
|
|
83
|
+
obj.source_group = source_group
|
|
84
|
+
obj.language = language
|
|
85
|
+
obj.script = script
|
|
86
|
+
obj.domain = domain
|
|
87
|
+
obj.source_uri = source_uri
|
|
88
|
+
obj.license = license
|
|
89
|
+
obj.retrieved_at = retrieved_at
|
|
90
|
+
obj.source_sha256 = source_sha256
|
|
91
|
+
obj.normalization_policy = normalization_policy
|
|
92
|
+
obj.consent_or_restrictions = consent_or_restrictions
|
|
93
|
+
obj.raw_text = raw_text
|
|
94
|
+
return obj
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
parts = [repr(str(self))]
|
|
98
|
+
for field_name in (
|
|
99
|
+
"source_id",
|
|
100
|
+
"source_group",
|
|
101
|
+
"language",
|
|
102
|
+
"script",
|
|
103
|
+
"domain",
|
|
104
|
+
"source_uri",
|
|
105
|
+
"license",
|
|
106
|
+
"retrieved_at",
|
|
107
|
+
"source_sha256",
|
|
108
|
+
"normalization_policy",
|
|
109
|
+
"consent_or_restrictions",
|
|
110
|
+
"raw_text",
|
|
111
|
+
):
|
|
112
|
+
val = getattr(self, field_name, None)
|
|
113
|
+
if val is not None:
|
|
114
|
+
parts.append(f"{field_name}={val!r}")
|
|
115
|
+
return f"CorpusItem({', '.join(parts)})"
|
|
116
|
+
|
|
117
|
+
def _is_jsonl(path: str | Path) -> bool:
|
|
118
|
+
"""True when *path* has a ``.jsonl`` extension (case-insensitive)."""
|
|
119
|
+
return str(path).lower().endswith(".jsonl")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_jsonl_record(
|
|
123
|
+
raw_line: str,
|
|
124
|
+
line_number: int,
|
|
125
|
+
*,
|
|
126
|
+
strict: bool = True,
|
|
127
|
+
) -> dict[str, Any] | None:
|
|
128
|
+
"""Parse one JSONL line into a dict with at least a ``"text"`` key.
|
|
129
|
+
|
|
130
|
+
Returns *None* for blank/empty lines. Raises on malformed JSON or
|
|
131
|
+
missing ``"text"`` when *strict* is True; logs a warning and returns
|
|
132
|
+
*None* when *strict* is False.
|
|
133
|
+
"""
|
|
134
|
+
stripped = raw_line.strip()
|
|
135
|
+
if not stripped:
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
obj = _json.loads(stripped)
|
|
139
|
+
except _json.JSONDecodeError as exc:
|
|
140
|
+
msg = f"JSONL line {line_number}: invalid JSON: {exc}"
|
|
141
|
+
if strict:
|
|
142
|
+
raise InputValidationError(msg, code="jsonl_parse_error") from exc
|
|
143
|
+
_log.warning(msg)
|
|
144
|
+
return None
|
|
145
|
+
if not isinstance(obj, dict):
|
|
146
|
+
msg = f"JSONL line {line_number}: expected JSON object, got {type(obj).__name__}"
|
|
147
|
+
if strict:
|
|
148
|
+
raise InputValidationError(msg, code="jsonl_parse_error")
|
|
149
|
+
_log.warning(msg)
|
|
150
|
+
return None
|
|
151
|
+
if "text" not in obj:
|
|
152
|
+
msg = f"JSONL line {line_number}: missing required 'text' key"
|
|
153
|
+
if strict:
|
|
154
|
+
raise InputValidationError(msg, code="jsonl_parse_error")
|
|
155
|
+
_log.warning(msg)
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(obj["text"], str):
|
|
158
|
+
msg = f"JSONL line {line_number}: 'text' must be a string"
|
|
159
|
+
if strict:
|
|
160
|
+
raise InputValidationError(msg, code="jsonl_parse_error")
|
|
161
|
+
_log.warning(msg)
|
|
162
|
+
return None
|
|
163
|
+
# Accept doc_id as alias for source_group
|
|
164
|
+
if "doc_id" in obj and "source_group" not in obj:
|
|
165
|
+
obj["source_group"] = obj["doc_id"]
|
|
166
|
+
return obj
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _classify_jsonl_text(
|
|
170
|
+
text: str,
|
|
171
|
+
min_length: int,
|
|
172
|
+
max_length: int,
|
|
173
|
+
normalizer: NormalizerConfig | None = None,
|
|
174
|
+
) -> tuple[int, str | None]:
|
|
175
|
+
"""Classify a ``"text"`` value from a JSONL record through the same
|
|
176
|
+
normalization and length-filter pipeline as plain-text lines."""
|
|
177
|
+
return _classify_raw_line(text, min_length, max_length, normalizer)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _empty_stats() -> dict[str, int]:
|
|
181
|
+
return {"total": 0, "passing": 0, "too_short": 0, "too_long": 0}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _merge_stats(base: dict[str, int], update: dict[str, int]) -> dict[str, int]:
|
|
185
|
+
for key, value in update.items():
|
|
186
|
+
base[key] += value
|
|
187
|
+
return base
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _update_stats_for_status(stats: dict[str, int], status: int) -> None:
|
|
191
|
+
if status == _SKIP:
|
|
192
|
+
return
|
|
193
|
+
stats["total"] += 1
|
|
194
|
+
if status == _TOO_SHORT:
|
|
195
|
+
stats["too_short"] += 1
|
|
196
|
+
elif status == _TOO_LONG:
|
|
197
|
+
stats["too_long"] += 1
|
|
198
|
+
else:
|
|
199
|
+
stats["passing"] += 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _classify_raw_line(
|
|
203
|
+
raw_line: str,
|
|
204
|
+
min_length: int,
|
|
205
|
+
max_length: int,
|
|
206
|
+
normalizer: NormalizerConfig | None = None,
|
|
207
|
+
) -> tuple[int, str | None]:
|
|
208
|
+
# Zl/Zp survive strip() when embedded. Never accept them as one corpus line.
|
|
209
|
+
if line_separator_error(raw_line):
|
|
210
|
+
return _SKIP, None
|
|
211
|
+
|
|
212
|
+
raw = raw_line.strip()
|
|
213
|
+
if not raw:
|
|
214
|
+
return _SKIP, None
|
|
215
|
+
|
|
216
|
+
raw_len = len(raw)
|
|
217
|
+
if raw_len < min_length:
|
|
218
|
+
return _TOO_SHORT, None
|
|
219
|
+
if raw_len > max_length:
|
|
220
|
+
return _TOO_LONG, None
|
|
221
|
+
|
|
222
|
+
# Normalize (Khmer canonical reorder) before length check
|
|
223
|
+
line = normalize(raw, normalizer)
|
|
224
|
+
line_len = len(line)
|
|
225
|
+
if line_len < min_length:
|
|
226
|
+
return _TOO_SHORT, None
|
|
227
|
+
if line_len > max_length:
|
|
228
|
+
return _TOO_LONG, None
|
|
229
|
+
|
|
230
|
+
return _PASS, line
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _count_lines(
|
|
234
|
+
lines, min_length: int, max_length: int, normalizer: NormalizerConfig | None = None
|
|
235
|
+
) -> dict[str, int]:
|
|
236
|
+
stats = _empty_stats()
|
|
237
|
+
for raw_line in lines:
|
|
238
|
+
status, _ = _classify_raw_line(raw_line, min_length, max_length, normalizer)
|
|
239
|
+
_update_stats_for_status(stats, status)
|
|
240
|
+
return stats
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _count_file_chunk(
|
|
244
|
+
path_str: str,
|
|
245
|
+
start: int,
|
|
246
|
+
end: int,
|
|
247
|
+
min_length: int,
|
|
248
|
+
max_length: int,
|
|
249
|
+
normalizer: NormalizerConfig | None = None,
|
|
250
|
+
) -> dict[str, int]:
|
|
251
|
+
_UTF8_BOM = b"\xef\xbb\xbf"
|
|
252
|
+
with open(path_str, "rb") as fh:
|
|
253
|
+
if start != 0:
|
|
254
|
+
fh.seek(start - 1)
|
|
255
|
+
if fh.read(1) not in {b"\n", b"\r"}:
|
|
256
|
+
fh.readline()
|
|
257
|
+
else:
|
|
258
|
+
fh.seek(start)
|
|
259
|
+
maybe_bom = fh.read(len(_UTF8_BOM))
|
|
260
|
+
if not maybe_bom.startswith(_UTF8_BOM):
|
|
261
|
+
fh.seek(start)
|
|
262
|
+
|
|
263
|
+
stats = _empty_stats()
|
|
264
|
+
while True:
|
|
265
|
+
line_start = fh.tell()
|
|
266
|
+
if line_start >= end:
|
|
267
|
+
break
|
|
268
|
+
raw_line = fh.readline()
|
|
269
|
+
if not raw_line:
|
|
270
|
+
break
|
|
271
|
+
status, _ = _classify_raw_line(
|
|
272
|
+
raw_line.decode("utf-8", errors="replace"),
|
|
273
|
+
min_length=min_length,
|
|
274
|
+
max_length=max_length,
|
|
275
|
+
normalizer=normalizer,
|
|
276
|
+
)
|
|
277
|
+
_update_stats_for_status(stats, status)
|
|
278
|
+
return stats
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _iter_chunk_ranges(path: Path, workers: int):
|
|
282
|
+
file_size = path.stat().st_size
|
|
283
|
+
chunk_size = max(1, file_size // workers)
|
|
284
|
+
start = 0
|
|
285
|
+
for worker_index in range(workers):
|
|
286
|
+
end = file_size if worker_index == workers - 1 else start + chunk_size
|
|
287
|
+
yield start, end
|
|
288
|
+
start = end
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_worker_count(path: Path, workers: int) -> int:
|
|
292
|
+
requested = max(0, int(workers))
|
|
293
|
+
cpu_count = os.cpu_count() or 1
|
|
294
|
+
if requested == 1:
|
|
295
|
+
return 1
|
|
296
|
+
if requested > 1:
|
|
297
|
+
return min(requested, cpu_count)
|
|
298
|
+
if cpu_count <= 1:
|
|
299
|
+
return 1
|
|
300
|
+
try:
|
|
301
|
+
if path.stat().st_size < _AUTO_PARALLEL_MIN_BYTES:
|
|
302
|
+
return 1
|
|
303
|
+
except OSError:
|
|
304
|
+
return 1
|
|
305
|
+
return cpu_count
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _count_corpus_serial(
|
|
309
|
+
path: Path,
|
|
310
|
+
min_length: int,
|
|
311
|
+
max_length: int,
|
|
312
|
+
normalizer: NormalizerConfig | None = None,
|
|
313
|
+
) -> dict[str, int]:
|
|
314
|
+
stats = _empty_stats()
|
|
315
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
316
|
+
batch_stats = _count_lines(fh, min_length, max_length, normalizer)
|
|
317
|
+
_merge_stats(stats, batch_stats)
|
|
318
|
+
return stats
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def load_corpus(
|
|
322
|
+
path: str | Path,
|
|
323
|
+
min_length: int = 1,
|
|
324
|
+
max_length: int = 260,
|
|
325
|
+
max_lines: int = 0,
|
|
326
|
+
normalizer: NormalizerConfig | None = None,
|
|
327
|
+
) -> Generator[str | CorpusItem, None, None]:
|
|
328
|
+
"""Stream lines from a corpus file, applying filters.
|
|
329
|
+
|
|
330
|
+
Auto-detects JSONL format by file extension (``.jsonl``). For JSONL
|
|
331
|
+
files, each yielded value is a :class:`CorpusItem` carrying provenance
|
|
332
|
+
metadata. For plain-text files, plain ``str`` values are yielded
|
|
333
|
+
(backward compatible).
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
path: Path to corpus text file (UTF-8, one string per line) or a
|
|
337
|
+
``.jsonl`` manifest.
|
|
338
|
+
min_length: Skip lines shorter than this many characters (after normalization).
|
|
339
|
+
max_length: Skip lines longer than this many characters (after normalization).
|
|
340
|
+
max_lines: Stop after yielding this many lines (0 = unlimited).
|
|
341
|
+
normalizer: Normalization config (uses defaults if None).
|
|
342
|
+
|
|
343
|
+
Yields:
|
|
344
|
+
Filtered, normalized text strings (``str`` for .txt, ``CorpusItem``
|
|
345
|
+
for .jsonl).
|
|
346
|
+
"""
|
|
347
|
+
path = Path(path)
|
|
348
|
+
if not path.exists():
|
|
349
|
+
raise InputValidationError(f"Corpus file not found: {path}", code="corpus_not_found")
|
|
350
|
+
|
|
351
|
+
if _is_jsonl(path):
|
|
352
|
+
yield from _load_corpus_jsonl(path, min_length, max_length, max_lines, normalizer)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
yielded = 0
|
|
356
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
357
|
+
for raw_line in fh:
|
|
358
|
+
status, line = _classify_raw_line(raw_line, min_length, max_length, normalizer)
|
|
359
|
+
if status != _PASS or line is None:
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
yield line
|
|
363
|
+
yielded += 1
|
|
364
|
+
if max_lines > 0 and yielded >= max_lines:
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _load_corpus_jsonl(
|
|
369
|
+
path: Path,
|
|
370
|
+
min_length: int,
|
|
371
|
+
max_length: int,
|
|
372
|
+
max_lines: int,
|
|
373
|
+
normalizer: NormalizerConfig | None,
|
|
374
|
+
) -> Generator[CorpusItem, None, None]:
|
|
375
|
+
"""Load a JSONL corpus manifest, yielding :class:`CorpusItem` values."""
|
|
376
|
+
yielded = 0
|
|
377
|
+
with path.open("r", encoding="utf-8-sig", errors="replace") as fh:
|
|
378
|
+
for line_number, raw_line in enumerate(fh, start=1):
|
|
379
|
+
record = _parse_jsonl_record(raw_line, line_number, strict=True)
|
|
380
|
+
if record is None:
|
|
381
|
+
continue
|
|
382
|
+
text = record["text"]
|
|
383
|
+
status, normalized = _classify_jsonl_text(
|
|
384
|
+
text, min_length, max_length, normalizer
|
|
385
|
+
)
|
|
386
|
+
if status != _PASS or normalized is None:
|
|
387
|
+
continue
|
|
388
|
+
item = CorpusItem(
|
|
389
|
+
normalized,
|
|
390
|
+
source_id=record.get("source_id"),
|
|
391
|
+
source_group=record.get("source_group"),
|
|
392
|
+
language=record.get("language"),
|
|
393
|
+
script=record.get("script"),
|
|
394
|
+
domain=record.get("domain"),
|
|
395
|
+
source_uri=record.get("source_uri"),
|
|
396
|
+
license=record.get("license"),
|
|
397
|
+
retrieved_at=record.get("retrieved_at"),
|
|
398
|
+
source_sha256=record.get("source_sha256"),
|
|
399
|
+
normalization_policy=record.get("normalization_policy"),
|
|
400
|
+
consent_or_restrictions=record.get("consent_or_restrictions"),
|
|
401
|
+
raw_text=record.get("raw_text", text),
|
|
402
|
+
)
|
|
403
|
+
yield item
|
|
404
|
+
yielded += 1
|
|
405
|
+
if max_lines > 0 and yielded >= max_lines:
|
|
406
|
+
break
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def load_corpus_with_stats(
|
|
410
|
+
path: str | Path,
|
|
411
|
+
min_length: int = 1,
|
|
412
|
+
max_length: int = 260,
|
|
413
|
+
max_lines: int = 0,
|
|
414
|
+
normalizer: NormalizerConfig | None = None,
|
|
415
|
+
*,
|
|
416
|
+
strict_jsonl: bool = False,
|
|
417
|
+
) -> tuple[list[str | CorpusItem], dict[str, int]]:
|
|
418
|
+
"""Load a corpus and return ``(items, stats)`` in one pass.
|
|
419
|
+
|
|
420
|
+
Shares normalization, length filtering and line-separator rejection with
|
|
421
|
+
:func:`load_corpus`, but also reports why records were dropped. This is
|
|
422
|
+
used by layout generation, which needs explicit rejection counts.
|
|
423
|
+
|
|
424
|
+
Stats keys:
|
|
425
|
+
``total``: non-blank records considered (after ``max_lines`` cap)
|
|
426
|
+
``passing``: items returned
|
|
427
|
+
``too_short`` / ``too_long``: rejected by length
|
|
428
|
+
``malformed``: JSONL line that was not a valid object with a string ``text``
|
|
429
|
+
``separator``: record contained U+2028/U+2029
|
|
430
|
+
``blank``: blank line
|
|
431
|
+
|
|
432
|
+
``strict_jsonl=False`` (the default) skips malformed JSONL records instead
|
|
433
|
+
of raising, so a single bad line does not abort the whole run.
|
|
434
|
+
"""
|
|
435
|
+
path = Path(path)
|
|
436
|
+
if not path.exists():
|
|
437
|
+
raise InputValidationError(f"Corpus file not found: {path}", code="corpus_not_found")
|
|
438
|
+
|
|
439
|
+
stats = {
|
|
440
|
+
"total": 0,
|
|
441
|
+
"passing": 0,
|
|
442
|
+
"too_short": 0,
|
|
443
|
+
"too_long": 0,
|
|
444
|
+
"empty": 0,
|
|
445
|
+
"has_line_separator": 0,
|
|
446
|
+
"malformed": 0,
|
|
447
|
+
"separator": 0,
|
|
448
|
+
"blank": 0,
|
|
449
|
+
}
|
|
450
|
+
items: list[str | CorpusItem] = []
|
|
451
|
+
is_jsonl = _is_jsonl(path)
|
|
452
|
+
encoding = "utf-8-sig" if is_jsonl else "utf-8"
|
|
453
|
+
|
|
454
|
+
with path.open("r", encoding=encoding, errors="replace") as fh:
|
|
455
|
+
for line_number, raw_line in enumerate(fh, start=1):
|
|
456
|
+
if not raw_line.strip():
|
|
457
|
+
stats["blank"] += 1
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if is_jsonl:
|
|
461
|
+
record = _parse_jsonl_record(raw_line, line_number, strict=strict_jsonl)
|
|
462
|
+
if record is None:
|
|
463
|
+
stats["malformed"] += 1
|
|
464
|
+
continue
|
|
465
|
+
text = record["text"]
|
|
466
|
+
metadata = {
|
|
467
|
+
"source_id": record.get("source_id"),
|
|
468
|
+
"source_group": record.get("source_group"),
|
|
469
|
+
"language": record.get("language"),
|
|
470
|
+
"script": record.get("script"),
|
|
471
|
+
"domain": record.get("domain"),
|
|
472
|
+
"source_uri": record.get("source_uri"),
|
|
473
|
+
"license": record.get("license"),
|
|
474
|
+
"retrieved_at": record.get("retrieved_at"),
|
|
475
|
+
"source_sha256": record.get("source_sha256"),
|
|
476
|
+
"normalization_policy": record.get("normalization_policy"),
|
|
477
|
+
"consent_or_restrictions": record.get("consent_or_restrictions"),
|
|
478
|
+
"raw_text": record.get("raw_text", text),
|
|
479
|
+
}
|
|
480
|
+
else:
|
|
481
|
+
text = raw_line
|
|
482
|
+
metadata = {}
|
|
483
|
+
stats["total"] += 1
|
|
484
|
+
if line_separator_error(text):
|
|
485
|
+
stats["separator"] += 1
|
|
486
|
+
stats["has_line_separator"] += 1
|
|
487
|
+
continue
|
|
488
|
+
# _classify_raw_line strips, length-filters and normalizes in one go.
|
|
489
|
+
status, normalized = _classify_raw_line(text, min_length, max_length, normalizer)
|
|
490
|
+
if status == _SKIP:
|
|
491
|
+
# Separators were already rejected above, so this is a
|
|
492
|
+
# whitespace-only text value (e.g. JSONL {"text": " "}).
|
|
493
|
+
stats["blank"] += 1
|
|
494
|
+
stats["empty"] += 1
|
|
495
|
+
continue
|
|
496
|
+
if status == _TOO_SHORT:
|
|
497
|
+
stats["too_short"] += 1
|
|
498
|
+
continue
|
|
499
|
+
if status == _TOO_LONG:
|
|
500
|
+
stats["too_long"] += 1
|
|
501
|
+
continue
|
|
502
|
+
if normalized is None:
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
if is_jsonl:
|
|
506
|
+
item: str | CorpusItem = CorpusItem(normalized, **metadata)
|
|
507
|
+
else:
|
|
508
|
+
item = normalized
|
|
509
|
+
items.append(item)
|
|
510
|
+
stats["passing"] += 1
|
|
511
|
+
if max_lines > 0 and stats["passing"] >= max_lines:
|
|
512
|
+
break
|
|
513
|
+
|
|
514
|
+
return items, stats
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def count_corpus(
|
|
518
|
+
path: str | Path,
|
|
519
|
+
min_length: int = 1,
|
|
520
|
+
max_length: int = 260,
|
|
521
|
+
workers: int = 0,
|
|
522
|
+
normalizer: NormalizerConfig | None = None,
|
|
523
|
+
) -> dict[str, int]:
|
|
524
|
+
"""Scan the full corpus and return filter statistics.
|
|
525
|
+
|
|
526
|
+
Auto-detects JSONL format by file extension. JSONL counting always
|
|
527
|
+
runs serially (records are not byte-splittable).
|
|
528
|
+
|
|
529
|
+
Returns a dict with keys:
|
|
530
|
+
total, passing, too_short, too_long
|
|
531
|
+
"""
|
|
532
|
+
path = Path(path)
|
|
533
|
+
if not path.exists():
|
|
534
|
+
raise InputValidationError(f"Corpus file not found: {path}", code="corpus_not_found")
|
|
535
|
+
|
|
536
|
+
if _is_jsonl(path):
|
|
537
|
+
return _count_corpus_jsonl(path, min_length, max_length, normalizer)
|
|
538
|
+
|
|
539
|
+
resolved_workers = _resolve_worker_count(path, workers)
|
|
540
|
+
if resolved_workers <= 1:
|
|
541
|
+
return _count_corpus_serial(path, min_length, max_length, normalizer)
|
|
542
|
+
|
|
543
|
+
stats = _empty_stats()
|
|
544
|
+
try:
|
|
545
|
+
with mp.Pool(processes=resolved_workers) as pool:
|
|
546
|
+
ranges = list(_iter_chunk_ranges(path, resolved_workers))
|
|
547
|
+
for batch_stats in pool.starmap(
|
|
548
|
+
_count_file_chunk,
|
|
549
|
+
[(str(path), s, e, min_length, max_length, normalizer) for s, e in ranges],
|
|
550
|
+
chunksize=1,
|
|
551
|
+
):
|
|
552
|
+
_merge_stats(stats, batch_stats)
|
|
553
|
+
except (OSError, PermissionError):
|
|
554
|
+
return _count_corpus_serial(path, min_length, max_length, normalizer)
|
|
555
|
+
|
|
556
|
+
return stats
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _count_corpus_jsonl(
|
|
560
|
+
path: Path,
|
|
561
|
+
min_length: int,
|
|
562
|
+
max_length: int,
|
|
563
|
+
normalizer: NormalizerConfig | None,
|
|
564
|
+
) -> dict[str, int]:
|
|
565
|
+
"""Count passing/short/long records in a JSONL corpus manifest."""
|
|
566
|
+
stats = _empty_stats()
|
|
567
|
+
with path.open("r", encoding="utf-8-sig", errors="replace") as fh:
|
|
568
|
+
for line_number, raw_line in enumerate(fh, start=1):
|
|
569
|
+
record = _parse_jsonl_record(raw_line, line_number, strict=True)
|
|
570
|
+
if record is None:
|
|
571
|
+
continue
|
|
572
|
+
text = record["text"]
|
|
573
|
+
status, _ = _classify_jsonl_text(text, min_length, max_length, normalizer)
|
|
574
|
+
_update_stats_for_status(stats, status)
|
|
575
|
+
return stats
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def char_frequencies(
|
|
579
|
+
path: str | Path,
|
|
580
|
+
min_length: int = 1,
|
|
581
|
+
max_length: int = 260,
|
|
582
|
+
max_lines: int = 0,
|
|
583
|
+
normalizer: NormalizerConfig | None = None,
|
|
584
|
+
) -> Counter[str]:
|
|
585
|
+
"""Count character occurrences across the filtered, normalized corpus.
|
|
586
|
+
|
|
587
|
+
Used to identify rare characters for oversampling: lines that contain
|
|
588
|
+
infrequently-seen characters can be rendered more often so the model
|
|
589
|
+
sees enough examples of them.
|
|
590
|
+
"""
|
|
591
|
+
freq: Counter[str] = Counter()
|
|
592
|
+
for line in load_corpus(
|
|
593
|
+
path,
|
|
594
|
+
min_length=min_length,
|
|
595
|
+
max_length=max_length,
|
|
596
|
+
max_lines=max_lines,
|
|
597
|
+
normalizer=normalizer,
|
|
598
|
+
):
|
|
599
|
+
freq.update(line)
|
|
600
|
+
return freq
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def rare_chars_from_frequencies(freq: Counter[str], percentile: float) -> set[str]:
|
|
604
|
+
"""Return the least-frequent *percentile* percent of distinct characters in *freq*.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
freq: Character occurrence counts, e.g. from :func:`char_frequencies`.
|
|
608
|
+
percentile: Value in (0, 100]. 5.0 selects the least-frequent 5% of
|
|
609
|
+
distinct characters seen in the corpus (by character type, not
|
|
610
|
+
by occurrence count).
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
The set of characters considered "rare". Empty if *freq* is empty
|
|
614
|
+
or *percentile* is 0.
|
|
615
|
+
"""
|
|
616
|
+
if not freq or percentile <= 0:
|
|
617
|
+
return set()
|
|
618
|
+
percentile = min(100.0, percentile)
|
|
619
|
+
ordered = sorted(freq.items(), key=lambda kv: kv[1])
|
|
620
|
+
cutoff = max(1, round(len(ordered) * percentile / 100.0))
|
|
621
|
+
return {ch for ch, _ in ordered[:cutoff]}
|