sinlib 0.2.2__tar.gz → 0.3.0__tar.gz

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 (78) hide show
  1. {sinlib-0.2.2 → sinlib-0.3.0}/.gitignore +1 -0
  2. {sinlib-0.2.2 → sinlib-0.3.0}/.readthedocs.yaml +1 -1
  3. {sinlib-0.2.2 → sinlib-0.3.0}/PKG-INFO +50 -2
  4. {sinlib-0.2.2 → sinlib-0.3.0}/README.md +46 -0
  5. {sinlib-0.2.2 → sinlib-0.3.0}/pyproject.toml +4 -1
  6. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/__init__.py +10 -2
  7. sinlib-0.3.0/src/sinlib/charbert/__init__.py +15 -0
  8. sinlib-0.3.0/src/sinlib/charbert/alignment.py +154 -0
  9. sinlib-0.3.0/src/sinlib/charbert/backend.py +362 -0
  10. sinlib-0.3.0/src/sinlib/charbert/char_encoder.py +92 -0
  11. sinlib-0.3.0/src/sinlib/charbert/char_tokenization.py +123 -0
  12. sinlib-0.3.0/src/sinlib/charbert/config.py +35 -0
  13. sinlib-0.3.0/src/sinlib/charbert/embeddings.py +92 -0
  14. sinlib-0.3.0/src/sinlib/charbert/encoder.py +151 -0
  15. sinlib-0.3.0/src/sinlib/charbert/hi_module.py +107 -0
  16. sinlib-0.3.0/src/sinlib/charbert/modeling.py +81 -0
  17. sinlib-0.3.0/src/sinlib/charbert/seq2seq_decoder.py +482 -0
  18. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/spellcheck.py +279 -53
  19. sinlib-0.3.0/tests/test_charbert_backend.py +247 -0
  20. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_spellcheck.py +15 -12
  21. {sinlib-0.2.2 → sinlib-0.3.0}/website/astro.config.mjs +2 -0
  22. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/examples/typo-correction.md +1 -9
  23. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/examples/visualization.md +4 -1
  24. {sinlib-0.2.2 → sinlib-0.3.0}/.github/workflows/python-publish.yml +0 -0
  25. {sinlib-0.2.2 → sinlib-0.3.0}/.github/workflows/test.yml +0 -0
  26. {sinlib-0.2.2 → sinlib-0.3.0}/LICENSE +0 -0
  27. {sinlib-0.2.2 → sinlib-0.3.0}/requirements-dev.txt +0 -0
  28. {sinlib-0.2.2 → sinlib-0.3.0}/requirements.txt +0 -0
  29. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/encoding.py +0 -0
  30. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/romanize.py +0 -0
  31. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/subword.py +0 -0
  32. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/tokenizer.py +0 -0
  33. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/transliterate.py +0 -0
  34. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/__init__.py +0 -0
  35. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/chars.py +0 -0
  36. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/dataset_utils.py +0 -0
  37. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/model_utils.py +0 -0
  38. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/models/__init__.py +0 -0
  39. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/models/akshara_ngram.json +0 -0
  40. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/models/akshara_vocab.json +0 -0
  41. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/models/bigru_detector.pt +0 -0
  42. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/models/transliterator-checkpoint.pth +0 -0
  43. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/models/transliterator_model.py +0 -0
  44. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/preprocessing.py +0 -0
  45. {sinlib-0.2.2 → sinlib-0.3.0}/src/sinlib/utils/visualize.py +0 -0
  46. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_combined_detector.py +0 -0
  47. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_phase3_enhancements.py +0 -0
  48. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_romanizer.py +0 -0
  49. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_tokenizer.py +0 -0
  50. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_tokenizer_batch.py +0 -0
  51. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_tokenizer_bos.py +0 -0
  52. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_tokenizer_special_tokens.py +0 -0
  53. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_transliterator.py +0 -0
  54. {sinlib-0.2.2 → sinlib-0.3.0}/tests/test_visualize.py +0 -0
  55. {sinlib-0.2.2 → sinlib-0.3.0}/website/.gitignore +0 -0
  56. {sinlib-0.2.2 → sinlib-0.3.0}/website/.vscode/extensions.json +0 -0
  57. {sinlib-0.2.2 → sinlib-0.3.0}/website/.vscode/launch.json +0 -0
  58. {sinlib-0.2.2 → sinlib-0.3.0}/website/README.md +0 -0
  59. {sinlib-0.2.2 → sinlib-0.3.0}/website/package-lock.json +0 -0
  60. {sinlib-0.2.2 → sinlib-0.3.0}/website/package.json +0 -0
  61. {sinlib-0.2.2 → sinlib-0.3.0}/website/public/favicon.svg +0 -0
  62. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/assets/houston.webp +0 -0
  63. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/assets/logo-dark.png +0 -0
  64. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/assets/logo-light.png +0 -0
  65. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/api/encoding.mdx +0 -0
  66. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/api/preprocessing.mdx +0 -0
  67. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/api/romanizer.mdx +0 -0
  68. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/api/spellcheck.mdx +0 -0
  69. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/api/tokenizer.mdx +0 -0
  70. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/api/visualization.mdx +0 -0
  71. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/examples/tokenization.md +0 -0
  72. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/guides/spellcheck.mdx +0 -0
  73. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/guides/tokenization.mdx +0 -0
  74. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content/docs/index.mdx +0 -0
  75. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/content.config.ts +0 -0
  76. {sinlib-0.2.2 → sinlib-0.3.0}/website/src/styles/custom.css +0 -0
  77. {sinlib-0.2.2 → sinlib-0.3.0}/website/tsconfig.json +0 -0
  78. {sinlib-0.2.2 → sinlib-0.3.0}/welcome.png +0 -0
@@ -17,3 +17,4 @@ website/dist/
17
17
  website/node_modules/
18
18
  website/.astro/
19
19
 
20
+ notebooks/akshara_vocab.json
@@ -6,6 +6,6 @@ build:
6
6
  nodejs: "20"
7
7
  commands:
8
8
  - cd website && npm ci
9
- - cd website && npm run build
9
+ - cd website && ASTRO_TELEMETRY_DISABLED=1 npm run build
10
10
  - mkdir -p $READTHEDOCS_OUTPUT/html
11
11
  - cp -r website/dist/* $READTHEDOCS_OUTPUT/html/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sinlib
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Sinhala NLP Toolkit
5
5
  Project-URL: Code, https://github.com/Ransaka/sinlib
6
6
  Project-URL: Docs, https://sinlib.readthedocs.io
@@ -39,6 +39,8 @@ Requires-Dist: matplotlib>=3.9.4
39
39
  Requires-Dist: numpy>=1.24.0
40
40
  Requires-Dist: tqdm>=4.64.1
41
41
  Requires-Dist: transformers>=4.31.0
42
+ Provides-Extra: charbert
43
+ Requires-Dist: torch>=2.0; extra == 'charbert'
42
44
  Provides-Extra: dev
43
45
  Requires-Dist: pytest; extra == 'dev'
44
46
  Description-Content-Type: text/markdown
@@ -105,6 +107,52 @@ detector.suggest_correction("අඩිරාජ")
105
107
  # ['අධිරාජ']
106
108
  ```
107
109
 
110
+ ### Neural Typo Correction (optional, CharBERT)
111
+
112
+ TypoDetector can optionally delegate hard cases to a **Sinhala-CharBERT** neural
113
+ corrector — a dual-channel (subword + phonological akshara) seq2seq model. This
114
+ catches noise classes the statistical dictionary pipeline cannot fix: Singlish
115
+ transliteration, dialectal morphology (`යන්ඩ` → `යන්න`), ZWJ-damaged ligatures
116
+ (`ක්රීඩාව` → `ක්‍රීඩාව`), split/fused words, and Unicode decomposition errors.
117
+
118
+ Install the optional dependency and pick a backend mode:
119
+
120
+ ```python
121
+ from sinlib import TypoDetector
122
+
123
+ # "denoise" - bounded word-level neural fix when dictionary suggestions fail
124
+ # "seq2seq" - open-vocabulary sentence-level fix when structural noise is detected
125
+ # "hybrid" - both, in cascade (recommended)
126
+ detector = TypoDetector(neural_backend="hybrid")
127
+
128
+ detector("මම ගෙදර යන්ඩ ඕනේ")
129
+ # 'මම ගෙදර යන්න ඕනේ'
130
+
131
+ detector("මම gedara යන්න ඕනේ")
132
+ # 'මම ගෙදර යන්න ඕනේ'
133
+
134
+ detector("ක්රීඩාව")
135
+ # 'ක්‍රීඩාව'
136
+ ```
137
+
138
+ | Kwarg | Default | Description |
139
+ |---|---|---|
140
+ | `neural_backend` | `None` | `None`, `"denoise"`, `"seq2seq"`, or `"hybrid"` |
141
+ | `backend_model` | `Ransaka/sinhala-charbert-seq2seq` | HF Hub repo id or local checkpoint dir (`pytorch_model.bin` + `char_vocab.json`) |
142
+ | `backend_device` | auto (cuda > mps > cpu) | Torch device |
143
+ | `backend_revision` | `None` | Pin a Hub revision |
144
+ | `backend_num_beams` | `4` | Beam width for generation |
145
+
146
+ Notes:
147
+
148
+ - **Default behavior is unchanged** — without `neural_backend` the detector is
149
+ purely statistical and requires no torch.
150
+ - Neural corrections are gated: clean in-dictionary sentences never hit the
151
+ model, and a candidate is accepted only if it scores no worse than the input
152
+ (hallucination guard).
153
+ - If the checkpoint cannot be downloaded, the detector degrades gracefully to
154
+ statistical-only correction with a warning.
155
+
108
156
  ### Preprocessing
109
157
 
110
158
  ```python
@@ -60,6 +60,52 @@ detector.suggest_correction("අඩිරාජ")
60
60
  # ['අධිරාජ']
61
61
  ```
62
62
 
63
+ ### Neural Typo Correction (optional, CharBERT)
64
+
65
+ TypoDetector can optionally delegate hard cases to a **Sinhala-CharBERT** neural
66
+ corrector — a dual-channel (subword + phonological akshara) seq2seq model. This
67
+ catches noise classes the statistical dictionary pipeline cannot fix: Singlish
68
+ transliteration, dialectal morphology (`යන්ඩ` → `යන්න`), ZWJ-damaged ligatures
69
+ (`ක්රීඩාව` → `ක්‍රීඩාව`), split/fused words, and Unicode decomposition errors.
70
+
71
+ Install the optional dependency and pick a backend mode:
72
+
73
+ ```python
74
+ from sinlib import TypoDetector
75
+
76
+ # "denoise" - bounded word-level neural fix when dictionary suggestions fail
77
+ # "seq2seq" - open-vocabulary sentence-level fix when structural noise is detected
78
+ # "hybrid" - both, in cascade (recommended)
79
+ detector = TypoDetector(neural_backend="hybrid")
80
+
81
+ detector("මම ගෙදර යන්ඩ ඕනේ")
82
+ # 'මම ගෙදර යන්න ඕනේ'
83
+
84
+ detector("මම gedara යන්න ඕනේ")
85
+ # 'මම ගෙදර යන්න ඕනේ'
86
+
87
+ detector("ක්රීඩාව")
88
+ # 'ක්‍රීඩාව'
89
+ ```
90
+
91
+ | Kwarg | Default | Description |
92
+ |---|---|---|
93
+ | `neural_backend` | `None` | `None`, `"denoise"`, `"seq2seq"`, or `"hybrid"` |
94
+ | `backend_model` | `Ransaka/sinhala-charbert-seq2seq` | HF Hub repo id or local checkpoint dir (`pytorch_model.bin` + `char_vocab.json`) |
95
+ | `backend_device` | auto (cuda > mps > cpu) | Torch device |
96
+ | `backend_revision` | `None` | Pin a Hub revision |
97
+ | `backend_num_beams` | `4` | Beam width for generation |
98
+
99
+ Notes:
100
+
101
+ - **Default behavior is unchanged** — without `neural_backend` the detector is
102
+ purely statistical and requires no torch.
103
+ - Neural corrections are gated: clean in-dictionary sentences never hit the
104
+ model, and a candidate is accepted only if it scores no worse than the input
105
+ (hallucination guard).
106
+ - If the checkpoint cannot be downloaded, the detector degrades gracefully to
107
+ statistical-only correction with a warning.
108
+
63
109
  ### Preprocessing
64
110
 
65
111
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sinlib"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Sinhala NLP Toolkit"
5
5
  authors = [
6
6
  { name = "Ransaka", email = "ransaka.ravihara@gmail.com" }
@@ -28,6 +28,9 @@ classifiers = [
28
28
  dev = [
29
29
  "pytest",
30
30
  ]
31
+ charbert = [
32
+ "torch >= 2.0",
33
+ ]
31
34
 
32
35
  [project.urls]
33
36
  Code = "https://github.com/Ransaka/sinlib"
@@ -17,7 +17,13 @@ BatchEncoding
17
17
 
18
18
  TypoDetector
19
19
  N-gram–based spell checker for Sinhala. Use
20
- ``TypoDetector.from_pretrained()`` or instantiate directly.
20
+ ``TypoDetector.from_pretrained()`` or instantiate directly. Optionally
21
+ accepts ``neural_backend="seq2seq" | "denoise" | "hybrid"`` to delegate
22
+ hard cases to a CharBERT neural corrector.
23
+
24
+ CharBERTBackend
25
+ Loader/corrector for published Sinhala-CharBERT seq2seq checkpoints
26
+ (HF Hub or local directory). See ``sinlib.charbert``.
21
27
 
22
28
  preprocessing
23
29
  Utility module exposing ``remove_english_characters``,
@@ -42,6 +48,7 @@ from sinlib.subword import SubwordTokenizer
42
48
  from sinlib.spellcheck import TypoDetector
43
49
  from sinlib.romanize import Romanizer
44
50
  from sinlib.transliterate import Transliterator # noqa: F401
51
+ from sinlib.charbert import CharBERTBackend
45
52
  from sinlib.utils import preprocessing, setup_matplotlib
46
53
 
47
54
  __all__: List[str] = [
@@ -49,9 +56,10 @@ __all__: List[str] = [
49
56
  "Tokenizer",
50
57
  "SubwordTokenizer",
51
58
  "TypoDetector",
59
+ "CharBERTBackend",
52
60
  "Romanizer",
53
61
  "preprocessing",
54
62
  "setup_matplotlib",
55
63
  ]
56
64
 
57
- __version__ = "0.2.2"
65
+ __version__ = "0.3.0"
@@ -0,0 +1,15 @@
1
+ """
2
+ Optional Sinhala-CharBERT neural backend for sinlib's TypoDetector.
3
+
4
+ Provides inference-only model code and a high-level ``CharBERTBackend`` that
5
+ loads published CharBERT seq2seq checkpoints (HF Hub or local directory).
6
+ ``torch`` is imported lazily; the base sinlib install does not require it.
7
+ """
8
+
9
+ from sinlib.charbert.backend import CharBERTBackend
10
+ from sinlib.charbert.config import SinhalaCharBERTConfig
11
+
12
+ __all__ = [
13
+ "CharBERTBackend",
14
+ "SinhalaCharBERTConfig",
15
+ ]
@@ -0,0 +1,154 @@
1
+ """
2
+ Sequence Alignment Engine for mapping subword tokens to phonological character unit boundaries.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional, Tuple, Union
7
+ from transformers import AutoTokenizer, PreTrainedTokenizerFast, PreTrainedTokenizer
8
+
9
+ from sinlib.utils.preprocessing import normalize_sinhala, process_text
10
+ from sinlib.charbert.char_tokenization import SinhalaCharTokenizer
11
+
12
+
13
+ @dataclass
14
+ class AlignedSequence:
15
+ """Represents a dual-channel aligned text sequence."""
16
+ text: str
17
+ tokens: List[str]
18
+ input_ids: List[int]
19
+ attention_mask: List[int]
20
+ char_units: List[str]
21
+ char_input_ids: List[int]
22
+ char_attention_mask: List[int]
23
+ start_char_idx: List[int]
24
+ end_char_idx: List[int]
25
+ subword_offsets: List[Tuple[int, int]] = field(default_factory=list)
26
+
27
+
28
+ class SequenceAlignmentEngine:
29
+ """
30
+ Computes exact bidirectional boundary mappings between Subword Tokens (m)
31
+ and Phonological Units / Aksharas (N).
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ subword_tokenizer: Union[PreTrainedTokenizerFast, PreTrainedTokenizer, Any],
37
+ char_tokenizer: SinhalaCharTokenizer,
38
+ max_subword_length: int = 256,
39
+ max_char_length: int = 512,
40
+ ):
41
+ self.subword_tokenizer = subword_tokenizer
42
+ self.char_tokenizer = char_tokenizer
43
+ self.max_subword_length = max_subword_length
44
+ self.max_char_length = max_char_length
45
+
46
+ def _get_phonological_spans(self, text: str, units: List[str]) -> List[Tuple[int, int]]:
47
+ """Calculates character string offsets [start_char, end_char) for each phonological unit in raw text."""
48
+ spans: List[Tuple[int, int]] = []
49
+ curr_idx = 0
50
+ for u in units:
51
+ # Locate unit starting from current offset
52
+ found_idx = text.find(u, curr_idx)
53
+ if found_idx == -1:
54
+ # Fallback if normalization shifted index
55
+ found_idx = curr_idx
56
+ end_idx = found_idx + len(u)
57
+ spans.append((found_idx, end_idx))
58
+ curr_idx = end_idx
59
+ return spans
60
+
61
+ def align(self, text: str) -> AlignedSequence:
62
+ """
63
+ Aligns a single sentence across subword and character channels.
64
+ Returns subword IDs, character IDs, and boundary coordinate arrays start_char_idx and end_char_idx.
65
+ """
66
+ norm_text = normalize_sinhala(text.strip())
67
+ if not norm_text:
68
+ norm_text = " "
69
+
70
+ # 1. Subword Tokenization with offset mapping
71
+ subword_encoding = self.subword_tokenizer(
72
+ norm_text,
73
+ truncation=True,
74
+ max_length=self.max_subword_length,
75
+ return_offsets_mapping=True,
76
+ add_special_tokens=True,
77
+ )
78
+
79
+ subword_ids: List[int] = subword_encoding["input_ids"]
80
+ subword_attn: List[int] = subword_encoding.get("attention_mask", [1] * len(subword_ids))
81
+ subword_tokens: List[str] = self.subword_tokenizer.convert_ids_to_tokens(subword_ids)
82
+ subword_offsets: List[Tuple[int, int]] = subword_encoding["offset_mapping"]
83
+
84
+ # 2. Phonological Unit Tokenization
85
+ raw_units = process_text(norm_text)
86
+ unit_spans = self._get_phonological_spans(norm_text, raw_units)
87
+
88
+ # Prepend <bos> and append <eos> to phonological units
89
+ char_units = [SinhalaCharTokenizer.BOS_TOKEN] + raw_units + [SinhalaCharTokenizer.EOS_TOKEN]
90
+ char_ids = (
91
+ [self.char_tokenizer.bos_token_id]
92
+ + [self.char_tokenizer.vocab_map.get(u, self.char_tokenizer.unk_token_id) for u in raw_units]
93
+ + [self.char_tokenizer.eos_token_id]
94
+ )
95
+
96
+ # Truncate character sequence if needed
97
+ if len(char_ids) > self.max_char_length:
98
+ char_ids = char_ids[: self.max_char_length - 1] + [self.char_tokenizer.eos_token_id]
99
+ char_units = char_units[: self.max_char_length - 1] + [SinhalaCharTokenizer.EOS_TOKEN]
100
+ unit_spans = unit_spans[: len(char_units) - 2]
101
+
102
+ char_attn = [1] * len(char_ids)
103
+ bos_idx = 0
104
+ eos_idx = len(char_ids) - 1
105
+
106
+ # 3. Align Subword Tokens to Phonological Units
107
+ start_char_idx: List[int] = []
108
+ end_char_idx: List[int] = []
109
+
110
+ for i, (tok_start, tok_end) in enumerate(subword_offsets):
111
+ # Special tokens [CLS], [SEP], [PAD]
112
+ if tok_start == 0 and tok_end == 0:
113
+ if i == 0: # First token is [CLS] / [BOS]
114
+ start_char_idx.append(bos_idx)
115
+ end_char_idx.append(bos_idx)
116
+ else: # Last or padding token is [SEP] / [EOS]
117
+ start_char_idx.append(eos_idx)
118
+ end_char_idx.append(eos_idx)
119
+ continue
120
+
121
+ # Find matching phonological units by offset overlap
122
+ matching_unit_indices = []
123
+ for u_idx, (u_start, u_end) in enumerate(unit_spans):
124
+ # Check for overlap: max(tok_start, u_start) < min(tok_end, u_end)
125
+ if max(tok_start, u_start) < min(tok_end, u_end):
126
+ # Offset +1 because index 0 is <bos>
127
+ matching_unit_indices.append(u_idx + 1)
128
+
129
+ if matching_unit_indices:
130
+ start_char_idx.append(matching_unit_indices[0])
131
+ end_char_idx.append(matching_unit_indices[-1])
132
+ else:
133
+ # Nearest fallback to prevent out-of-bounds indexing
134
+ closest_idx = min(
135
+ range(len(unit_spans)),
136
+ key=lambda idx: abs(unit_spans[idx][0] - tok_start),
137
+ default=0,
138
+ )
139
+ adjusted_idx = min(closest_idx + 1, eos_idx - 1)
140
+ start_char_idx.append(adjusted_idx)
141
+ end_char_idx.append(adjusted_idx)
142
+
143
+ return AlignedSequence(
144
+ text=norm_text,
145
+ tokens=subword_tokens,
146
+ input_ids=subword_ids,
147
+ attention_mask=subword_attn,
148
+ char_units=char_units,
149
+ char_input_ids=char_ids,
150
+ char_attention_mask=char_attn,
151
+ start_char_idx=start_char_idx,
152
+ end_char_idx=end_char_idx,
153
+ subword_offsets=subword_offsets,
154
+ )