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/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]}