simphone 0.1.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.
simphone/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Phonetic similarity search over a list of words or spans."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from simphone.g2p import normalize
6
+ from simphone.index import PhoneticIndex
7
+
8
+ try:
9
+ __version__ = version("simphone")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["PhoneticIndex", "normalize"]
simphone/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m simphone``."""
2
+
3
+ import sys
4
+
5
+ from simphone.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
simphone/cli.py ADDED
@@ -0,0 +1,174 @@
1
+ """Command line for building and searching a phonetic index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from simphone.g2p import normalize
11
+ from simphone.index import PhoneticIndex, read_span_file
12
+
13
+
14
+ def _queries(args: argparse.Namespace) -> list[str]:
15
+ out = list(args.query or [])
16
+ if args.queries:
17
+ out.extend(read_span_file(args.queries))
18
+ return out
19
+
20
+
21
+ def _print_hits(query: str, hits: list[dict]) -> None:
22
+ print(query)
23
+ if not hits:
24
+ print(" (none)")
25
+ return
26
+ for h in hits:
27
+ dlen = h["dlen"]
28
+ sign = f"{dlen:+d}"
29
+ print(f" {h['text']}\t{h['sim']:.4f}\tn={h['n_units']}\tdlen={sign}")
30
+
31
+
32
+ def cmd_norm(args: argparse.Namespace) -> int:
33
+ texts = list(args.text or [])
34
+ if args.queries:
35
+ texts.extend(read_span_file(args.queries))
36
+ if not texts:
37
+ print("pass --text and/or --queries", file=sys.stderr)
38
+ return 1
39
+ for line in normalize(texts, args.lang, workers=args.workers):
40
+ print(line)
41
+ return 0
42
+
43
+
44
+ def cmd_build(args: argparse.Namespace) -> int:
45
+ spans = read_span_file(args.inventory)
46
+ if not spans:
47
+ print(f"no spans in {args.inventory}", file=sys.stderr)
48
+ return 1
49
+ print(f"phonemizing {len(spans)} spans ({args.lang})", flush=True)
50
+ index = PhoneticIndex.build(
51
+ spans,
52
+ args.lang,
53
+ tone_weight=args.tone_weight,
54
+ device=args.device,
55
+ cache_dir=args.cache_dir,
56
+ en_locale=args.en_locale,
57
+ workers=args.workers,
58
+ )
59
+ index.save(args.save)
60
+ print(f"saved {len(index.spans)} spans -> {args.save}")
61
+ return 0
62
+
63
+
64
+ def cmd_search(args: argparse.Namespace) -> int:
65
+ queries = _queries(args)
66
+ if not queries:
67
+ print("pass --query and/or --queries", file=sys.stderr)
68
+ return 1
69
+ if args.load:
70
+ index = PhoneticIndex.load(args.load)
71
+ else:
72
+ if not args.inventory or not args.lang:
73
+ print("pass --load, or --inventory with --lang", file=sys.stderr)
74
+ return 1
75
+ spans = read_span_file(args.inventory)
76
+ print(f"phonemizing {len(spans)} spans ({args.lang})", flush=True)
77
+ index = PhoneticIndex.build(
78
+ spans,
79
+ args.lang,
80
+ tone_weight=args.tone_weight,
81
+ device=args.device,
82
+ cache_dir=args.cache_dir,
83
+ en_locale=args.en_locale,
84
+ workers=args.workers,
85
+ )
86
+ hits = index.search_many(
87
+ queries,
88
+ topk=args.topk,
89
+ prefilter=args.prefilter,
90
+ sim_min=args.sim_min,
91
+ workers=args.workers,
92
+ block_size=args.block_size,
93
+ device=args.device,
94
+ cache_dir=args.cache_dir,
95
+ en_locale=args.en_locale,
96
+ )
97
+ rows = [{"query": q, "neighbors": h} for q, h in zip(queries, hits)]
98
+ for row in rows:
99
+ _print_hits(row["query"], row["neighbors"])
100
+ sys.stdout.flush()
101
+ if args.out:
102
+ path = Path(args.out)
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ with path.open("w", encoding="utf-8") as fh:
105
+ for row in rows:
106
+ fh.write(json.dumps(row, ensure_ascii=False))
107
+ fh.write("\n")
108
+ print(f"wrote {len(rows)} rows -> {path}", file=sys.stderr)
109
+ return 0
110
+
111
+
112
+ def build_parser() -> argparse.ArgumentParser:
113
+ p = argparse.ArgumentParser(
114
+ prog="simphone",
115
+ description="Find phonetically similar words or spans with a feature-weighted edit distance.",
116
+ )
117
+ sub = p.add_subparsers(dest="command", required=True)
118
+
119
+ n = sub.add_parser("norm", help="expand spans to their spoken form")
120
+ n.add_argument("--lang", required=True, choices=["vi", "en"])
121
+ n.add_argument("--text", action="append", default=None, help="repeatable span")
122
+ n.add_argument("--queries", default=None, help="file of spans, one per line")
123
+ n.add_argument(
124
+ "--workers",
125
+ type=int,
126
+ default=0,
127
+ help="sea-g2p Rayon threads for Vietnamese (0 = os.cpu_count()). Ignored for English.",
128
+ )
129
+ n.set_defaults(func=cmd_norm)
130
+
131
+ b = sub.add_parser("build", help="phonemize an inventory and save the index")
132
+ b.add_argument("--lang", required=True, choices=["vi", "en"])
133
+ b.add_argument("--inventory", required=True, help="one span per line")
134
+ b.add_argument("--save", required=True, help="directory to write")
135
+ b.add_argument("--tone-weight", type=float, default=0.5)
136
+ b.add_argument("--device", default="cpu", help="English out-of-dictionary G2P device")
137
+ b.add_argument(
138
+ "--workers",
139
+ type=int,
140
+ default=0,
141
+ help="CPU workers for Vietnamese segmentation (0 = os.cpu_count()). "
142
+ "Also sets sea-g2p's Rayon thread count.",
143
+ )
144
+ b.add_argument("--cache-dir", default=None, help="Charsiu model cache (English)")
145
+ b.add_argument("--en-locale", default="eng-us")
146
+ b.set_defaults(func=cmd_build)
147
+
148
+ s = sub.add_parser("search", help="search one or more queries against an inventory")
149
+ s.add_argument("--load", default=None, help="index directory from build")
150
+ s.add_argument("--lang", choices=["vi", "en"], help="required with --inventory")
151
+ s.add_argument("--inventory", default=None, help="build in memory instead of --load")
152
+ s.add_argument("--query", action="append", default=None, help="repeatable span")
153
+ s.add_argument("--queries", default=None, help="file of query spans, one per line")
154
+ s.add_argument("--topk", type=int, default=50)
155
+ s.add_argument("--prefilter", type=int, default=300)
156
+ s.add_argument("--sim-min", type=float, default=0.8)
157
+ s.add_argument("--workers", type=int, default=0, help="0 = os.cpu_count()")
158
+ s.add_argument("--block-size", type=int, default=512)
159
+ s.add_argument("--tone-weight", type=float, default=0.5)
160
+ s.add_argument("--device", default="cpu")
161
+ s.add_argument("--cache-dir", default=None)
162
+ s.add_argument("--en-locale", default="eng-us")
163
+ s.add_argument("--out", default=None, help="JSONL of query rows")
164
+ s.set_defaults(func=cmd_search)
165
+ return p
166
+
167
+
168
+ def main(argv=None) -> int:
169
+ args = build_parser().parse_args(argv)
170
+ return args.func(args)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ sys.exit(main())
simphone/distance.py ADDED
@@ -0,0 +1,346 @@
1
+ """Feature-weighted phonetic edit distance.
2
+
3
+ Substitution cost is the panphon articulatory distance between base phones,
4
+ normalized to ``[0, 1]``, plus ``tone_weight`` when the tone marks differ.
5
+ panphon ignores tone, so the tone term is what keeps tone in the score.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional, Sequence
11
+
12
+ import numpy as np
13
+
14
+ # Charsiu English tones: Chao letters U+02E5..U+02E9, sometimes with U+02C0 (ˀ).
15
+ CHAO_TONE_CHARS = frozenset(chr(c) for c in range(0x02E5, 0x02EA))
16
+ CHAO_EXTRA = frozenset({"\u02C0"})
17
+
18
+ # sea-g2p Vietnamese tones, peeled out of the syllable before segmentation.
19
+ # Confirmed on ma/mà/mả/mã/má/mạ: huyền=2, hỏi=4, ngã=5, nặng=6, sắc=U+025C (ɜ).
20
+ # Ngang has no mark. ɜ is a tone glyph here, not the IPA vowel.
21
+ VI_TONE_MARKS = frozenset({"2", "4", "5", "6", "\u025c"})
22
+
23
+ TONE_CHARS = CHAO_TONE_CHARS | CHAO_EXTRA | VI_TONE_MARKS
24
+
25
+ _DP_FUNC = None
26
+
27
+
28
+ def is_tone_token(token: str) -> bool:
29
+ """True if ``token`` is only Chao tone letters (and optional ˀ)."""
30
+ return bool(token) and any(c in CHAO_TONE_CHARS for c in token) and all(
31
+ c in CHAO_TONE_CHARS or c in CHAO_EXTRA for c in token
32
+ )
33
+
34
+
35
+ def split_base_tone(token: str) -> tuple[str, str]:
36
+ """Split a phone token into ``(base_ipa, tone_str)``.
37
+
38
+ Chao-only tokens yield ``("", tone)``. A sea-g2p vowel with a trailing
39
+ Vietnamese tone mark (``aː2``, ``aɜ``) yields the base vowel and that mark.
40
+ A one-character ``ɜ`` is left as a base phone so an English NURSE vowel is
41
+ not treated as Vietnamese sắc.
42
+ """
43
+ if is_tone_token(token):
44
+ return "", token
45
+ if len(token) > 1 and token[-1] in VI_TONE_MARKS:
46
+ return token[:-1], token[-1]
47
+ base = "".join(c for c in token if c not in CHAO_TONE_CHARS and c not in CHAO_EXTRA)
48
+ tone = "".join(c for c in token if c in CHAO_TONE_CHARS or c in CHAO_EXTRA)
49
+ return base, tone
50
+
51
+
52
+ def span_phones(
53
+ units: Sequence[str], unit_tokens: dict[str, Sequence[str]]
54
+ ) -> list[str]:
55
+ """Concatenate per-unit phone-token lists (no word-boundary token)."""
56
+ toks: list[str] = []
57
+ for u in units:
58
+ toks.extend(unit_tokens.get(u, ()))
59
+ return toks
60
+
61
+
62
+ def _get_dp():
63
+ """Weighted Levenshtein ``dp(a, b, C, indel) -> float``. Numba when available."""
64
+ global _DP_FUNC
65
+ if _DP_FUNC is not None:
66
+ return _DP_FUNC
67
+
68
+ def _dp(a, b, C, indel):
69
+ la = a.shape[0]
70
+ lb = b.shape[0]
71
+ if la == 0:
72
+ return lb * indel
73
+ if lb == 0:
74
+ return la * indel
75
+ prev = np.empty(lb + 1, dtype=np.float32)
76
+ cur = np.empty(lb + 1, dtype=np.float32)
77
+ for j in range(lb + 1):
78
+ prev[j] = j * indel
79
+ for i in range(1, la + 1):
80
+ cur[0] = i * indel
81
+ ai = a[i - 1]
82
+ for j in range(1, lb + 1):
83
+ sub = prev[j - 1] + C[ai, b[j - 1]]
84
+ dele = prev[j] + indel
85
+ ins = cur[j - 1] + indel
86
+ m = sub
87
+ if dele < m:
88
+ m = dele
89
+ if ins < m:
90
+ m = ins
91
+ cur[j] = m
92
+ for j in range(lb + 1):
93
+ prev[j] = cur[j]
94
+ return prev[lb]
95
+
96
+ try:
97
+ from numba import njit
98
+
99
+ _DP_FUNC = njit(cache=True)(_dp)
100
+ except Exception:
101
+ _DP_FUNC = _dp
102
+ return _DP_FUNC
103
+
104
+
105
+ class PhoneticDistance:
106
+ """Cost matrix and weighted edit distance over a fixed phone-token list."""
107
+
108
+ def __init__(
109
+ self,
110
+ tokens: Sequence[str],
111
+ *,
112
+ tone_weight: float = 0.5,
113
+ indel_cost: float = 1.0,
114
+ ) -> None:
115
+ self.tone_weight = float(tone_weight)
116
+ self.indel_cost = float(indel_cost)
117
+ self.tokens = list(dict.fromkeys(tokens))
118
+ self.tok2id = {t: i for i, t in enumerate(self.tokens)}
119
+ self.cost_scale = 1.0
120
+ self._bases: list[str] = []
121
+ self._Cb: Optional[np.ndarray] = None
122
+ self._tok_base: list[int] = []
123
+ self._tok_tone: list[str] = []
124
+ self.cost = self._build_cost()
125
+ self._dp = _get_dp()
126
+
127
+ @classmethod
128
+ def from_matrix(
129
+ cls,
130
+ tokens: Sequence[str],
131
+ cost: np.ndarray,
132
+ *,
133
+ tone_weight: float,
134
+ indel_cost: float,
135
+ cost_scale: float,
136
+ ) -> "PhoneticDistance":
137
+ """Restore a matrix saved by :meth:`PhoneticIndex.save` (no panphon)."""
138
+ obj = cls.__new__(cls)
139
+ obj.tone_weight = float(tone_weight)
140
+ obj.indel_cost = float(indel_cost)
141
+ obj.tokens = list(tokens)
142
+ obj.tok2id = {t: i for i, t in enumerate(obj.tokens)}
143
+ obj.cost = np.asarray(cost, dtype=np.float32)
144
+ obj.cost_scale = float(cost_scale) if cost_scale and cost_scale > 0 else 1.0
145
+ obj._dp = _get_dp()
146
+ obj._recover_components()
147
+ return obj
148
+
149
+ def _base_cost_matrix(self, bases: list[str]) -> tuple[np.ndarray, float]:
150
+ """Normalized ``[0,1]`` panphon distances. Empty base is the null segment."""
151
+ from panphon.distance import Distance
152
+
153
+ dist = Distance()
154
+ n = len(bases)
155
+ raw = np.zeros((n, n), dtype=np.float64)
156
+ from tqdm import tqdm
157
+
158
+ real = [i for i, b in enumerate(bases) if b]
159
+ for x in tqdm(range(len(real)), desc="panphon", unit="phone"):
160
+ for y in range(x + 1, len(real)):
161
+ i, j = real[x], real[y]
162
+ try:
163
+ v = float(dist.weighted_feature_edit_distance(bases[i], bases[j]))
164
+ except Exception:
165
+ v = -1.0
166
+ if v != v:
167
+ v = -1.0
168
+ raw[i, j] = raw[j, i] = v
169
+ finite = raw[raw > 0]
170
+ scale = float(finite.max()) if finite.size else 1.0
171
+ if scale <= 0:
172
+ scale = 1.0
173
+ raw[raw < 0] = scale
174
+ raw = raw / scale
175
+ for i, b in enumerate(bases):
176
+ if not b:
177
+ for j in range(n):
178
+ raw[i, j] = raw[j, i] = 0.0 if i == j else 1.0
179
+ return raw, scale
180
+
181
+ def _split_all(self, tokens: Sequence[str]) -> tuple[list[str], list[str]]:
182
+ bases: list[str] = []
183
+ tones: list[str] = []
184
+ for t in tokens:
185
+ b, tn = split_base_tone(t)
186
+ bases.append(b)
187
+ tones.append(tn)
188
+ return bases, tones
189
+
190
+ def _build_cost(self) -> np.ndarray:
191
+ bases, tones = self._split_all(self.tokens)
192
+ uniq = list(dict.fromkeys(bases))
193
+ base2id = {b: i for i, b in enumerate(uniq)}
194
+ Cb, scale = self._base_cost_matrix(uniq)
195
+ self._bases = uniq
196
+ self._Cb = Cb
197
+ self.cost_scale = scale
198
+ self._tok_base = [base2id[b] for b in bases]
199
+ self._tok_tone = tones
200
+ return self._cost_from_components(self._tok_base, self._tok_tone)
201
+
202
+ def _cost_from_components(self, bidx: Sequence[int], tones: Sequence[str]) -> np.ndarray:
203
+ assert self._Cb is not None
204
+ k = len(bidx)
205
+ C = np.zeros((k, k), dtype=np.float32)
206
+ for i in range(k):
207
+ bi = bidx[i]
208
+ ti = tones[i]
209
+ for j in range(k):
210
+ c = float(self._Cb[bi, bidx[j]])
211
+ if ti != tones[j]:
212
+ c += self.tone_weight
213
+ C[i, j] = c
214
+ np.fill_diagonal(C, 0.0)
215
+ return C
216
+
217
+ def _recover_components(self) -> None:
218
+ """Rebuild the base-cost matrix from the saved full-token matrix."""
219
+ bases, tones = self._split_all(self.tokens)
220
+ uniq = list(dict.fromkeys(bases))
221
+ base2id = {b: i for i, b in enumerate(uniq)}
222
+ first: dict[str, int] = {}
223
+ for i, b in enumerate(bases):
224
+ first.setdefault(b, i)
225
+ n = len(uniq)
226
+ Cb = np.zeros((n, n), dtype=np.float64)
227
+ for a in uniq:
228
+ ia = first[a]
229
+ for b in uniq:
230
+ ib = first[b]
231
+ c = float(self.cost[ia, ib])
232
+ if tones[ia] != tones[ib]:
233
+ c -= self.tone_weight
234
+ if c < 0.0:
235
+ c = 0.0
236
+ Cb[base2id[a], base2id[b]] = c
237
+ self._bases = uniq
238
+ self._Cb = Cb
239
+ self._tok_base = [base2id[b] for b in bases]
240
+ self._tok_tone = tones
241
+
242
+ def _pair_base(self, dist, a: str, b: str) -> float:
243
+ if a == b:
244
+ return 0.0
245
+ if not a or not b:
246
+ return 1.0
247
+ try:
248
+ v = float(dist.weighted_feature_edit_distance(a, b))
249
+ except Exception:
250
+ return 1.0
251
+ if v != v or v < 0:
252
+ return 1.0
253
+ scale = self.cost_scale if self.cost_scale > 0 else 1.0
254
+ out = v / scale
255
+ return 1.0 if out > 1.0 else out
256
+
257
+ def ensure_tokens(self, tokens: Sequence[str]) -> None:
258
+ """Append phones that are not in the matrix.
259
+
260
+ Existing inventory ids stay valid. A new base phone gets panphon costs
261
+ against the bases already stored; the saved inventory block is not rebuilt.
262
+ """
263
+ new: list[str] = []
264
+ for t in tokens:
265
+ if t not in self.tok2id and t not in new:
266
+ new.append(t)
267
+ if not new:
268
+ return
269
+ assert self._Cb is not None
270
+ from panphon.distance import Distance
271
+
272
+ dist = Distance()
273
+ new_bases, new_tones = self._split_all(new)
274
+ extra: list[str] = []
275
+ known = set(self._bases)
276
+ for b in new_bases:
277
+ if b not in known:
278
+ known.add(b)
279
+ extra.append(b)
280
+ if extra:
281
+ old_n = len(self._bases)
282
+ add_n = len(extra)
283
+ grown = np.zeros((old_n + add_n, old_n + add_n), dtype=np.float64)
284
+ grown[:old_n, :old_n] = self._Cb
285
+ for i, b in enumerate(extra):
286
+ bi = old_n + i
287
+ for j, ob in enumerate(self._bases):
288
+ grown[bi, j] = grown[j, bi] = self._pair_base(dist, b, ob)
289
+ for k in range(i):
290
+ grown[bi, old_n + k] = grown[old_n + k, bi] = self._pair_base(
291
+ dist, b, extra[k]
292
+ )
293
+ self._bases = list(self._bases) + extra
294
+ self._Cb = grown
295
+ base2id = {b: i for i, b in enumerate(self._bases)}
296
+ old_k = len(self.tokens)
297
+ bidx = self._tok_base + [base2id[b] for b in new_bases]
298
+ tones = self._tok_tone + new_tones
299
+ C = np.zeros((old_k + len(new), old_k + len(new)), dtype=np.float32)
300
+ C[:old_k, :old_k] = self.cost
301
+ for i in range(old_k, old_k + len(new)):
302
+ for j in range(old_k + len(new)):
303
+ if i == j:
304
+ continue
305
+ c = float(self._Cb[bidx[i], bidx[j]])
306
+ if tones[i] != tones[j]:
307
+ c += self.tone_weight
308
+ C[i, j] = C[j, i] = c
309
+ np.fill_diagonal(C, 0.0)
310
+ self.cost = C
311
+ for i, t in enumerate(new):
312
+ self.tok2id[t] = old_k + i
313
+ self.tokens.extend(new)
314
+ self._tok_base = bidx
315
+ self._tok_tone = tones
316
+
317
+ def id_char_map(self, base: int = 0x100) -> list[str]:
318
+ """One distinct unicode char per token id, for token-level rapidfuzz."""
319
+ out: list[str] = []
320
+ for idx in range(len(self.tokens)):
321
+ c = base + idx
322
+ if 0xD800 <= c <= 0xDFFF:
323
+ c += 0x800
324
+ out.append(chr(c))
325
+ return out
326
+
327
+ def encode(self, tokens: Sequence[str]) -> np.ndarray:
328
+ """Phone tokens to int32 ids. Unknown tokens are dropped."""
329
+ return np.array(
330
+ [self.tok2id[t] for t in tokens if t in self.tok2id], dtype=np.int32
331
+ )
332
+
333
+ def distance(self, a_ids: np.ndarray, b_ids: np.ndarray) -> float:
334
+ return float(self._dp(a_ids, b_ids, self.cost, self.indel_cost))
335
+
336
+ def normalized_similarity(self, a_ids: np.ndarray, b_ids: np.ndarray) -> float:
337
+ la, lb = int(a_ids.shape[0]), int(b_ids.shape[0])
338
+ m = max(la, lb)
339
+ if m == 0:
340
+ return 1.0
341
+ sim = 1.0 - self.distance(a_ids, b_ids) / m
342
+ if sim < 0.0:
343
+ return 0.0
344
+ if sim > 1.0:
345
+ return 1.0
346
+ return sim